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