Files
alkstore/docs/architecture/engine-sqlite.md
T

224 lines
15 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: stable
last_updated: "2026-10-11 (the notify prune-cadence note — the
notifications table's at-attach-only hygiene sharpened, accepted at
the growth-posture review)"
---
# SQLite engine
The `alkstore-sqlite` engine implements
[core-contract.md](core-contract.md) on rusqlite over the
[forked honker-core substrate](decisions/011-sqlite-substrate-fork.md) —
carried in-tree as the engine crate's substrate module subtree
([ADR-013](decisions/013-fold-substrate-into-sqlite.md)). This spec
records WHAT the engine is internally (its connection architecture,
seam, and wake plumbing) — not code-level HOW. Decisions live in ADRs;
per-contract obligations live in the core spec and are not restated here.
## Identity and posture
- Single driver: rusqlite, riding the
[forked honker-core substrate](decisions/011-sqlite-substrate-fork.md)
— the forked lineage machinery carried in-tree as this crate's
`src/substrate/` module subtree
([ADR-012](decisions/012-forked-substrate-design.md) design,
[ADR-013](decisions/013-fold-substrate-into-sqlite.md) packaging) —
with `bundled-sqlite` for hermetic builds. No `.so` runtime artifacts
([ADR-003](decisions/003-sqlite-driver.md) for the driver decision;
ownership resolved by
[ADR-011](decisions/011-sqlite-substrate-fork.md) — OQ-06's read
fired ADR-005's fork trigger).
- Sync machinery, async-facing trait: honker-core is sync (std
threads, blocking iterators); the engine bridges at the trait seam
per the family-standard posture (alktty REQ-TTY-01 precedent).
Every engine call runs in `spawn_blocking`.
- Single-host by nature: file-backed, one machine, NFS-two-writers
unsupported (honker's honesty posture, inherited). See
[deployment.md](deployment.md); the boundary's surface location is
decided ([ADR-016](decisions/016-deployment-honesty.md)) — this
crate's identity and docs state the posture; no runtime descriptor
exists.
## Connection architecture
- **Writer** — one dedicated connection; the only one permitted to
write. Serializes all mutations (WAL single-writer, modeled
honestly, not fought).
- **Readers** — a small pool of read connections for lookups and
claim/ack work.
- **Watcher** — honker-core's `SharedUpdateWatcher`: a dedicated
thread polling `PRAGMA data_version` at the default 1 ms cadence,
fanning out to listeners, overtriggering on purpose (waking all
subscribers per poll tick, even when several commits coalesced
inside one tick — wake is a hint; consumers re-read indexed state,
[ADR-006](decisions/006-wake-and-delivery-contract.md)). The 1 ms
default is the shipping default — the POC's published wake-latency
numbers were measured at it
([ADR-023](decisions/023-fourth-review-round.md) §4); the cadence
is tunable via `SqliteOpts::poll_interval`
([ADR-008](decisions/008-contract-v1-pinning.md) §6's config split
— engine opts carry cadence), the documented idle cost and tuning
recipe in [deployment.md](deployment.md).
- Each connection runs the substrate's bootstrap at open: pragmas
(WAL, `synchronous=NORMAL`, busy timeout), `attach_notify`,
the substrate's function attachments and schema bootstrap (the
alknet-filesystem POC's wiring shape). Watcher spawn is fallible in
the substrate ([ADR-012](decisions/012-forked-substrate-design.md)
§4); open fails if the watcher cannot start.
- **The `open` path argument is a plain filesystem path**
([ADR-023](decisions/023-fourth-review-round.md) §3): the
substrate's `open_conn` drops the inherited `SQLITE_OPEN_URI` flag
(a registered fork delta), so `?name=value` suffixes in the path
are literal filenames — no URI interpretation, no connection-
semantics mutation outside the constructor's opts (`:memory:` works
without URI mode; URI-only features like shared-cache memory DBs
re-enter with a consumer-inventory row).
## Mapping the contract
| Contract piece | Engine realization |
|---|---|
| notify / listen | honker's notify functions inside the caller's tx; `listen()` bridges the watcher's fanout into a tokio receiver (one `spawn_blocking` thread per subscription doing `blocking_send`) |
| streams | the forked substrate's stream machinery (inherited near-verbatim — the `__alkstore_stream` table's field set is already the contract's shape (its `topic` column carries the contract's `stream` field — the one name delta, ADR-012 §3's fidelity posture), the offset-ASC read path, keyed publish's nullable key column, monotone offset saves ([ADR-015](decisions/015-streams-depth.md)); explicit offset saves through the tx seam; `trim_to` is a `DELETE FROM … WHERE offset <= ?` inside the writer-slot lease) |
| queues | owned queue machinery (forked substrate re-derived on [ADR-010](decisions/010-queue-semantics-depth.md) — stamps, no-stranded-rows sweep, dead-visible `get_job`); ADR-003's ride posture superseded on ownership by [ADR-011](decisions/011-sqlite-substrate-fork.md) |
| named locks | the substrate's lock machinery (`lock_renew` carries the renew semantics the guarantee row pins; re-acquire does not refresh TTL — inherited deliberately, [ADR-012](decisions/012-forked-substrate-design.md) §4) |
| scheduler / outbox | collapse shape ([ADR-009](decisions/009-scheduler-collapse.md)): owned scheduler storage (`__alkstore_scheduler_tasks`, forked substrate) carrying `@every` specs (cron machinery not ported); the leader loop pattern (TTL lock, heartbeat, exit-before-tick-on-loss); the leadership lock is `__alkstore_scheduler`; outbox = helper over queues with the derived backing-queue name ([ADR-008](decisions/008-contract-v1-pinning.md) §4) |
| begin_tx | acquires the writer slot, opens `BEGIN IMMEDIATE`, returns the caller-held handle ([ADR-007](decisions/007-transactional-seam.md)) |
| handle ops | each `*_tx` op round-trips `spawn_blocking` to the same connection (thread-affinity note in [ADR-007](decisions/007-transactional-seam.md)) |
| commit/rollback | releases the writer slot |
## Obligations and constraints
- **A long transaction parks the writer** (the slot lease is the
honest model). Contract docs must surface this so consumers budget
transactions ([ADR-007](decisions/007-transactional-seam.md) negative
consequence).
- Watcher failure handling is inherited: on watcher death, every
subscriber's receiver closes (`WatcherDeathGuard` behavior) —
consumers see the close, never a silent hang
([ADR-006](decisions/006-wake-and-delivery-contract.md)). New
subscriptions issued *after* a watcher death (a `listen()` or a
stream `subscribe()`) fail closed with the opaque `Error::Database`
fallback — they never enroll onto a dead feed to park forever
(review 004 item 2); recovery is a fresh store open. The watcher
also treats the db file's identity as unknown until its first
successful stat: a failed startup stat does not seed a false
identity that the first later stat would spuriously trip into the
death path (review 004 item 3 — a transient stat hiccup at start
closes nothing).
- Wake coalescing: bursts inside one poll tick produce one wake;
correctness is preserved by the re-read contract
(POC-pinned: missed-wake stress with correct post-burst re-reads).
- **Deployment note**: the lineage's rusqlite generation
(^0.40.1) — this engine crate's own dependency — has a rustc floor
≥ 1.99; binaries linking this engine
carry that requirement ([deployment.md](deployment.md) matrix).
- The optimization path, if seam throughput ever demands it: a
dedicated std-thread bridge (one thread owning the writer conn, ops
over mpsc — measured ~2× the spawn_blocking shape at p50 in POC #1),
or a raised watcher cadence for idle CPU. Neither is the default.
### Notifications-table hygiene
The `__alkstore_notifications` table (this engine's notify wake
transport) is pruned to its newest 10,000 rows, oldest-first —
the at-attach cap (ADR-010 §6, register D-16) — and the attach is
the **only** prune site: the prune runs at every connection open (the
substrate's bootstrap); there is no background sweeper, no
time-based retention, and no consumer-visible prune operation — the
table stayed off the contract surface by ADR-008 §4's disposition
(upstream's `prune_notifications*` helpers are not carried).
The consequence a deployer should size honestly: an idle-but-listening
process — one that opens its connections at startup and holds them —
attaches never, so nothing trims the table; every committed notify
across the fleet lands a row and the table grows past the cap until
the next attach (a reconnect, a second `open` of the same file by any
process, or a restart). Attach-prune is hygiene at open, not retention
at runtime. Long-lived, notify-heavy single-process deployments should
either budget an attach event into their runbook or size the table
deliberately. Accepted with that caveat at the growth-posture review;
the per-engine record lives in [deployment.md](deployment.md)'s growth
postures.
## What the fork question resolved
The [quality read (OQ-06)](../research/quality-read-honker-core.md) —
this engine's dependency gate — resolved 2026-10-05: ADR-005's fork
trigger **fired** ([ADR-011](decisions/011-sqlite-substrate-fork.md)),
and the fork's design is pinned by
[ADR-012](decisions/012-forked-substrate-design.md): the substrate is a
vendored contract-blind lineage module folded into this engine crate
([ADR-013](decisions/013-fold-substrate-into-sqlite.md)); the
watcher/connection architecture this spec describes is inherited
verbatim where kept ([ADR-003](decisions/003-sqlite-driver.md)
unchanged in architecture), with the port deltas ADR-012 §4 decides
(fallible watcher spawn, bounded reconnect backoff, panic-free
death-closes-subscribers). The
[evidence](../research/quality-read-honker-core.md): published
0.5.0's unreleased-fix-train defect class, plus ADR-010's queue depth
requiring engine-owned queue SQL in any posture. The substrate's table
family is `__alkstore_*` (ADR-010 §8's naming authorization).
## Design Decisions
| ADR | Decision | Summary |
|---|---|---|
| [001](decisions/001-crate-split.md) | Crate split | single-driver engine crate |
| [002](decisions/002-feature-scope.md) | Feature scope | which rows this engine serves |
| [003](decisions/003-sqlite-driver.md) | Driver | rusqlite + the forked substrate lineage, bridged seam, inherited watcher — ownership resolved by 011 |
| [005](decisions/005-dependency-ownership.md) | Ownership | published honker-core; fork trigger fired by OQ-06 (ADR-011) |
| [006](decisions/006-wake-and-delivery-contract.md) | Wake contract | data_version watcher, coalescing, death-closes-receivers |
| [007](decisions/007-transactional-seam.md) | Tx seam | writer-slot lease, `BEGIN IMMEDIATE`, `spawn_blocking` round-trips |
| [008](decisions/008-contract-v1-pinning.md) | Contract v1 | pinned surface; outbox backing queue derived under the reserved prefix; wake payload transport unused (non-contract) |
| [009](decisions/009-scheduler-collapse.md) | Scheduler collapse | `@every` specs; `__alkstore_scheduler` leadership; cron rejected |
| [010](decisions/010-queue-semantics-depth.md) | Queue depth | semantics pinned; §5/§3a realization resolved by ADR-011 (owned code) |
| [011](decisions/011-sqlite-substrate-fork.md) | Substrate fork | OQ-06's trigger fired; substrate owned, queue ops re-derived on contract v1 |
| [012](decisions/012-forked-substrate-design.md) | Fork design | contract-blind substrate, fidelity posture, port deltas, panic-free watcher death |
| [013](decisions/013-fold-substrate-into-sqlite.md) | Substrate packaging | folded into `alkstore-sqlite` (`src/substrate/`); no fourth crate |
| [014](decisions/014-outbox-tx-enqueue.md) | Outbox tx enqueue | `outbox_enqueue_tx` on `TxHandle`; derivation engine-side through the writer-slot lease |
| [015](decisions/015-streams-depth.md) | Streams depth | key = carried metadata (the inherited nullable column); global-FIFO ordering; `trim_to` as a writer-lease delete; event shape already the substrate's |
| [016](decisions/016-deployment-honesty.md) | Deployment honesty | no runtime capability surface; single-host posture stated by crate identity + docs; this engine never produces `PayloadTooLarge` (pinned in the contract suite) |
| [017](decisions/017-contract-versioning.md) | Contract versioning | engine pins core `alkstore = "1.y"` in its manifest; substrate cherry-picks are class-4 non-events; adoption duties per its §5 |
| [018](decisions/018-provenance-register-and-cherry-picks.md) | Substrate provenance | `src/substrate/PROVENANCE.md` (identity, delta register — cherry-picks as tagged entries); cherry-picks recorded at adoption, non-versioning |
| [019](decisions/019-mechanism-handle-surfaces.md) | Handle surfaces | boxed handle traits (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`); `worker_id` stamps the row's claimant column; `StopToken` core-owned |
| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue options | delay-over-`run_at` in the substrate's re-derived enqueue; scheduler fires stamp from derived defaults; serde_json byte encoding |
| [021](decisions/021-tx-reads-and-value-shape-fixes.md) | Third review round | tx-read methods route through the writer-slot lease; `Job.claimed_at` (the fork's claimed_at column, surfaced in `Job`); `schedule()` queue argument validated; drop = rollback releases the lease with `ROLLBACK` |
| [023](decisions/023-fourth-review-round.md) | Fourth review round | `open` path is a plain filesystem path (URI flag dropped — registered fork delta); 1 ms watcher default stands, cadence carried onto `SqliteOpts::poll_interval`; `encode_payload` typed (`Codec`); numeric-argument domains pinned contract-side (extents clamp empty — enforced at this engine's trait-impl entry over the substrate) |
## Open Questions
Open questions are tracked in
[open-questions.md](open-questions.md). Key
questions affecting this document:
- **OQ-06**: honker-core quality read — fork-trigger assessment —
**resolved** (2026-10-05,
[ADR-011](decisions/011-sqlite-substrate-fork.md); trigger fired).
- **OQ-12**: streams depth — **resolved**
(2026-10-05, [ADR-015](decisions/015-streams-depth.md)): key =
carried metadata; global-FIFO-by-offset ordering row;
`StreamEvent` shape (honker's, `topic` → `stream` in the contract
type — the substrate's column name stays per the ADR-012 §3
fidelity posture); `publish_with_key_tx` routes through the
writer-slot lease; `trim_to` is a plain delete in the same lease.
- **OQ-13**: transactional outbox enqueue shape — **resolved**
(2026-10-05, [ADR-014](decisions/014-outbox-tx-enqueue.md)):
`outbox_enqueue_tx(outbox, opts, payload)` on the `TxHandle` trait;
the SQLite handle routes the op through the writer-slot lease
([ADR-007](decisions/007-transactional-seam.md)) into the forked
substrate's `__alkstore_outbox:{name}` backing queue.
Resolved: **OQ-09** (scheduler collapse —
[ADR-009](decisions/009-scheduler-collapse.md)), **OQ-05** (queue
semantics depth — [ADR-010](decisions/010-queue-semantics-depth.md)),
**OQ-12** (streams depth — [ADR-015](decisions/015-streams-depth.md)),
**OQ-08** (capability surface — none, by default ever;
[ADR-016](decisions/016-deployment-honesty.md)), **OQ-10** (contract
versioning — [ADR-017](decisions/017-contract-versioning.md)), and
**OQ-11**
(fork follow-through — the substrate's provenance register and the
cherry-pick procedure;
[ADR-018](decisions/018-provenance-register-and-cherry-picks.md)),
2026-10-05/06.