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:
@@ -44,6 +44,12 @@ pub struct GroupDocsServiceImpl {
|
|||||||
/// fan-out, which for the host all commit in one advisory-locked
|
/// fan-out, which for the host all commit in one advisory-locked
|
||||||
/// transaction. Reads stay on `repo`.
|
/// transaction. Reads stay on `repo`.
|
||||||
writer: Arc<dyn GroupDocsWriter>,
|
writer: Arc<dyn GroupDocsWriter>,
|
||||||
|
/// 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 GroupDocsServiceImpl {
|
impl GroupDocsServiceImpl {
|
||||||
@@ -60,6 +66,13 @@ impl GroupDocsServiceImpl {
|
|||||||
/// best-effort writer. Superseded by [`Self::with_atomic_writes`].
|
/// best-effort writer. Superseded by [`Self::with_atomic_writes`].
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
|
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(BestEffortGroupDocsWriter::new(self.repo.clone(), events));
|
self.writer = Arc::new(BestEffortGroupDocsWriter::new(self.repo.clone(), events));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -71,6 +84,7 @@ impl GroupDocsServiceImpl {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_atomic_writes(mut self, pool: sqlx::PgPool) -> Self {
|
pub fn with_atomic_writes(mut self, pool: sqlx::PgPool) -> Self {
|
||||||
self.writer = Arc::new(PostgresGroupDocsWriter::new(pool));
|
self.writer = Arc::new(PostgresGroupDocsWriter::new(pool));
|
||||||
|
self.atomic = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +100,7 @@ impl GroupDocsServiceImpl {
|
|||||||
repo.clone(),
|
repo.clone(),
|
||||||
Arc::new(NoopEventEmitter),
|
Arc::new(NoopEventEmitter),
|
||||||
)),
|
)),
|
||||||
|
atomic: false,
|
||||||
max_rows: crate::quota::group_docs_max_rows_from_env(),
|
max_rows: crate::quota::group_docs_max_rows_from_env(),
|
||||||
max_total_bytes: crate::quota::group_docs_max_total_bytes_from_env(),
|
max_total_bytes: crate::quota::group_docs_max_total_bytes_from_env(),
|
||||||
repo,
|
repo,
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ pub struct GroupFilesServiceImpl {
|
|||||||
/// shared-trigger fan-out — for the host, all under one advisory-locked
|
/// shared-trigger fan-out — for the host, all under one advisory-locked
|
||||||
/// transaction. Reads stay on `repo`.
|
/// transaction. Reads stay on `repo`.
|
||||||
writer: Arc<dyn GroupFilesWriter>,
|
writer: Arc<dyn GroupFilesWriter>,
|
||||||
|
/// 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 GroupFilesServiceImpl {
|
impl GroupFilesServiceImpl {
|
||||||
@@ -56,6 +62,7 @@ impl GroupFilesServiceImpl {
|
|||||||
repo.clone(),
|
repo.clone(),
|
||||||
Arc::new(NoopEventEmitter),
|
Arc::new(NoopEventEmitter),
|
||||||
)),
|
)),
|
||||||
|
atomic: false,
|
||||||
repo,
|
repo,
|
||||||
resolver,
|
resolver,
|
||||||
authz,
|
authz,
|
||||||
@@ -74,6 +81,13 @@ impl GroupFilesServiceImpl {
|
|||||||
/// best-effort writer. Superseded by [`Self::with_atomic_writes`].
|
/// best-effort writer. Superseded by [`Self::with_atomic_writes`].
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
|
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(BestEffortGroupFilesWriter::new(self.repo.clone(), events));
|
self.writer = Arc::new(BestEffortGroupFilesWriter::new(self.repo.clone(), events));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -86,6 +100,7 @@ impl GroupFilesServiceImpl {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_atomic_writes(mut self, pool: sqlx::PgPool, root: std::path::PathBuf) -> Self {
|
pub fn with_atomic_writes(mut self, pool: sqlx::PgPool, root: std::path::PathBuf) -> Self {
|
||||||
self.writer = Arc::new(PostgresGroupFilesWriter::new(pool, root));
|
self.writer = Arc::new(PostgresGroupFilesWriter::new(pool, root));
|
||||||
|
self.atomic = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ pub struct GroupKvServiceImpl {
|
|||||||
/// fan-out, which for the host all commit in one advisory-locked
|
/// fan-out, which for the host all commit in one advisory-locked
|
||||||
/// transaction. Reads stay on `repo`.
|
/// transaction. Reads stay on `repo`.
|
||||||
writer: Arc<dyn GroupKvWriter>,
|
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 {
|
impl GroupKvServiceImpl {
|
||||||
@@ -73,6 +79,13 @@ impl GroupKvServiceImpl {
|
|||||||
/// supersedes it — shared writes fire nothing.
|
/// supersedes it — shared writes fire nothing.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
|
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.writer = Arc::new(BestEffortGroupKvWriter::new(self.repo.clone(), events));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -88,6 +101,7 @@ impl GroupKvServiceImpl {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_atomic_writes(mut self, pool: sqlx::PgPool) -> Self {
|
pub fn with_atomic_writes(mut self, pool: sqlx::PgPool) -> Self {
|
||||||
self.writer = Arc::new(PostgresGroupKvWriter::new(pool));
|
self.writer = Arc::new(PostgresGroupKvWriter::new(pool));
|
||||||
|
self.atomic = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +125,7 @@ impl GroupKvServiceImpl {
|
|||||||
repo.clone(),
|
repo.clone(),
|
||||||
Arc::new(NoopEventEmitter),
|
Arc::new(NoopEventEmitter),
|
||||||
)),
|
)),
|
||||||
|
atomic: false,
|
||||||
max_rows: crate::quota::group_kv_max_rows_from_env(),
|
max_rows: crate::quota::group_kv_max_rows_from_env(),
|
||||||
max_total_bytes: crate::quota::group_kv_max_total_bytes_from_env(),
|
max_total_bytes: crate::quota::group_kv_max_total_bytes_from_env(),
|
||||||
repo,
|
repo,
|
||||||
|
|||||||
Reference in New Issue
Block a user