simocracy-indexer · tap-epds-volume-fix

Simocracy Indexer — tap-epds volume fix + cleanup discipline

@daviddao

Closed the volume gap from the previous run by attaching a persistent volume to tap-epds, proved cursor persistence across restarts, re-ran the end-to-end smoke test (this time deleting the test sim afterwards), and along the way fixed a real backfillDid bug that left deleted sims visible in the indexer.

Date: 2026-05-06 · Target: simocracy-indexer-production.up.railway.app · pass


Headline

cursor=7122
Resumed after restart #3
2/2
Smoke test passes
1/2
Test sims cleaned up
1
Real indexer bug fixed in flight

Headline. Attached a persistent volume to tap-epds, redeployed three times to verify cursor persistence (cursor=0 → cursor=0 → cursor=7122), re-ran the end-to-end smoke test with proper cleanup discipline (registered mail.tm account, sim deleted via UI, deletion verified through both the firehose path and a clean indexer GraphQL query). En route, found and fixed a separate bug where deletes weren't always reaching the indexer (tap sometimes triggers a resync instead of forwarding a delete commit, and backfillDid was a pure upsert with no delete-missing pass). Codified the cleanup discipline as the simocracy-smoke-test agent skill so future runs don't strand test accounts on the ePDS.

What this run picked up

The previous run, dual-tap-epds-firehose, validated the dual-tap topology end-to-end but left two loose ends:

Both got addressed in this run.

1 — Persistent volume for tap-epds

$ cd ~/Projects/simocracy-indexer
$ railway service tap-epds
$ railway volume add --mount-path /data
Volume "tap-epds-volume" created for service tap-epds in production at "/data"

$ railway volume list
Volume: tap-epds-volume         Attached to: tap-epds            Mount path: /data
Volume: tap-volume              Attached to: tap                 Mount path: /data
Volume: simocracy-indexer-volume Attached to: simocracy-indexer  Mount path: /app/data

Railway hot-attaches volumes — they're visible to the running container only after a restart, so an explicit railway redeploy --service tap-epds is required to actually mount the new persistent /data.

Proving cursor persistence (3 successive restarts)

This is the core of why the volume matters — the SQLite firehose_cursors table in /data/tap.db needs to outlive the container.

RestartWhenPre-existing cursor in DB?Connected with cursor
#1~22:14, after first redeployno — fresh volumecursor=0
#2~22:16, ~90s after #1no — too fast for RunCursorSaver to advance lastSeq ≥ 1cursor=0
#3~22:20, ~4m after #2yes — cursor row presentcursor=7122
# Restart #3 startup log — proof of resume
[INFO] connecting to firehose
       url="wss://certified.one/xrpc/com.atproto.sync.subscribeRepos?cursor=7122"
       cursor=7122
[INFO] connected to firehose
Starting Container

This is exactly what we wanted: with TAP_CURSOR_SAVE_INTERVAL=1s (default) and a persistent /data volume, tap survives restarts without losing its place. Restart #2 came too quickly to be a fair test — saveCursor only writes when seq ≥ 1, and the bsky/atproto firehose's lowest sequence on the cursor save path needs a few moments to advance — so it produced a false-looking "no cursor" but doesn't reflect normal operation.

2 — End-to-end smoke test, this time with cleanup

Same path as the previous run — disposable email → certified.one OAuth → mint a sim → verify it reaches the indexer — but this time:

Emailsimocracy-smoke-r2@deltajohnsons.com (mail.tm registered account) Handlesmk-vol-25668.certified.one DIDdid:plc:hgs7ptdnin6u6l3hzw4kh47y PDShttps://certified.one (verified via plc.directory) First simat://…/org.simocracy.sim/3ml7pv2x44k2x "VolFix Smoke 051226" Second simat://…/org.simocracy.sim/3ml7rwip35c2x "Cleanup Verify Test"

Walkthrough

