Arm A: honker-core linked on our rusqlite (bridge per REQ-TTY-01, honker's watcher). Arm B: honker extension .so over sqlx-sqlite (natively async call path, own watcher, per-pool-connection extension + bootstrap — stress-testing what the CI proof script doesn't cover: pool wiring, lost connections, full surface). Option 2 (honker-rs-as-substrate) dropped from scope with reasoning: its mutex-pinned sync transaction model is subsumed by both other postures' trade space. Five probes (async seam, watcher, transactional contract, packaging, cross-process interop), a decision gate including a legitimate hybrid verdict, and out-of-scope boundaries (postgres side, full surface, extension-as-consumer-feature regardless of outcome). Phase-0: POC register added, plan/frontmatter updated; AGENTS.md: POC-register convention codified.
9.8 KiB
status, title, last_updated
| status | title | last_updated |
|---|---|---|
| spec | POC #1 — SQLite engine posture: honker-core-on-rusqlite vs honker-extension-over-sqlx | 2026-10-04 |
POC: SQLite engine posture — spec
POC register #1 (phase-0.md). Feeds OQ-ST-03 (the driver question's SQLite half) and OQ-ST-04 (the reactive contract's SQLite wake side). Code: standalone crate in the global workspace (
/workspace/alkstore-sqlite-posture-poc— self-contained, per the Phase 0 convention; POC code stays out of this repo). Findings land indocs/research/poc-sqlite-posture-findings.mdhere regardless.
What this POC must decide
OQ-ST-03 named three SQLite postures (§OQ-ST-03, option list 1–3). Option
2 (honker-rs as substrate) is dropped from POC scope: its blocking
Database API and mutex-pinned transaction model would have to sit
entirely under our async core, and both other postures give us the
machinery with more ownership — the POC's comparison between 1 and 3
subsumes the honest parts of 2 (if 3 works and 1 works, 2 is an
intermediate we can revisit; its unique value — the typed binding — is
what the core crate's trait surface supersedes anyway). The POC decides:
Posture A (library): honker-core linked on our rusqlite connection
— apply_default_pragmas + attach_notify + attach_honker_functions
bootstrap_honker_schema(the alknet-filesystem POC's usage), sync work bridged to the async core per the family precedent (alktty REQ-TTY-01: blocking impl on dedicated threads /spawn_blocking, feeding tokio channels).
Posture B (extension): honker's loadable .so under sqlx-sqlite
— SqliteConnectOptions::extension(ext) per pool connection +
SELECT honker_bootstrap(), all feature calls as plain SQL through
SqliteExecutor<'e> (pool / connection / transaction alike). CI-proven
shape (honker's own scripts/proof/orm/rust, checkout @ f4e53c6, PR
#106) — this POC does not re-prove basic wiring; it stress-tests what
the proof script does not cover.
The question is not "which is faster at raw SQL" (they call the same machinery — any SQL-path delta is small); it is which posture the sqlite engine crate should be built on, decided at four load-bearing points: the async seam, the watcher, the packaging/deployment story, and the transactional contract.
The two arms
Both arms implement the same narrow slice of the alkstore core-crate trait sketch (the trait surface is OQ-ST-04's contract question — the POC sketches just enough to compare postures, not to pin the contract):
queue_enqueue_tx/queue_claim_batch/queue_ack_batch— including the transactional-enqueue property (business write + enqueue in one tx, rollback drops both — principle 2).notify_tx(channel, payload)/listen(channel)— the reactive pair.stream_publish_tx/read_since(consumer)(offset save/read only — not the full stream contract).lock_acquire/lock_releasewith TTL, sufficient for alkfs's writer-coordination row.
Arm A — honker-core on our rusqlite (library posture)
honker-core = "0.5"(published crate, per the AGENTS.md posture — read the checkout for reference, build against the published version).- Connection model: one writer connection + reader pool (rusqlite,
bundled-sqlitefeature for the POC's hermetic build), bridged to async via a smallspawn_blockingwrapper (measure dedicated-thread + mpsc as the alternative, the REQ-TTY-01 double). - Watcher: honker-core's
SharedUpdateWatcher::spawn_with_config(default 1 ms polling) drivinglisten()wakes; listener re-reads after wake (the opaque-wake + re-read contract).
Arm B — honker extension over sqlx (extension posture)
- Build the extension from the published honker source tree once
(
cargo build --release -p honker-extension→.so); the POC loads it viaHONKER_EXTENSION_PATH(the guide's wiring; SeaORM/Toasty sections are out of scope — sqlx is the only ORM-family shape that matters here). - sqlx 0.8
runtime-tokio, per-pool-connectionextension(ext)+honker_bootstrap()on connect (the proof script's pattern, applied to aSqlitePoolrather than a single connection — the POC checks the pool story the script didn't run). - All honker SQL through
SqliteExecutor<'e>; async calls natively — no sync bridge on the call path (this is the posture's load- bearing advantage; the POC verifies it survives the full surface, not just the proof script's enqueue path). - Watcher: ours — a
spawn_blocking/std-thread loop pollingPRAGMA data_versionon a dedicated sqlx connection (the honker-core-watcher logic re-implemented thin over sqlx; no honker-core dependency in this arm — that is the point of the posture).
Instruments
- Async-seam probe — the decision's first axis. Arm A: call-path
latency and executor-neutrality of the
spawn_blockingbridge at realistic claim/enqueue cadence (single-digit-millisecond queue ops under WAL-NORMAL); thread cost per connection vs a shared bridge pool;Send/Synctrait-shape friction actually encountered when handing rusqlite types across the seam. Arm B: baseline async call cost through sqlx (prepared statements, pool checkout) — the honest floor any posture pays. Deliverable: the seam-cost table. - Watcher probe — both arms, the reactive pair end-to-end:
commit→wake→receiver-notified latency distribution (p50/p99) at the
default 1 ms cadence; wake correctness under a pool of writers
(
data_versionobserved from a different connection than the writer's — Arm B's dedicated-watcher-connection shape vs Arm A's honker-core watcher); missed-wake stress (N rapid commits, assert wake count ≥ 1 and state re-read correct — the overtriggering contract, not per-commit wakes); behavior when the watcher connection is lost (Arm B) or the watcher thread panics (both). Deliverable: the wake-latency table + failure-mode notes. - Transactional/contract probe — both arms, the property tests:
business-write + enqueue + notify inside one caller-owned
transaction; forced rollback drops both (assert no ghost rows in
_honker_jobs/notification tables); concurrent writers (two async tasks, each in a transaction, same queue) — Arm A's rusqlite single-writer contention behavior vs Arm B's sqlx pool;SQLITE_BUSYbehavior surfaced through each driver (the async error path honker's busy_timeout story must survive). Deliverable: property-test matrix per arm. - Packaging/deployment probe — the honest costs, not benchmarks:
Arm A's build graph (honker-core pulls rusqlite + chrono + feature
surface;
bundled-sqlitecompile time; version-pinning coupling between our rusqlite and honker-core's); Arm B's .so lifecycle (build-or-download per release, load path resolution, sqlx's load-then-disable extension discipline verified on the pool, macOS and system-sqlite caveats recorded but not blockers). Deliverable: the packaging-notes table. - Interop check (both arms) — two processes against one db file: writer in one process, listener in another (the cross-process wake being the whole point of the substrate). Each arm with its own watcher/extension setup on both sides.
Decision gate
Arm A is chosen iff any of:
- the async seam costs are materially worse under B than A (B's native async is the posture's whole premise); or
- the extension/pool wiring (per-connection extension + bootstrap on a real pool, lost-connection recovery) fails or proves fragile; or
- the .so packaging burden is judged unacceptable for the engine crate's zero-ops posture (REQ-1's shape: no daemon, but also no binary-artifact management at runtime).
Arm B is chosen iff:
- the seam costs are materially worse under A (bridge overhead at claim/ack cadence, thread-per-connection, trait friction), AND B's wiring + watcher story holds under the probes above; or
- sqlx's pool/prepared-statement machinery is measurably the better fit for the engine's concurrency posture, and the .so cost is accepted.
The gate may also land "A for the engine core, B's watcher pattern for wake plumbing" or similar hybrid — the probes are designed to separate the call-path question from the watcher question, and a hybrid verdict is a legitimate finding if the evidence slices that way. Either way the findings feed: OQ-ST-03's resolution (the SQLite driver posture, the tokio-postgres pg side rides separately), OQ-ST-04's SQLite wake contract, OQ-ST-06's fork/reference calculus (how much honker-core machinery each posture actually consumes), and the core-crate trait sketch's transactional seam (what a transaction handle looks like in each posture).
Out of scope
- The Postgres engine (tokio-postgres LISTEN/NOTIFY wiring — after the OQ-ST-04 contract shape is pinned; OQ-ST-03 remains coupled to it).
- The full honker surface (scheduler, rate limits, result storage — the scheduler may collapse into queues per the inventory; the POC covers enough to validate wake + queue + offset machinery).
- The loadable-extension surface as a consumer-visible feature (OQ-ST-07's cut stands regardless of this POC's outcome — option B uses the extension mechanism internally but the crate stays an in-process Rust library; the .so is never a user-facing artifact).
- Performance optimization beyond what the probes need to produce comparable numbers — both arms call the same SQL machinery, so this POC is not a bench-fest.
Register note
This POC is #1 in alkstore's register (phase-0.md), specified 2026-10-04. It is deliberately the first POC: it de-risks the SQLite side of OQ-ST-03 (the driver question the whole crate's shape leans on) with a direct A-vs-B comparison, and its trait-sketch exercise doubles as the first concrete pass at OQ-ST-04's contract pinning.