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:
@@ -118,24 +118,7 @@ impl DocsRepo for PostgresDocsRepo {
|
||||
collection: &str,
|
||||
data: Value,
|
||||
) -> Result<DocRow, DocsRepoError> {
|
||||
let id = Uuid::new_v4();
|
||||
let row: (DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
|
||||
"INSERT INTO docs (app_id, collection, id, data) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&data)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at: row.0,
|
||||
updated_at: row.1,
|
||||
})
|
||||
create_on(&self.pool, app_id, collection, data).await
|
||||
}
|
||||
|
||||
async fn get(
|
||||
@@ -179,34 +162,7 @@ impl DocsRepo for PostgresDocsRepo {
|
||||
id: DocId,
|
||||
data: Value,
|
||||
) -> Result<Option<Value>, DocsRepoError> {
|
||||
// Same CTE shape as KV's set ([kv_repo.rs:101-132]): SELECT the
|
||||
// previous data before the UPDATE so the service can emit
|
||||
// `prev_data` in the update ServiceEvent. Single statement, no
|
||||
// explicit transaction. Inherits KV's last-writer-wins race
|
||||
// under concurrent writers; documented as a known limitation
|
||||
// for v1.1.2.
|
||||
let row: Option<(Option<Value>,)> = sqlx::query_as(
|
||||
"WITH prev AS ( \
|
||||
SELECT data FROM docs \
|
||||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||
), \
|
||||
updated AS ( \
|
||||
UPDATE docs SET data = $4, updated_at = NOW() \
|
||||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING 1 \
|
||||
) \
|
||||
SELECT (SELECT data FROM prev) FROM updated",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&data)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
// `row` is None when the UPDATE matched no rows (missing doc);
|
||||
// Some((Some(prev),)) on success. `data` is JSONB NOT NULL so
|
||||
// the inner Option is always Some when prev exists.
|
||||
Ok(row.and_then(|(v,)| v))
|
||||
update_on(&self.pool, app_id, collection, id, data).await
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
@@ -215,17 +171,7 @@ impl DocsRepo for PostgresDocsRepo {
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<Value>, DocsRepoError> {
|
||||
let row: Option<(Value,)> = sqlx::query_as(
|
||||
"DELETE FROM docs \
|
||||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING data",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
delete_on(&self.pool, app_id, collection, id).await
|
||||
}
|
||||
|
||||
async fn list(
|
||||
@@ -448,6 +394,100 @@ fn value_to_text(v: &Value) -> Option<String> {
|
||||
// pin the cross-app isolation invariant at the SQL level.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Connection-scoped mutations
|
||||
//
|
||||
// Generic over the executor so the same SQL serves the pooled trait methods
|
||||
// above and `crate::atomic_write`, which runs the write and the trigger fan-out
|
||||
// it produces on ONE connection inside ONE transaction.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
pub(crate) async fn create_on<'c, E>(
|
||||
exec: E,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
data: Value,
|
||||
) -> Result<DocRow, DocsRepoError>
|
||||
where
|
||||
E: sqlx::PgExecutor<'c>,
|
||||
{
|
||||
let id = Uuid::new_v4();
|
||||
let row: (DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
|
||||
"INSERT INTO docs (app_id, collection, id, data) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&data)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
Ok(DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at: row.0,
|
||||
updated_at: row.1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the previous data (for the update event), `None` if the doc is
|
||||
/// missing. The CTE captures the prior data alongside the UPDATE so the service
|
||||
/// can emit `prev_data` without a second round trip.
|
||||
pub(crate) async fn update_on<'c, E>(
|
||||
exec: E,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: Value,
|
||||
) -> Result<Option<Value>, DocsRepoError>
|
||||
where
|
||||
E: sqlx::PgExecutor<'c>,
|
||||
{
|
||||
let row: Option<(Option<Value>,)> = sqlx::query_as(
|
||||
"WITH prev AS ( \
|
||||
SELECT data FROM docs \
|
||||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||
), \
|
||||
updated AS ( \
|
||||
UPDATE docs SET data = $4, updated_at = NOW() \
|
||||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING 1 \
|
||||
) \
|
||||
SELECT (SELECT data FROM prev) FROM updated",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&data)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
Ok(row.and_then(|(v,)| v))
|
||||
}
|
||||
|
||||
/// Returns the deleted doc's data if it existed, `None` if no such doc.
|
||||
pub(crate) async fn delete_on<'c, E>(
|
||||
exec: E,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<Value>, DocsRepoError>
|
||||
where
|
||||
E: sqlx::PgExecutor<'c>,
|
||||
{
|
||||
let row: Option<(Value,)> = sqlx::query_as(
|
||||
"DELETE FROM docs \
|
||||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING data",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sql_shape_tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user