- TypeScript 100%
A scoped pull had no age filter at all: it re-fetched every `_paths` doc under the model prefix — tombstones included, and those never age out inside the 30d grace window — then re-read and re-hashed the whole local subtree. Core takes a per-model lock per workflow step, so that cost multiplied by step count. `lastPulledAt` can't gate it. Only an unscoped pull advances it, and `capabilities()` advertises `scopedSync`, so core scopes nearly every pull — on this extension's own repo the global watermark sits at 2026-04-24 against a reconcile point of 2026-08-19. Every doc under every prefix reads as new, and a probe against it can never fire. So each prefix gets its own floor in the sidecar, recorded by the completed scoped pull that established it, and read back as the later of that mark and `lastPulledAt` (a full pull hydrated this prefix too). A metadataOnly pull is excluded — it skips `data/.../raw`, so its position would hide those docs from the next pull, the same reason it leaves `lastPulledAt` alone. Absent on existing sidecars, which stay schema v2 and behave exactly as before until their first scoped pull records a floor — no deployed cache is forced through a full walk. Measured on swamp-media (@keeb/mms/organizer, ~3.5k paths), pull phase isolated from CLI startup: 96/65/67ms -> 3/3/2ms, changes=0 both ways. Deleting a freshly pushed version's three files and re-pulling restores all three, so docs past the floor still arrive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| extensions | ||
| workflows/datastore-maintenance | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| deno.json | ||
| LICENSE.txt | ||
| manifest.yaml | ||
| README.md | ||
| SWAMP.md | ||
MongoDB Datastore
Custom swamp DatastoreProvider backed by MongoDB.
Built for a mega swamp — one shared .swamp that many users and agents
read/write concurrently.
Replaces the coarse per-model file lock of the filesystem/S3 backends with finer-grained, event-driven coordination.
Requirements
- MongoDB 4.0+ running as a replica set in any configuration - single node is fine.
- Swamp CLI with extension support.
Install
Three steps. You need a swamp repo and a MongoDB replica set you can reach.
1. Pull the extension into your swamp repo:
swamp extension pull @keeb/mongodb-datastore
2. Add it as the datastore in your repo's .swamp.yaml:
datastore:
type: "@keeb/mongodb-datastore"
config:
uri: "mongodb://mongo.example.com:27017/?replicaSet=rs0&authSource=admin"
username: "swamp-user"
passwordEnv: "MONGO_PASSWORD"
database: "swamp"
tenantId: "my-org"
namespace: "my-repo"
See Configuration for field-by-field descriptions.
3. Put your MongoDB password in <repoDir>/.env (gitignored):
MONGO_PASSWORD=...
Swamp picks it up on the next invocation.
Configuration
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
uri |
string | yes | — | MongoDB URI. Must resolve to a replica set. |
username |
string | yes | — | Mongo user, passed to the driver as an auth option. |
passwordEnv |
string | no | MONGO_PASSWORD |
Env var name holding the password. Loaded from <repoDir>/.env at startup. |
database |
string | no | swamp |
Shared database; per-repo isolation is by collection prefix. |
tenantId |
string | no | default |
Tenant identifier; part of the collection prefix. |
namespace |
string | yes | — | Per-repo identifier; part of the collection prefix. |
defaultLockTtlMs |
number | no | 30000 |
Default lock TTL. Must exceed your longest critical section. |
maxPoolSize |
number | no | 20 |
Max connections in the pool shared by every provider in the process. |
maxIdleTimeMS |
number | no | 60000 |
How long an idle pooled connection is kept before the driver closes it. |
One MongoClient is shared per cluster + repo for the life of the process, and
every connection is stamped with an appName of
swamp:<tenantId>/<namespace>#<pid> so $currentOp and mongod's logs can say
which repo and process a connection belongs to.
Collections are prefixed t_<tenantId>_r_<namespace>_* — _locks for lock
docs, _paths for the manifest, _blobs for content-addressed bytes.
What it does
- Distributed lock.
findOneAndUpdateon a lock doc, TTL + heartbeat refresh, nonce fenced onreleaseandforceRelease. Global + per-model keys. - Manifest + content-addressed blob sync. The datastore-tier cache tree
(
.swamp/<cache>/{data,outputs,workflow-runs,...}) is split across two collections:_pathsholds one doc per file ({_id: relPath, hash, size, updatedAt, deletedAt}) and_blobsholds bytes keyed by their sha256. Pull = cursor over_pathssince the last watermark + bulk$inover_blobsfor the unique hashes the host doesn't already have. Push = hash locally, upsert any blob that's missing (idempotent on the hash_id), upsert path docs in bulk. Identical bytes pushed by N agents collapse to one blob server-side; renames are free; the cursor itself is the wire transport (no per-file roundtrips). - Dirty tracking via an append-only journal.
markDirtyappends one line to<cache>/.datastore-dirty.log— no read, no parse, no rewrite. The JSON sidecar next to it (.datastore-sync-state.json) holds only scalars (watermarks and flags) and is rewritten only when one of them changes. On push, the journal is deduped and coalesced: a dirty directory absorbs every dirty path beneath it, since the push walks a dirty directory in full. PastMAX_DIRTY_PATHS(10k) tracking degrades to a single full walk, which is cheaper than reconciling that many roots individually. - Health verifier. Rejects non-replica-set clusters and reports primary/secondary state, latency, and namespace.
Maintenance
-
Blob GC.
_blobsis append-only by design — dedup means no push can know whether some other path still references a hash — so tombstoning a path leaves its bytes behind.Reclamation runs through the
sweepmethod — see Run it as a workflow below. It sweeps every namespace in the cluster by default, including ones whose owning checkout has moved away:# Dry run is the default; nothing is deleted until you say so. swamp model method run datastoreMaintenance sweep swamp model method run datastoreMaintenance sweep --arg dryRun=false swamp model method run datastoreMaintenance sweep --arg dryRun=false \ --arg 'namespaces=["other-repo"]' --arg skipBlobs=trueA push inserts a blob before upserting the path doc that references it, so a sweep landing between the two would delete bytes a peer is about to point at. Two defenses, in order of importance:
- Grace window (the real one). Blobs carry
createdAt; anything younger thangraceMinutes(default 60) is spared regardless of reachability. - Global lock, held for the sweep's duration — defense in depth only.
Swamp core does not funnel every write through it. A real sweep that held
the lock still lost one blob to a concurrent push, which is why the grace
window exists. Blobs written before 2026.08.19.1 have no
createdAtand are always eligible, so the first sweep after upgrading is the risky one: run it when the cluster is quiet.
Concretely: a namespace that is actively written and whose blobs all predate
createdAthas no protection at all — every unreferenced blob looks eligible, including one a push inserted a second ago. Either quiesce the writer first, or setskipBlobs: trueto take just the tombstones (which have no such race) and come back for the bytes during a maintenance window.A dangling reference is not fatal — pull skips a path whose blob is missing, and the owning host re-uploads the bytes on its next full walk, since the push probes blob existence independently of the path diff. Do not "fix" one by tombstoning the path: that deletes the owning host's local copy on its next pull.
- Grace window (the real one). Blobs carry
-
Tombstone pruning. Always runs as part of
sweep, before the blob pass — tombstones are not blob references, so dropping them never strands bytes, and doing it first lets the blob pass collect whatever they were the last trace of. A tombstone is how a deletion reaches peers — pull seesdeletedAtand unlinks the local copy — so_pathsaccumulates them forever. On one real repo they were 855,438 docs against 21,171 live.Pruned tombstones are hard-deleted, which makes the deletion invisible: a peer whose
lastPulledAtpredates it never learns the file is gone and will re-upload it on its next full walk. The grace window (tombstoneDays, default 30) must therefore exceed the longest gap between any peer's syncs — the same trade-off as Cassandra'sgc_grace_seconds, with the same failure mode if set too low. A host dormant longer than the window should be re-bootstrapped rather than allowed to push. -
Reclaiming disk after a sweep. Deleting documents returns space to WiredTiger's free list, not to the filesystem — a swept cluster still reports its old disk usage until compacted.
_blobssat at 44.4 GB allocated with 44.1 GB reusable; compacting took it to 185 MB.swamp model method run datastoreMaintenance compactThe method issues
compactwithforce: true, which is required on a replica-set primary and slows concurrent operations for its duration — it is the slow step of the workflow (15 minutes on a 44 GB collection), so run it when the cluster is quiet. Collections holding less thanminReusableMb(default 1) are skipped. -
Version retention is swamp's job, not the datastore's. The largest sync costs come from unbounded data versions, which this extension faithfully mirrors. Check
garbageCollectionon your model types' output specs and runswamp data gc— on one real repo that tookdata/from 229,598 files to 1,258.autoGc: truein.swamp.yamldid not keep up; schedule it. -
Run all of the above as a workflow. The extension ships a companion model type,
@keeb/mongodb-datastore/maintenance, so reclamation composes into swamp rather than sitting in a shell script beside it. ADatastoreProviderhas no method surface a workflow can call; this model closes that gap, using the same sweep functions the CLI uses.swamp vault create local_encryption datastore-vault swamp vault put datastore-vault MONGO_PASSWORD swamp model create @keeb/mongodb-datastore/maintenance datastoreMaintenance \ --global-arg uri='mongodb://mongo.example.com:27017/?replicaSet=rs0&authSource=admin' \ --global-arg username=swamp-user \ --global-arg 'password=${{ vault.get(datastore-vault, MONGO_PASSWORD) }}' \ --global-arg database=swamp --global-arg tenantId=my-org swamp workflow run @keeb/datastore-maintenanceThree methods, each fanning out over every namespace in one execution — looping
model method runagainst a single model serializes on its lock:Method Does inventoryLive paths, tombstones, blobs, allocated vs reusable bytes, idle days sweepPrune tombstones past grace + blobs no live path references compactReturn freed space to the filesystem sweepdefaults todryRun: true— opt in to deletion. It also refuses to sweep blobs in a namespace that is both actively written and holding only pre-createdAtblobs, recordingblobsSkippedandskipReasonin its output instead of racing an in-flight push.Results are ordinary swamp data, tagged with workflow provenance:
swamp data query 'modelName == "datastoreMaintenance" && specName == "sweep"'This is the piece that prevents a repeat. The incident that motivated the 2026.08.19.1 rewrite was not a protocol bug — it was retention drift nobody was watching. Pair it with
swamp data gc, which is what actually keeps the corpus small; everything here cleans up after it.
Important Information
-
Vault secrets do not travel. Swamp's
local_encryptionvault reads and writes<repoDir>/.swamp/secrets/...on local disk regardless of datastore. This extension excludes thesecrets/tier from sync entirely — neither the symmetric.keyfiles nor their.encciphertext are ever pushed to MongoDB, and anysecrets/*docs left in the remote by an older version are skipped on pull. Vault contents stay per-host; use a non-local (KMS-backed) vault if you need cross-host secrets.Security note (versions ≤ 2026.05.25.1): earlier releases listed
secretsin the synced tier, so a repo that switched to this datastore pushed every vault.keynext to its.encciphertext into the shared MongoDB — anyone with read access could decrypt them (CVE-class encryption-at-rest defeat). After upgrading, rotate every secret that was synced and purge the leaked docs from MongoDB, e.g.:// hashes of the now-orphaned secret blobs, to drop after tombstoning paths const hashes = db["<prefix>_paths"].find( { _id: /^secrets\// }, { hash: 1 }, ).map((d) => d.hash); db["<prefix>_paths"].deleteMany({ _id: /^secrets\// }); db["<prefix>_blobs"].deleteMany({ _id: { $in: hashes } }); -
TTL must exceed your critical section. The lock's nonce fences
release/forceReleaseonly; it does not fence writes performed inside the critical section. If a holder pauses past TTL, another process can legitimately take over while the first still believes it holds the lock. SizedefaultLockTtlMswith margin. -
swamp datastore setupcan OOM on large existing.swamp/trees. Swamp core's migrator reads the tree into memory; at ~1 GB / ~100k files it dies. Purge.swamp/first, or use--skip-migrationand let workflows repopulate. -
Host-local files are never synced.
*.db,*.db-wal,*.db-shm(swamp's SQLite catalogs) and in-flight*.tmp.<pid>.<uuid>staging files are excluded on both legs. A-shmfile is a mmap'd shared-memory region that means nothing off the machine that made it, and both it and-walchurn on every command — syncing them re-uploaded a blob per invocation for bytes no peer could correctly consume. -
Two watermarks, not one.
lastPulledAttracks hydrated content and drives pull;lastReconciledAttracks when this cache last enumerated the complete remote path list and drives the push tombstone pass. They must stay separate: a push stampsupdatedAt = nowon every path it writes, so those docs sort newer thanlastPulledAtand the tombstone pass — which skips anything newer, to protect a peer's concurrent writes — would refuse to ever delete them. The symptom is a host that cannot propagate deletion of data it pushed itself:swamp data gcprunes locally and the remote keeps every version.
Related
@keeb/mongodb — sibling extension for
querying MongoDB collections from swamp workflows. Different extension (a
model , not a datastore).
Development
Contributor notes: CLAUDE.md and SWAMP.md.
License
MIT.