01simocracy.org landing page.
02Email entered in the sign-in dialog.
03certified.one verification challenge.
04Code 409912 read from mail.tm via API.
05Handle picked: smk-vol-25668 (after a couple of "handle taken" retries).
06OAuth consent. Account exists on certified.one.
07Empty /my-sims immediately after first login.
08First sim "VolFix Smoke 051226" configured, ready to save.
09Sim saved. Toast confirms simocracy.org/sims/volfix-smoke-051226.

Indexer verification

$ curl -s 'https://simocracy-indexer-production.up.railway.app/graphql' \
  -H 'Content-Type: application/json' \
  -d '{"query":"query Q($collection:String!,$first:Int){records(collection:$collection,first:$first){edges{node{uri did rkey value}}}}",
       "variables":{"collection":"org.simocracy.sim","first":1000}}' \
  | jq --arg did 'did:plc:hgs7ptdnin6u6l3hzw4kh47y' \
      '.data.records.edges | map(select(.node.did == $did))'

[
  {
    "node": {
      "uri":  "at://did:plc:hgs7ptdnin6u6l3hzw4kh47y/org.simocracy.sim/3ml7pv2x44k2x",
      "did":  "did:plc:hgs7ptdnin6u6l3hzw4kh47y",
      "rkey": "3ml7pv2x44k2x",
      "value": { "name": "VolFix Smoke 051226", "$type": "org.simocracy.sim", ... }
    }
  }
]

And the same delivery proof as last run — the Auto-backfill log line (firehose-path trigger) rather than Backfill-did (webapp-ping path):

