fix(kv): commit a write and its trigger fan-out in one transaction

Audit #6. `KvServiceImpl` wrote the row, waited for it to commit, then
asked the emitter to resolve matching triggers and insert outbox rows —
a second transaction on a second connection. If that second step failed,
the row was permanently in the store with its trigger having never
fired: invisible to the caller (the write "succeeded"), unrecoverable by
any retry, and only ever logged. The code said as much:

    // Audit finding (Medium): this is non-transactional with the data
    // write — the row above has already committed by the time emit()
    // runs, so an emit failure means triggers silently don't fire.

Introduce `atomic_write::KvWriter` — the mutating half of the service (the
write AND the fan-out it produces), so the two can share a transaction:

  * `PostgresKvWriter` opens a tx, writes via `kv_repo::*_on(&mut *tx, …)`,
    runs the fan-out on the SAME connection via `emit_on(&mut *tx, …)`, and
    commits. An emit failure now rolls the write back and surfaces to the
    script, which is the honest outcome — the caller learns the write did
    not happen instead of silently getting a store that disagrees with its
    triggers.
  * `BestEffortKvWriter` keeps the old write-then-log-on-emit-failure
    semantics for the in-memory unit tests (no Postgres).

Both sit behind one trait, so the service body has a single code path and
the choice is a constructor detail (`with_atomic_writes(pool)` in the host).

Pool-deadlock rule, documented on the module: everything inside the tx runs
on the tx's connection. A writer that held a tx and then reached for a
second pooled connection could starve — the pool is sized to the execution
concurrency cap, so N executions each wanting 2 connections deadlock. The
fan-out takes `&mut *tx` and never touches a repo.

`tests/atomic_write.rs` pins it by injecting an outbox failure (a Postgres
BEFORE-INSERT trigger scoped to the test's own app_id, so parallel tests are
unaffected): the set errors and the key is NOT in the store; likewise a
delete rolls back rather than dropping a key nothing downstream hears about.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-14 19:28:17 +02:00
parent a2360a9464
commit 01e4e5a8f5
6 changed files with 736 additions and 187 deletions

View File

@@ -170,12 +170,18 @@ pub async fn build_app(
let kv_repo = Arc::new(PostgresKvRepo::new(pool.clone()));
let docs_repo = Arc::new(PostgresDocsRepo::new(pool.clone()));
let events: Arc<dyn ServiceEventEmitter> = Arc::new(OutboxEventEmitter::new(pool.clone()));
let kv: Arc<dyn KvService> = Arc::new(KvServiceImpl::with_max_value_bytes(
kv_repo.clone(),
authz.clone(),
events.clone(),
picloud_manager_core::kv_service::kv_max_value_bytes_from_env(),
));
// `with_atomic_writes` makes each KV mutation and the trigger fan-out it
// produces commit in ONE transaction — an outbox failure rolls the write
// back rather than leaving a committed row whose trigger never fires.
let kv: Arc<dyn KvService> = Arc::new(
KvServiceImpl::with_max_value_bytes(
kv_repo.clone(),
authz.clone(),
events.clone(),
picloud_manager_core::kv_service::kv_max_value_bytes_from_env(),
)
.with_atomic_writes(pool.clone()),
);
// §11.6 shared group collections (KV): a separate store keyed by the owning
// group, resolved from cx.app_id's chain. Reuses the KV value-size cap.
// §11.6 shared-collection repos hoisted so both the SDK services AND the M4