Files
poimen/tasks/T0.5-eventlog-port-redb-implementation.md
T

144 lines
6.7 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.
# T0.5 — EventLog port + redb implementation
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T0.6, T1.x |
## Goal
The full `EventLog` port and its embedded `redb` implementation. Keys
`(TenantId, RunId, BranchId, Lsn)`. Async signatures even though the local
implementation is synchronous underneath.
## Facts (inlined — no spec read needed)
```rust
#[async_trait]
pub trait EventLog: Send + Sync {
/// Append records, apply derived state, advance consumer position, enqueue
/// the outbox — all four or none. Returns the assigned LSNs.
async fn commit(&self, batch: CommitBatch) -> Result<Vec<Lsn>>;
async fn read(&self, key: BranchKey, from: Lsn, limit: usize) -> Result<Vec<LogRecord>>;
async fn put_checkpoint(&self, key: BranchKey, upto: Lsn, state: &[u8]) -> Result<()>;
/// Newest checkpoint at or below `upto`. `None` means fold from LSN 0.
async fn latest_checkpoint(&self, key: BranchKey, upto: Lsn)
-> Result<Option<Checkpoint>>;
/// Committed-but-unshipped export intents, in `(BranchKey, Lsn)` order.
/// Read by the relay, never from the execution path.
async fn drain_outbox(&self, tenant: TenantId, limit: usize) -> Result<Vec<OutboxEntry>>;
async fn ack_outbox(&self, shipped: &[(BranchKey, Lsn)]) -> Result<()>;
}
pub struct CommitBatch {
/// Carries the tenant. Never a separate parameter beside a key that already
/// holds one — two sources for one fact is one too many.
pub key: BranchKey,
pub records: Vec<WorkEvent>, // LSNs assigned by the implementation
pub state: Vec<StateDelta>,
pub position: Option<ConsumerPosition>,
pub outbox: Vec<ExportIntent>,
}
```
- **`commit` is one method, not four, because atomicity is the contract.** A port
exposing `append` alone puts the other three writes outside the transaction —
the durability guarantee gone, and gone invisibly, since each write
individually succeeds.
- Everything is `async`. An async signature over a local call costs a negligible
poll; a sync signature over a network call is impossible, and the Postgres
backend (T7.1) implements this same trait. The prior revision declared a
synchronous store and documented in the same file that a networked
implementation could not honour the signature — a port finished while already
known to be unimplementable.
- `redb` specifics: pure Rust, ACID, MVCC, copy-on-write shadow paging, so a torn
write cannot corrupt the file — it simply does not take effect. The durability
enum is `#[non_exhaustive]`, so set `Durability::Immediate` **explicitly**
rather than relying on the default. (Avoid `sled` — years at 0.34 beta with
known space amplification.)
- LSNs are per branch, monotonic, gap-free. Not a global counter: a global one
serializes every run through a single atomic, and the ordering contract only
promises per-run total order.
- `latest_checkpoint` and `drain_outbox`/`ack_outbox` exist from the start. A
checkpoint that can be written and not read is an optimization nobody can use;
writes without their matching reads are how T2.5 and T7.4 discover the port is
short.
## Steps
1. Define the port trait, `CommitBatch`, `Checkpoint`, `OutboxEntry`,
`ExportIntent`, `StateDelta`, `ConsumerPosition` exactly as above.
2. Open the four `redb` tables: `EVENT_LOG`, `RUN_STATE`, `CONSUMER_POSITION`,
`OUTBOX`. Every key is `BranchKey`-derived or `Scoped<_>` (T0.1).
3. Implement LSN allocation **inside** the write transaction: read the current
max LSN for the `BranchKey`, assign `max+1..` across the batch. Being inside
the transaction is what makes it gap-free under concurrency.
4. Implement `commit` as a single `begin_write` → four inserts → `commit()`, with
`Durability::Immediate` set explicitly.
5. Implement `read` as a range scan bounded by `(key, from)..`, honouring `limit`.
6. Implement the checkpoint pair and the outbox pair. `drain_outbox` returns in
`(BranchKey, Lsn)` order — that ordering is the only one the relay is promised.
7. Write the conformance suite as a **generic function over `impl EventLog`**, so
T7.1's Postgres backend runs the identical tests. This is where the
abstraction becomes real or does not.
## Acceptance
- Append 10k records across 3 branches of 2 runs in 2 tenants; read back in order
per branch; assert no cross-tenant visibility.
- Port audit: compile stub call sites for T2.5 (checkpoints), T7.4 (outbox relay)
and T8.4 (reduction) against the trait. Every method they need already exists.
## Verify
**Harness:** the conformance suite written as
`fn conformance<L: EventLog>(make: impl Fn() -> L)`. It is the deliverable T7.1
reuses unmodified — write it as a library function, not as `#[test]` bodies.
**Integration test**`tests/it_eventlog_conformance.rs`:
1. Append 10k records across 3 branches × 2 runs × 2 tenants.
2. Read back per branch from LSN 0; assert contiguous `1..n` with no gaps and no
duplicates.
3. **Concurrency:** spawn 16 tasks committing to the *same* `BranchKey`
simultaneously; assert the union of returned LSNs is exactly `1..=total` with
no repeats. Gap-freedom only breaks under contention.
4. **Isolation:** scan the raw table with no tenant predicate and assert every
key carries the expected tenant — do not rely on the filtered read path.
5. Round-trip a checkpoint: `put_checkpoint`, then `latest_checkpoint` at an LSN
above and below it.
6. Outbox: commit with entries, `drain_outbox`, assert `(BranchKey, Lsn)`
ascending order, `ack_outbox`, assert a second drain returns empty.
7. **Port audit:** a `tests/port_audit.rs` with stub call sites for T2.5, T7.4
and T8.4 that must compile against the trait.
**Command:** `cargo test -p storage --test it_eventlog_conformance --test port_audit`
**False pass:**
- Single-threaded LSN allocation test. It passes against a
read-then-write-outside-the-transaction implementation, which is the actual bug.
- Cross-tenant isolation checked through a query that already filters by tenant —
it passes against a completely unscoped table. Step 4 is the real check.
- A conformance suite written inline as `#[test]`s, which then cannot be reused
for Postgres and quietly becomes redb-only.
## Traps
- Adding a convenience `append()` that skips the other three tables. It will be
on the hot path within a week.
- Passing `tenant` as a second parameter beside `BranchKey`.
- Letting `Durability` default. The enum is `#[non_exhaustive]`; the default can
change underneath you.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §7, §8.3 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)