Files
alkstore/docs/research/poc-sqlite-posture-spec.md
T
glm-5.3-flash 4165c94ab0 docs: specify POC #1 — SQLite engine posture comparison (poc-sqlite-posture-spec.md)
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.
2026-10-04 09:31:46 +00:00

190 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: spec
title: "POC #1 — SQLite engine posture: honker-core-on-rusqlite vs honker-extension-over-sqlx"
last_updated: 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 in
> `docs/research/poc-sqlite-posture-findings.md` here 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_release` with 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-sqlite` feature for the POC's hermetic build), bridged to
async via a small `spawn_blocking` wrapper (measure dedicated-thread +
mpsc as the alternative, the REQ-TTY-01 double).
- Watcher: honker-core's `SharedUpdateWatcher::spawn_with_config`
(default 1 ms polling) driving `listen()` 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
via `HONKER_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-connection `extension(ext)` +
`honker_bootstrap()` on connect (the proof script's pattern, applied
to a `SqlitePool` rather 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 polling
`PRAGMA data_version` on 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
1. **Async-seam probe** — the decision's first axis. Arm A: call-path
latency and executor-neutrality of the `spawn_blocking` bridge at
realistic claim/enqueue cadence (single-digit-millisecond queue ops
under WAL-NORMAL); thread cost per connection vs a shared bridge
pool; `Send`/`Sync` trait-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.
2. **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_version` observed 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.
3. **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_BUSY`
behavior surfaced through each driver (the async error path honker's
busy_timeout story must survive). Deliverable: property-test matrix
per arm.
4. **Packaging/deployment probe** — the honest costs, not benchmarks:
Arm A's build graph (honker-core pulls rusqlite + chrono + feature
surface; `bundled-sqlite` compile 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.
5. **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.