Repository navigation
[Rewrite P3] Storage seam + LMDB backend (strfry-style) + PG→LMDB migration #864
Copy link
Copy link
Open
Labels
factoryBuilt by the software factoryBuilt by the software factorylmdbLMDB / strfry-style storage backendLMDB / strfry-style storage backendrewritenostream 4.0 Rust/LMDB rewritenostream 4.0 Rust/LMDB rewriterustRust implementation workRust implementation work
Milestone
Description
Parent: #860
Milestone: Phase 3: LMDB storage
Host: both · Expected cost: $2,400
The storage seam and the LMDB backend. This is the largest single risk in the rewrite — strfry-style embedded storage replacing PostgreSQL.
-
nostream-storagetraits first, ported from the measured repository interfaces (src/@types/repositories.ts):IEventRepository(create/upsert/findByFilters/countByFilters/deleteByPubkeyAndIds/deleteByPubkeyExceptKinds/vanish/retention),IUserRepository(balances, admission, vanished),IInvoiceRepository, plus invite-codes, dvm-jobs, nip05-verifications, reports, notification-outbox, delivery-log - Postgres backend kept during the rewrite (the parity oracle needs it): the current knex schema (37 migrations) becomes sqlx migrations; behaviour-identical
- LMDB backend design (strfry's approach):
- event rows keyed by event id; secondary indexes: pubkey+kind+created_at (replaceable resolution), kind+created_at (subscription scans), tag indexes for
#e/#p/#a/#d(strfry's per-tag-name indexes), full-text for NIP-50 (tantivy sidecar or LMDB prefix — decide in the design sub-issue) - dup-id detection via id-keyed main DB; deletion/replaceability as index updates, not row mutation
- transactions per write batch; reader/writer concurrency model mapped to LMDB's MVCC (readers never block)
- the metadata tables (users, invoices, invites, outbox) — decide: same LMDB DBs (simple, portable, no postgres at all) vs a small embedded SQL (rebook/rusqlite) for the relational tail; record the decision with reasoning
- event rows keyed by event id; secondary indexes: pubkey+kind+created_at (replaceable resolution), kind+created_at (subscription scans), tag indexes for
- NIP-50 search: NIP-50 FTS index measured in the TS tree (
20260427_000000_add_nip50_fts_index.jsmigration) — port semantics to the chosen search store, parity-tested on the sampled corpus - Migration tool:
nostream-cli migrate pg-to-lmdb— bulk copy with checksum manifest, resumable, measured throughput target ≥ 50k events/s on chappie hardware - Benchmark rig (chappie): the measured TS/PG baseline (events/s ingest, REQ latency p50/p95/p99 across 1M/10M event corpora) vs LMDB — numbers in
docs/rewrite/benchmarks.md; the cutover gate reads these
Acceptance: the LMDB backend passes the full storage-parity suite (every repository test the TS suite carries, re-expressed against both backends); benchmark report exists with the PG baseline and LMDB numbers side by side; migration tool round-trips a 1M-event corpus with zero checksum mismatches.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
factoryBuilt by the software factoryBuilt by the software factorylmdbLMDB / strfry-style storage backendLMDB / strfry-style storage backendrewritenostream 4.0 Rust/LMDB rewritenostream 4.0 Rust/LMDB rewriterustRust implementation workRust implementation work
Activity
chappie-daemon commentedon Oct 19, 2026
Factory job
007-storage-lmdbopened: baserewrite/main@b324ef5(Phase 1 + nostream-core merged), branchfactory/007-storage-lmdb, worktreeprojects/nostream-worktrees/007-storage-lmdb, factory 0.10.6, expected $2,400 (Phase 3 anchor — no measured job in this phase yet; it will be replaced, not scaled, from this job's measurement). Stage: recon.Pulled ahead of #865 on measured evidence (recorded on #860): the storage traits are missing the methods REQ/EVENT need, and #865's differential harness needs a Postgres + Redis host this sandbox does not have. So this job runs first.
One scope question the spec must settle, since the issue bundles five large surfaces (traits, Postgres kept as oracle, LMDB backend, migration tool, benchmark rig): if that is more than one job, the spec says which slice this job delivers and what moves to named follow-ups — a plan that needs more than the anchor is a finding to report, not absorb.}
chappie-daemon commentedon Oct 19, 2026
Recon complete (
recon.md, 452 lines). Headline measurements:IQueryResult.stream()(consumed by the REQ handler) has no Rust form, and DBEvent's storage-only fields (deleted_at,expires_at,first_seen,event_deduplication,remote_address) have no home innostream_core::Event.postgres:16-alpine+redis:7-alpinepublished on ports, all 37 migrations applied (Batch 1 run: 37 migrations) and 118 scenarios / 587 steps pass in 7.3 s. Containers removed afterwards.seal()sorts so negentropy imposes no index order; tantivy 0.27 builds in 42 s, rusqlite 0.40bundledin 43 s,rebookdoes not exist on crates.io; LMDB measured 48.8k events/s with scattered index keys (147.7k clustered), 3.86M point lookups/s; Postgres 17.9k events/s and a REQ-shape p50 of 11.17 ms over 200k rows.find_by_filters) + the Postgres backend as sqlx migrations + the differential parity harness; the LMDB store, NIP-50 search,pg-to-lmdband the benchmark rig become four named follow-ups with their own acceptance. The $2,400 anchor prices all five surfaces, so the follow-ups carry the remainder — recorded as the cost finding rather than absorbed.nostream-clihas neither amigratecommand nor anostream-storagedependency yet; no 1M/10M corpus exists (seeds hold 463 events, no generator).Spec stage now.}
chappie-daemon commentedon Oct 19, 2026
Spec complete (
spec.md, 1,021 lines). The issue is delivered as five jobs; this job is the first slice.QueryStream<'a, T>forfind_by_filters/find_reconciliation_itemsthat works for a Postgres portal and an LMDB read transaction, a storageEventRecordwrapper holding DBEvent's five storage-only columns (coreEventuntouched), the Postgres backend as 37 sqlx migrations, and the differential parity harness.scripts/storage-parity.sh→ sqlx migrations + a catalogue-in-fixture check, the TS-recorded 51-method differential replay (empty diff), thencucumber-jsagainst the sqlx-migrated database (118 scenarios / 587 steps). CI gains apostgres:16-alpineservice and runs the two#[ignore]d targets.sqlx 0.9(runtime-tokio, postgres, migrate, macros, chrono, json, tls-none),chrono 0.4,futures-core 0.3. Nothing added to the CLI.nostream-cli migrate pg-to-lmdb(600–1,000), benchmark rig with 1M/10M corpora (no corpus exists today).Cost, replaced on the board rather than absorbed: the $2,400 anchor priced all five surfaces; this slice is ≈49% of the measured code, so the Project item and
job.jsonnow read $1,170 for this job, with ≈$1,230 belonging to the four follow-ups. The figure is a scope split from the measured code share, not a calibration — Phase 3 still has no measured job, and this job's close will supply the first one.}chappie-daemon commentedon Oct 19, 2026
Build round 2: STATUS: READY at
8d1ec50(three commits, not pushed), after a round-1 BLOCKED on ordinary remaining work. Independently verified here, not taken frombuild.md:cargo test --workspace --all-features→ 589 passed / 0 failed / 2 ignored (base 546);fmtand both clippy passes clean; release build green.scripts/storage-parity.sh→ exit 0, all three steps: sqlx migrations from empty + catalogue match, the 110-operation TS-recorded differential replay with an empty diff, and 118 Cucumber scenarios / 587 steps passing against the sqlx-migrated database. Containers created labelled, published on ports, and removed by the script's trap.The harness found and fixed real backend defects before review: the upsert tuple appended its extra columns outside the parentheses,
inetwas read as text (…/32) instead of viahost(), and the actionable-target sort key. Two catalogue drifts the migration test caught (a default spelling, a unique-constraint name) are now matched by the migrations.One deviation recorded rather than taken: AC13's grep prints 2 because one
INSERT INTO event_tagsis the trigger function's own, and the pinned catalogue fixes that text; the set-based backfill is present and equivalent. Fixtures are generated (tests/capture/gen-storage-parity.mjs, which refuses a rewrite checkout) and committed: the wholepubliccatalogue and a 483-event / 51-method replay script including UNION, NIP-50, NIP-56, retention buckets, invoice scaling, lease and backoff paths, and two serializable transaction groups.Review scoping now.}
chappie-daemon commentedon Oct 19, 2026
Review round 1 closed: security, code, architecture and performance all returned CHANGES. The two that matter most:
scripts/storage-parity.shpublished the throwaway Postgres with the Docker default bind (0.0.0.0) and the literal superuser passwordpostgres; the reviewer verified a login and a query from another host, and Redis was unauthenticated the same way. On every harness run that is a superuser shell (COPY … FROM PROGRAM) exposed on all interfaces.Arg::Textmade sqlx declareTEXT, so the plan casts$1::texttoregconfigand cannot useevents_content_fts_idx: 444.8–671.3 ms per execution vs 0.088 ms on a 200k-event database, with the parameter typedregconfig(what node-postgres sent). Same SQL, full scan on every search.Also real: with no replicas the code still dials a second pool (double the connection budget) and
connectomitstimezone=UTC;QueryStream'sSendbound does not hold against heed's default!Sendread transaction, so the ADR's LMDB claim is unproven;sqlxis not confined behind a feature, so every dependent links the Postgres driver; the oldbackend::PostgresBackendstub survives as dead code;FieldUpdate::Clearis a no-op fortos_accepted_at;delivery_log.find_successful_target_idsis unscripted (50 of 51 methods); the retention-whitelist scenario is vacuous; and ADR 0003 lacks consequences and revisit triggers.What held up: every value reaches the driver bound (no SQL interpolation of ids, authors, kinds, tags, search text or geohash), transactions are SERIALIZABLE with server-side assertion, and the fixtures genuinely reproduce the capture — the code reviewer regenerated
schema.jsonbyte-identically from242a3001. Builder round 2 dispatched (resume 1 of 5).}chappie-daemon commentedon Oct 19, 2026
Builder round 3 (round-2 of review) at
dbf4e0f, four appended commits, 22 files, +704/−232. Verified here: workspace 592/0/2 ignored (was 589), tree clean, and the harness exits 0 with 118 scenarios / 587 steps when invoked asNODE_PATH=<primary>/node_modules PARITY_HOST=100.106.134.5 PARITY_BIND=100.106.134.5 ./scripts/storage-parity.sh.Blockers closed with proof: ports publish as
-p "$PARITY_BIND:…"with no wildcard (the builder'sdocker inspectshows the host address, and the repo has zero references to the private address); per-runopenssl rand -hex 16passwords reach-e, both URLs, the CucumberDB_URIand Redis--requirepass; NIP-50's config is guarded^[a-z_][a-z0-9_]*$and inlined so the plan is a Bitmap Index Scan onevents_content_fts_idx, 0.198 ms, asserted withenable_seqscan = off— a deliberate divergence from the capture, which binds?::regconfigand misses its own index, recorded in ADR 0003.Also fixed: one pool until a replica exists,
timezone=UTCon every constructor, heedread-txn-no-tlswith a revisit trigger, sqlx behind apostgresfeature (--no-default-featureslinks no driver), the dead stub deleted,tos_accepted_atClear now nulls,find_successful_target_idsscripted (115 ops / 51 distinct methods), the retention-whitelist scenario reordered with real counts, and ADR 0003 gained consequences plus five triggers.One open question the round-2 lenses are asked to judge: the script's new defaults (127.0.0.1) do not work here, because cargo tests run inside the builder container whose loopback is not the daemon host's — the two exports above are what make it green. Either the head or CLAUDE.md must say so, or the script must discover the right address.}
chappie-daemon commentedon Oct 19, 2026
Review round 2 closed at
7349441: security PASS, performance PASS, code PASS, architecture CHANGES.Closed with proof, not with prose:
PARITY_BINDrefuses at preflight and creates nothing;docker create -p 127.0.0.1:…reportsHostIp: 127.0.0.1), all five credential sites carry per-run passwords, and a forced admin-connection failure logs zero occurrences of the password. NIP-50's new interpolation surface is one guarded site (^[a-z_][a-z0-9_]*$, error before SQL), pinned by an injection unit test.events_content_fts_idx, 2 rows, 0.162 ms versus 371–399 ms Parallel Seq Scan for the old bound-config shape; theenable_seqscan=offassertion is meaningful (the wrong shape still plans a Seq Scan). One pool, 3 connections, not 6.#[ignore]d DB targets pass against a labelled Postgres.Architecture's four are record and build-config hygiene, not behaviour: the
postgresfeature gates the library but not the tests (so the config the ADR nominates for the LMDB follow-up cannot be built —cargo check --no-default-features --all-targetsfails), ADR trigger 4 cannot fire because the plan test EXPLAINs a hardcoded'simple'predicate rather than the composer's SQL, ADR:166 wrongly says the heed feature changes no LMDB behaviour (NO_TLSis OR-ed at env open), and the preflight misclassifiesunix:///fd:///tcp://[::1]as remote. Round 3 dispatched with those plus four cheap notes (batch constant 11 vs 10, a rename-proof redaction step, ADR op count, explicit wildcard override).}chappie-daemon commentedon Oct 19, 2026
Builder round 4 (review round 3) at
281b3b3: three commits. Verified here: workspace 594/0/2 ignored (was 593),cargo check -p nostream-storage --no-default-features --all-targetsFinished, tree clean.The architecture verdict is addressed at its cause rather than annotated: the DB-backed test targets are now
#![cfg(feature = "postgres")]and CI runs the feature-off build it claimed; the plan guard loads the fixture's settings and PREPAREs/EXPLAINs the composer's own statement, sonip50.language: englishmakes it fail — trigger 4 can fire; the ADR now says what the heed feature does (EnvFlags::NO_TLSOR-ed at env open, which is whyRoTxn: Sendis sound) and adds a trigger forEventRecordencoding; the preflight parses the daemon endpoint (onlytcp:///ssh://are remote, brackets stripped) and refuses wildcard binds, withunix://andtcp://[::1]now proceeding. The four cheap notes are in: the batch constant matches the tuple's binds, the redaction step requires1 passedso a rename cannot pass vacuously, and the ADR's counts (115 entries: 112 calls + 2 groups + 1 probe) match the fixture.}chappie-daemon commentedon Oct 19, 2026
Review round 3 at
281b3b3: security CHANGES, code CHANGES, architecture CHANGES — every round-2 finding confirmed resolved by measurement, and what remains is small and exact.Confirmed by running, not reading:
--no-default-features --all-targetsbuilds and its feature-free test list is non-empty (3 tests incl. the trait dyn-compatibility check); the plan guard now fails whennip50.language: englishis composed (Seq Scan, 215 ms) and passes withsimple(Bitmap Index Scan); the ADR's heed claim matches heed 0.20.5env.rs:295-302; the endpoint predicate matrix classifiesunset/unix:///fd:///npipe:///tcp://127.0.0.1|localhost|[::1]local andtcp://host/ssh:///remote-IP remote; and the redaction guard flips to exit 1 when the test is renamed.What still blocks:
PARITY_BIND='[::]'passes the guard and Docker bindsHostIp: "::"(measured), while the bare::arm is dead because Docker rejects that spelling.deleted_ata literalNULL), and ADR:177-180/build.md:92-93 say 112 direct calls plus one probe (fixture: 111 calls plus two probes; the 115 total is right).Round 4 dispatched (resume 4 of 5).}
chappie-daemon commentedon Oct 19, 2026
Builder round 5 (review round 4) at
edfd19d, two commits. Verified here: workspace 594/0/2 ignored, tree clean, and the wildcard guard through the real script withNODE_PATHset —[::],[::0],0::,::0000,0.0.0.0and[0:0:0:0:0:0:0:0]each exit 1 with one line (is a wildcard: publish on one address), while[::1]exits 1 with the remote-daemon line.The four items are closed at their cause: one
unbracketpass feeds both the loopback and wildcard checks, with the IPv6 wildcard recognised structurally (all characters0or:) rather than by literal;probe_dialdials the unbracketed host whilePARITY_ADMIN_URL/DB_URIkeep brackets (Node'snet.connectreturns ENOTFOUND for[::1]); ADR 0003 now says 7,281 rows at nine binds and 6,553 at ten (deleted_ata literalNULL); the fixture breakdown reads 115 = 111 calls + 2 transaction groups (7 nested) + 2 probes, 52 distinct ops = 51 + probe; andsettings_from_fixturehonoursPARITY_NIP50_LANGUAGE, withenglishmaking the migrations target fail on the indexed-expression check.Round 4 is the last resume in the budget (4 of 5).}
chappie-daemon commentedon Oct 19, 2026
Review round 4 at
edfd19d: security, code and architecture all CHANGES — but rounds 1–3 are confirmed closed, and what remains is one classification fix plus one paragraph.Real finding (security and code, same root): the wildcard guard matches characters, so Docker's dotted and mapped spellings slip through. Measured through the real script and
docker inspect:::0.0.0.0/0::0.0.0.0/[::0.0.0.0]publishHostIp:"::", and::ffff:0:0/::ffff:0.0.0.0publishHostIp:"::ffff:0.0.0.0"— proved reachable off-loopback (18793 OPENfrom another container vs18794 CLOSEDfor a loopback publish). Separately,0:0:0:0:0:0:0:1reaches the preflight though Docker normalises it to::1, the same remote-daemon evasion round 3 named for[::1].Architecture's is the ADR overclaim: the target hard-codes the shipped
simpleexpression, soPARITY_NIP50_LANGUAGE=englishfails before the EXPLAIN even if the index were rebuilt — the trigger must say what the run does and what a deployment must rebuild, and showPARITY_ADMIN_URLin the printed command (verbatim the command panics on a missing URL).Round 5 is the last resume in the budget (5 of 5): the fix is canonical address parsing (node is already required by the probe) plus a reworded paragraph, and a CHANGES after it means rounds-exhausted.}
chappie-daemon commentedon Oct 19, 2026
Final build round at
641fa42(one commit). Verified here: workspace 594/0/2 ignored, tree clean, and through the real script —::0.0.0.0-family spellings,[::ffff:0.0.0.0],0:0:0:0:0:ffff:0:0and0x0.0.0.0each exit 1 with the wildcard line and create nothing, while::ffff:7f0:1and::ffff:7ff:1correctly classify as explicit (they then fail at container start here only because those addresses are not local).The classifier now parses through WHATWG URL — bracketing IPv6 first, which the parser requires — and classifies the canonical text: wildcard
::/::ffff:0:0/0.0.0.0; loopback::1,127/8,localhost, and mapped loopback decided numerically on the first mapped group (0x7f00..=0x7fff, so the::ffff:7f…prefix false positives the operator measured are rejected); everything else explicit.PARITY_HOSTis canonicalised with it. Trigger 4 now states what the run does, shows a command that runs, and records that a deployment rebuilding the index for another language must re-run the guard against its own database — which this target cannot certify.Round 5 is the final review round in the granted budget (5 of 5 resumes): a CHANGES here means rounds-exhausted and the job escalates with the named finding.}
chappie-daemon commentedon Oct 19, 2026
Final review round at
641fa42— the last in the granted budget: security PASS, code PASS, architecture PASS, all three confirmed by measurement.::/::ffff:0:0/0.0.0.0and refuses pre-create —::0.0.0.0,[::0.0.0.0],0::0.0.0.0,[::ffff:0.0.0.0],0:0:0:0:0:ffff:0:0,::0:ffff:0:0,0.0.0.0.,0,0x0,::0000each exit 1 with zerodocker createcalls. The mapped-loopback arm is numeric:::ffff:7f0:1and::ffff:7ff:1are explicit (7.240.0.1, 7.255.0.1),::ffff:7f00:1…::ffff:7fff:ffffare loopback.PARITY_HOSTcanonicalisation keeps exactly one bracket pair in the URLs while the probe dials the plain form; userinfo/path/query inputs reduce to the host.simpleexpression, the assertion-before-EXPLAIN order, the scratch-database limit and the rejection of deriving the assertion from settings, with a command that runs.Approve stage now: the factory approver is reading the full six-round job.}
chappie-daemon commentedon Oct 19, 2026
Approve gate: ESCALATE — condition
money-access-identity. Not a correctness refusal: the approver verified the branch itself and everything holds — oracle exit 0 (redaction guard, 37-row migrations idempotent and catalogue-identical, 115-entry parity with an empty diff, Cucumber 118/587, containers removed), fixture provenance reproduced from a fresh capture (schema byte-identical, parity identical modulo two DB-generated UUIDs it never compares), the plan guard fails underenglishfor the right reason, AC1–19 met or assigned to the manager,merge-treeclean, and no test, guard or migration weakened.It escalates because the change implements the payment and access storage path (
confirm_invoice,charge_user, invoice/balance rows,admit_user/revoke_admission/is_admitted, replica routing) and adds 37 schema migrations, three workspace dependencies and a CI service — themoney-access-identity,schema-or-dependencyanddeployment-configbullets all fire, and it must not land unattended.Clears on: Cameri's word to merge, plus CI green on the pushed branch. The branch is held at
641fa42infactory/007-storage-lmdb(unpushed), worktree intact, job atneeds-human/escalated. Nothing else is outstanding on my side.}chappie-daemon commentedon Oct 20, 2026
Storage slice merged
PR #910 squash-merged into
rewrite/mainas827558a655c2a6e181f5e128e44e089361368e69(head12539d4).What landed — surfaces 1–2 only, per the job's spec decision S1:
nostream-storage: the nine*Repositorytraits (51 methods) and the record/stream types.migrations+paritytest targets,scripts/storage-parity.sh, fixtures, ADR 0001–0003.Verification at merge:
cargo test --workspacescripts/storage-parity.shCoverage note:
coverage/coverallsreported −3.1% at 70.9% on the PR..nycrc.jsoninstruments onlysrc/**/*.tsand this branch changes zero files there (0 undersrc/, 60 undercrates/), so the number is not about this diff; the two coverage rows also disagreed with each other at the same 70.9%. The Rust suite is outside that denominator.Not landed — still open on this issue:
pg-to-lmdbmigration tool.Follow-ups raised by the post-merge Greptile review (it posted after the merge; neither was a required check):
Cargo.tomlselectssqlxwith default features off andtls-none, so ansslmode=requireorverify-fullURL has no TLS provider. A TLS-required deployment cannot connect today.run_transactionpasses a freshrefsmap to every nested op, sooutbox.enqueuefollowed bymark_delivered/mark_failedin one group cannot resolve the id. One map per group, keeping committed references and discarding rolled-back ones.