fix(groups): don't let with_events silently downgrade a transactional writer

`with_events()` and `with_atomic_writes()` both install a writer, last call wins.
Appending them in the wrong order in the host would swap the transactional writer
back out for the best-effort one — silently reverting the write-path invariant AND
dropping the per-group quota enforcement that lives in it, with nothing in the type
system to catch it.

The services now carry an `atomic` flag: once `with_atomic_writes` has run,
`with_events` warns and no-ops instead of clobbering it. Also enforce the group
byte ceiling in `BestEffortGroupFilesWriter::update`, so the non-transactional
writer can't be used as an unmetered bypass and a unit test can catch its removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-14 21:12:01 +02:00
parent 1fc4080800
commit 8f35050499
3 changed files with 45 additions and 0 deletions

View File

@@ -56,6 +56,12 @@ pub struct GroupKvServiceImpl {
/// fan-out, which for the host all commit in one advisory-locked
/// transaction. Reads stay on `repo`.
writer: Arc<dyn GroupKvWriter>,
/// Set once [`Self::with_atomic_writes`] has installed the transactional
/// writer. `with_events` refuses to overwrite it — silently downgrading a
/// service back to non-transactional writes (and dropping its quota
/// enforcement) because a builder call was appended in the wrong order is
/// exactly the kind of regression the type system will not catch.
atomic: bool,
}
impl GroupKvServiceImpl {
@@ -73,6 +79,13 @@ impl GroupKvServiceImpl {
/// supersedes it — shared writes fire nothing.
#[must_use]
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
if self.atomic {
tracing::warn!(
"with_events() called after with_atomic_writes() — ignored; the \
transactional writer already resolves and writes the fan-out itself"
);
return self;
}
self.writer = Arc::new(BestEffortGroupKvWriter::new(self.repo.clone(), events));
self
}
@@ -88,6 +101,7 @@ impl GroupKvServiceImpl {
#[must_use]
pub fn with_atomic_writes(mut self, pool: sqlx::PgPool) -> Self {
self.writer = Arc::new(PostgresGroupKvWriter::new(pool));
self.atomic = true;
self
}
@@ -111,6 +125,7 @@ impl GroupKvServiceImpl {
repo.clone(),
Arc::new(NoopEventEmitter),
)),
atomic: false,
max_rows: crate::quota::group_kv_max_rows_from_env(),
max_total_bytes: crate::quota::group_kv_max_total_bytes_from_env(),
repo,