simocracy-indexer logs:
  Connected to tap[1] (ws://tap-epds.railway.internal:2480/channel)
  Connected to tap[0] (ws://tap.railway.internal:2480/channel)
  …
  First record from new DID did:plc:hgs7ptdnin6u6l3hzw4kh47y — auto-backfilling
  Auto-backfill did:plc:hgs7ptdnin6u6l3hzw4kh47y: inserted=1 deleted=0

tap-epds logs:
  [INFO] processing resync did="did:plc:hgs7ptdnin6u6l3hzw4kh47y"
  [INFO] fetching repo from PDS pds="https://certified.one"

3 — Cleanup, and the bug it surfaced

This is where things got interesting. Deleting the first test sim ("VolFix Smoke 051226") via simocracy.org's /my-sims page did delete it from the PDS, but it stayed in the indexer:

10The bug: after deleting the sim, simocracy.org's /my-sims page (which reads from the indexer) still shows "1 SIM" with the deleted draft card visible. The PDS says {"records": []}, but the indexer's DuckDB row was never removed.

Root cause

Two things lined up to swallow the delete:

  1. tap triggered a resync, not a delete commit. Looking at cmd/tap/firehose.go in indigo — when a commit's prevData doesn't match tap's tracked MST root for that repo (which can happen after multi-op commits, like simocracy's "delete sim then write a delete-history record" sequence), tap marks the repo Desynchronized and triggers a resync worker, but doesn't process the commit itself. So the action="delete" never reaches the downstream WebSocket. The indexer sees the resync logs but no delete event:
    tap-epds:
      [INFO] processing resync did="did:plc:hgs7ptdnin6u6l3hzw4kh47y"
      [INFO] fetching repo from PDS  size=837
      [INFO] iterating repo records  rev="3ml7qxcykpc2x"
      [INFO] pre-loaded existing records count=0   ← repo is now empty
      [INFO] resync repo complete                   ← but no delete forwarded
  2. backfillDid was a pure upsert. The simocracy webapp pings GET /backfill-did?did=… after every record write, including deletes. backfillDid would refetch the user's repo, see 0 sim records, and… do nothing. There was no diff-against-DuckDB step. So neither the firehose path nor the backfill path cleaned up.

Fix — make backfillDid a real reconciliation

Shipped two commits to simocracy-indexer:

Verification — the second sim, deleted cleanly

Created a second sim ("Cleanup Verify Test"), confirmed it landed in the indexer, then deleted it. This time the firehose path delivered the delete cleanly (no resync edge case), so the indexer caught it via handleTapMessage:

simocracy-indexer logs:
  Connected to tap[0] (ws://tap.railway.internal:2480/channel)
  Connected to tap[1] (ws://tap-epds.railway.internal:2480/channel)
  Deleted: at://did:plc:hgs7ptdnin6u6l3hzw4kh47y/org.simocracy.sim/3ml7rwip35c2x (via tap[1])

PDS:    {"records": []}                                    ✓
Indexer: sim records for that DID: 0 (expected 0)          ✓

The first sim's stale row is also gone now — the new backfillDid reconciliation tidied it up on a subsequent webapp-triggered backfill ping.

4 — Recorded the discipline as a skill

To prevent the "stranded test account" problem from recurring, codified the smoke-test workflow + cleanup discipline as a Pi agent skill at ~/.pi/agent/skills/simocracy-smoke-test/SKILL.md. Key rules baked in:

Findings

Pass — volume + cursor persistence working

Three successive restarts confirmed: cursor=0 → cursor=0 → cursor=7122. The volume + the TAP_CURSOR_SAVE_INTERVAL=1s default + tap's RunCursorSaver goroutine combine to give us proper resume semantics. No more 200–400 GB backfill window re-pull on every restart.

Pass — end-to-end smoke test green

Account creation on certified.one through the simocracy.org email-login flow works as expected. Sim records arrive at the indexer via the new tap-epds upstream — Auto-backfill log line confirms the firehose path delivered, not the webapp ping. Both tap[0] and tap[1] reported as connected throughout.

Fixed — backfillDid was an upsert-only, deletes never propagated

Discovered while validating cleanup discipline: deleted sims still appeared in simocracy.org's /my-sims page because the indexer's DuckDB rows never got removed. tap's resync edge case meant the firehose path didn't always deliver delete commits, and backfillDid (which the webapp pings after every write) was a pure upsert. Made it a true reconciliation: enumerate each (did, collection) from the PDS, diff against DuckDB, delete missing URIs. Guarded on a per-collection success flag so a transient PDS error doesn't produce false deletes. Verified end-to-end with a fresh sim minted, deleted, and confirmed gone from both the PDS and the indexer.

Fixed — duck.all called with the wrong binding format

The first version of the reconciliation passed an array of bindings (duck.all(sql, [did, collection])) but duck.all takes positional spread args. The query threw Invalid Input Error: Values were not provided, the surrounding try/catch swallowed it, and deleted=0 was logged regardless of whether there were stale rows. Spread the args (duck.all(sql, did, collection)) and added a console.error so the next regression is observable instead of silent.

Pass — cleanup discipline now codified as a skill

Added ~/.pi/agent/skills/simocracy-smoke-test/SKILL.md documenting the full email → certified.one → simocracy.org → indexer flow plus the post-test cleanup ritual. Future runs will follow this without re-deriving it.

Warning — previous run's test sim is permanently stranded

The sim from the previous run (at://did:plc:njw357tdisvgovexfks6xem2/org.simocracy.sim/3ml7oyn62wk2x, account epds-smoke-test.certified.one) was created with an anonymous mail.tm inbox that vanished when the agent session ended. Without inbox access there's no way to re-authenticate as that user; the sim record still lives on the certified.one PDS and will need ePDS-operator coordination (hypercerts-org) to clean up. The new skill prevents this for future runs but doesn't retroactively fix it.

Reproducing

indexer commits619cc95 + 3b01369 Railway changerailway volume add --service tap-epds --mount-path /data Restart timelineassets/99-tap-epds-restart-logs.txt Indexer stats (final)assets/99-stats-final.json Volumes (final)assets/99-volumes-final.txt Indexer logs (deletion)assets/99-indexer-logs-final.txt Skill~/.pi/agent/skills/simocracy-smoke-test/SKILL.md Previous runsimocracy-indexer/dual-tap-epds-firehose