fix(docs): make docs writes transactional and the group quota a bound

Audit #6 and #8, applied to the docs store — the same two bugs KV had, in
the same two places.

Per-app docs (#6): create/update/delete wrote the row, then emitted
best-effort. An outbox failure left a committed doc whose trigger never
fired. They now go through `atomic_write::DocsWriter`, whose Postgres impl
writes and fans out on one connection in one transaction.

Group docs (#8): the service read `count_rows`/`projected_total_bytes` on
pooled connections and wrote on another, so concurrent creators each saw the
same pre-write count and together overshot the ceiling.
`PostgresGroupDocsWriter` takes a per-group advisory lock — on its OWN
`docs`-namespaced key, so it serializes against other docs writers to that
group but not against KV writers, which draw on a separate ceiling.

The bespoke `check_total_bytes` (with its own copy of the upper-bound fast
path) is gone; group docs now shares `group_quota::check_group_write` with
group KV, so the row ceiling, the projected-bytes ceiling, and the fast path
that skips the O(n) SUM scan have exactly one implementation between them.

tests/atomic_write.rs gains the docs row-quota race (16 concurrent creators
against a ceiling of 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-14 19:45:26 +02:00
parent b086713e3d
commit 58bf0ab3ec
7 changed files with 959 additions and 351 deletions

View File

@@ -16,10 +16,13 @@
use std::sync::Arc;
use picloud_manager_core::atomic_write::{
GroupKvTarget, GroupKvWriter, KvWriter, PostgresGroupKvWriter, PostgresKvWriter,
GroupDocsTarget, GroupDocsWriter, GroupKvTarget, GroupKvWriter, KvWriter,
PostgresGroupDocsWriter, PostgresGroupKvWriter, PostgresKvWriter,
};
use picloud_manager_core::group_quota::GroupWriteQuota;
use picloud_shared::{AppId, ExecutionId, GroupId, GroupKvError, RequestId, ScriptId, SdkCallCx};
use picloud_shared::{
AppId, ExecutionId, GroupDocsError, GroupId, GroupKvError, RequestId, ScriptId, SdkCallCx,
};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
@@ -436,3 +439,67 @@ async fn concurrent_writers_cannot_push_a_group_past_its_byte_quota() {
.await
.expect("cleanup");
}
/// Group DOCS carried the identical check-then-write race as group KV, and the
/// identical fix (a per-group advisory lock, on its own `docs`-namespaced key so
/// it doesn't contend with KV writes).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_writers_cannot_push_a_group_past_its_docs_row_quota() {
const MAX_DOCS: u64 = 4;
let Some(pool) = pool_or_skip().await else {
return;
};
let group = mk_group(&pool).await;
let writer = Arc::new(PostgresGroupDocsWriter::new(pool.clone()));
let quota = GroupWriteQuota {
max_rows: MAX_DOCS,
max_total_bytes: u64::MAX,
max_value_bytes: 256 * 1024,
};
let app = Uuid::new_v4();
let mut set = tokio::task::JoinSet::new();
for i in 0..16 {
let w = Arc::clone(&writer);
set.spawn(async move {
let cx = cx(app);
w.create(
&cx,
GroupDocsTarget {
group_id: GroupId::from(group),
collection: "articles",
},
serde_json::json!({ "n": i }),
quota,
)
.await
});
}
let mut ok = 0;
while let Some(r) = set.join_next().await {
match r.expect("task") {
Ok(_) => ok += 1,
Err(GroupDocsError::QuotaExceeded { .. }) => {}
Err(e) => panic!("unexpected error: {e}"),
}
}
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM group_docs WHERE group_id = $1")
.bind(group)
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(
n,
i64::try_from(MAX_DOCS).unwrap(),
"the group must hold EXACTLY its doc ceiling"
);
assert_eq!(ok, usize::try_from(MAX_DOCS).unwrap());
sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(group)
.execute(&pool)
.await
.expect("cleanup");
}