A Checkpoint Saver That Doesn't Ask for a Database
I kept hitting the same wall building LangGraph agents: the moment I wanted checkpoints to survive a restart, the official savers wanted a Postgres instance or a SQLite file sitting on a disk I had to manage myself. Fine for a long-running server. Awkward for anything that deploys as a container with no attached volume, which by then was most of what I was shipping. I already had a bucket. I didn’t want a database next to it just to remember where an agent left off.
So I built langgraph-checkpoint-objectstorage: one ObjectStorageSaver class that reads the scheme off a connection string (file://, s3://, gcs://) and picks the backend accordingly. No subclass per provider, no
config object with a backend name and three sets of credentials fields.
Change the string, change where checkpoints live. That constraint, one
class covering all three backends, ended up shaping everything else about
the package.
It also created a trap I walked into almost immediately. LangGraph’s BaseCheckpointSaver contract requires both sync and async methods
(put/aput, get_tuple/aget_tuple, and so on), and my first instinct
was to write both, since the async I/O libraries for S3 and GCS
(s3fs, gcsfs) are genuinely async under the hood. I got about halfway
through a second, sync-flavored implementation of the same key-listing
logic before I noticed I was fixing the same bug twice, in two places,
worded slightly differently each time. That’s the kind of drift that turns
into a support ticket eighteen months later when someone finds the one
method where the sync and async paths quietly disagree.
I threw out the second implementation. The business logic (key layout,
filtering, ordering, idempotency) is written once, as async methods. The
sync public API is a thin asyncio.run(...) wrapper around that same core,
not a parallel implementation:
Underneath that core sits an I/O bridge that picks native async calls when
the backend filesystem supports them, and falls back to asyncio.to_thread when it doesn’t. Local disk has no async API to speak
of, so it just runs on a thread, and nobody outside the bridge can tell the
difference. One source of truth per operation, not two implementations
drifting apart a release at a time.
The other decision I didn’t compromise on: every checkpoint and every write becomes its own object, named by thread, namespace, and checkpoint ID, never a row updated in place.
No read-modify-write on an existing key means two writers on different
threads can never race each other, and a single put call is always a
single write, not a read followed by a write with a gap in between where
someone else’s write could land. It costs something: list() with a
filter has to fetch every checkpoint in a thread and filter client-side,
since object storage has no query engine to push a filter into. For the
threads I actually run, dozens to low hundreds of checkpoints, that trade
is free. I wrote it down in the README anyway, because “free for now” is
exactly the kind of claim that stops being true silently.
Encryption raised a sharper version of the same question — what “wrong” should even mean here. Encrypting the payload bytes with AES-256-GCM was an afternoon’s work. Deciding what happens when someone moves an encrypted object somewhere it doesn’t belong took longer. I bound the cipher’s associated data to the object’s full storage identity — thread, namespace, checkpoint ID, task, write index. Move that object to a different thread and it doesn’t quietly decrypt under whatever key that path happens to resolve to. It just fails to decrypt at all. I’d rather a checkpoint refuse to open than open into the wrong conversation (see ADR 0004 for the full design).
The part I was most tempted to skip was testing against the real
contract instead of my own assumptions about it. It would’ve been easy
to write unit tests that check what I built does what I meant it to do,
which mostly just confirms I’m consistent with myself. Instead I ran the
saver through langgraph-checkpoint-conformance, the same contract test
suite LangGraph’s own sqlite and postgres savers are held to, against all
three backends: local disk, and S3 and GCS through local emulators so CI
doesn’t need a cloud account to prove anything. That’s what caught the put_writes overwrite rule I’d gotten backwards on my first pass: regular
channels are first-write-wins, but the control channels
(ERROR/SCHEDULED/INTERRUPT/RESUME) always replace. My tests
passed. The conformance suite didn’t, until I matched the official savers
exactly. I’d rather find that out in CI than from someone’s production
agent losing an interrupt.
Testing caught the correctness bugs. Performance didn’t get to hide behind a guess either — I built a real benchmark suite before I let myself believe anything about how fast this was, and local disk turned out to be paying a tax nobody had noticed: every sync call was spinning up a fresh event loop just to tear it down again, because the sync API is a thin wrapper around the async core underneath it. One persistent background loop per saver instance, reused across every call instead of rebuilt for each one, and the local-disk numbers moved together across every single operation, because they’d all been quietly paying the same hidden cost (see ADR 0006 for the profiling that found it).