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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user