From f33ebaafa15626e0f8bc3e23341835e08ebf4a33 Mon Sep 17 00:00:00 2001 From: ngoodger-block Date: Wed, 28 Oct 2026 09:26:41 +1100 Subject: [PATCH] refactor(schema): stop auto-fencing new tables in desired-state bootstrap (#8620) ### Why? This is a cleanup that brings the schema bootstrap in line with how the codebase works today, as asked for by [`docs/database.md`](https://github.com/block/buzz/blob/dc25198d530acc6237a0320b697365f29daf0ea6/docs/database.md#L103-L124) (#8273). Writes are now blocked during community deletion by application admission (`begin_community_write_transaction`), and that doc's Rule 1 says new tables must not add triggers. But `schema/community_write_fence.sql` still looped over every table with a `community_id` column and attached the old fence trigger to it. That meant a new table got a trigger on a fresh database and none on a migrated one, so the two schema paths disagreed and new tenant tables were blocked. The doc says this bootstrap change must land "before the feature, or as the feature's first PR." ### What? Deletes the blanket fence loop and its `\i` from `schema/schema.sql`. Existing legacy tables keep their fences through their explicit `schema/triggers/` files, so no current table loses its fence and runtime behavior is unchanged. Adds a test that a new `community_id` table stays unfenced while `events` and `users` stay fenced, and updates `docs/database.md` and `ARCHITECTURE.md` to match. It stands on its own; the media-sharing PRs (#8451, #8444) are the first to rely on it. buzz-review-completed --------- Signed-off-by: Nikolaj Goodger Co-authored-by: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 2 + crates/buzz-db/src/runtime/migration.rs | 4 ++ .../schema_fence_bootstrap_postgres_tests.rs | 39 +++++++++++++++++++ docs/database.md | 20 +++++----- schema/community_write_fence.sql | 31 --------------- .../attach_community_write_fence.sql | 6 +-- schema/schema.sql | 9 ++--- 7 files changed, 60 insertions(+), 51 deletions(-) create mode 100644 crates/buzz-db/src/runtime/tests/schema_fence_bootstrap_postgres_tests.rs delete mode 100644 schema/community_write_fence.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7ff26b4ef7..34f2afa2d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -504,6 +504,8 @@ shape is an explicit transition, not a flag day: never spans communities. Serving leases use the same application admission. Startup and deletion catalog validation tolerate the universal fence objects being absent; inventories still record live fences for older executors. + Desired-state bootstraps install only explicitly declared legacy fences. + New tenant-owned tables rely on application admission, without a fence trigger. - **Replica floor (transition foundation):** runtime now provides a shared replica-floor lock helper for compliant channel-event writers, and the writer probe handshake takes the exclusive counterpart before sampling `S`, scanning diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index ccbd8615ab..def49cef09 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -14,6 +14,10 @@ use crate::Result; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); +#[cfg(test)] +#[path = "tests/schema_fence_bootstrap_postgres_tests.rs"] +mod schema_fence_bootstrap_postgres_tests; + /// Run all pending Buzz database migrations. /// /// The entire run holds the exclusive [`SCHEMA_DESTRUCTION_LOCK_KEY`] session diff --git a/crates/buzz-db/src/runtime/tests/schema_fence_bootstrap_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/schema_fence_bootstrap_postgres_tests.rs new file mode 100644 index 0000000000..67eb9a9a99 --- /dev/null +++ b/crates/buzz-db/src/runtime/tests/schema_fence_bootstrap_postgres_tests.rs @@ -0,0 +1,39 @@ +use sqlx::PgPool; + +/// A fresh bootstrap keeps the declared backstops but never discovers new +/// community tables and silently attaches a trigger to them. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_bootstrap_keeps_only_explicit_legacy_fences() { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + sqlx::raw_sql("CREATE TABLE new_tenant_probe (community_id UUID NOT NULL)") + .execute(&pool) + .await + .unwrap(); + // Only checked-in schema files contribute SQL to this test bootstrap. + sqlx::raw_sql(sqlx::AssertSqlSafe( + crate::test_support::desired_state_schema_sql(), + )) + .execute(&pool) + .await + .unwrap(); + + for (table, expected) in [ + ("new_tenant_probe", false), + ("events", true), + ("users", true), + ] { + let fenced: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_trigger \ + WHERE tgrelid = $1::regclass \ + AND tgfoid = 'enforce_community_write_fence()'::regprocedure)", + ) + .bind(table) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(fenced, expected, "unexpected fence on {table}"); + } +} diff --git a/docs/database.md b/docs/database.md index 78ad9486dd..0a3c9b58e7 100644 --- a/docs/database.md +++ b/docs/database.md @@ -100,7 +100,7 @@ writer of a fenced table already goes through admission. The remaining fence-retirement work drops the fence triggers and functions, and MUST also update ARCHITECTURE.md § *Dual enforcement*. -**Today this blocks new tenant-owned tables.** Startup and deletion validation +**New tenant-owned tables use application admission.** Startup and deletion validation no longer require a fence on any table; they pass with every universal fence object dropped (`migration_schema_fence_objects_are_optional_for_serving_and_deletion` in `crates/buzz-db/src/store/deletion.rs`). Two things still matter: @@ -110,21 +110,19 @@ in `crates/buzz-db/src/store/deletion.rs`). Two things still matter: (`community deletion catalog drift (missing=…, unknown=…)`), and deletion fails closed for every community, not only the new table's. Listing the table is enough to pass. -- Desired-state bootstraps run `schema/community_write_fence.sql`, which - attaches a fence trigger to every `community_id` table not excluded by - `community_write_fence_excluded_table`. A fresh desired-state database would - put a trigger on the new table and a migrated one would not, so the two - schema paths would diverge. +- Desired-state bootstraps install only the legacy fences explicitly listed + under `schema/triggers/`. They do not discover and fence new tables. + Keep those existing backstops until their own retirement is verified; + new tables receive no fence in either schema path. -So before a feature can add a tenant-owned `community_id` table, the -desired-state bootstrap must stop attaching fence triggers. That change belongs -to the fence-retirement work. Land it before the feature, or as the feature's -first PR. Do not work around it with a trigger, or with an entry in +The blanket desired-state fence attachment has been removed. A feature may +add a tenant-owned `community_id` table when it follows the checklist below. +Do not add a trigger, or an entry in `community_write_fence_excluded_table` for a table the community owns. #### Checklist: adding a tenant-owned `community_id` table -After the desired-state change above has landed, all in the same PR: +All in the same PR: - [ ] Every writer of the table goes through community admission (`begin_community_write_transaction` or another constructor that returns diff --git a/schema/community_write_fence.sql b/schema/community_write_fence.sql deleted file mode 100644 index 2ebc9fd1f9..0000000000 --- a/schema/community_write_fence.sql +++ /dev/null @@ -1,31 +0,0 @@ --- Attach the universal fence to every existing table carrying community_id, --- including deployment-private sidecars whose community_id is provenance. --- --- Desired-state schema application does not replay migration history, so the --- explicit `SELECT attach_community_write_fence('')` calls in --- `triggers/
.sql` stay as first-class catalog declarations. They also --- make the fence contract visible to migration linting instead of hiding it --- only in this dynamic bootstrap loop. This loop runs last, so it fences every --- desired-state table regardless: a fenced desired state does not prove the --- migration path attaches the fence. Keep the explicit calls. -DO $$ -DECLARE - target REGCLASS; -BEGIN - FOR target IN - SELECT c.oid::REGCLASS - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - JOIN pg_attribute a ON a.attrelid = c.oid - WHERE n.nspname = current_schema() - AND c.relkind IN ('r', 'p') - AND NOT c.relispartition - AND a.attname = 'community_id' - AND NOT a.attisdropped - AND NOT community_write_fence_excluded_table(c.relname) - ORDER BY c.oid::REGCLASS::TEXT - LOOP - PERFORM attach_community_write_fence(target); - END LOOP; -END -$$; diff --git a/schema/functions/attach_community_write_fence.sql b/schema/functions/attach_community_write_fence.sql index 2b004ff684..94d510f75c 100644 --- a/schema/functions/attach_community_write_fence.sql +++ b/schema/functions/attach_community_write_fence.sql @@ -1,6 +1,6 @@ --- Attach the universal fence to one community-scoped relation. Future --- migrations must invoke this helper explicitly after CREATE/ALTER introduces --- community_id; the migration lint enforces that contract. +-- Attach the legacy universal fence to one community-scoped relation. Only +-- tables fenced before fence retirement use it; new tenant tables rely on +-- application admission and must not call it (docs/database.md Rule 1). CREATE FUNCTION attach_community_write_fence(target REGCLASS) RETURNS VOID LANGUAGE plpgsql AS $$ DECLARE diff --git a/schema/schema.sql b/schema/schema.sql index 15376d9eb2..ce60e11a20 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -7,9 +7,9 @@ -- -- Everything SchemaBot cannot apply lives in this directory's other -- subdirectories: `types/`, `functions/`, `partitions/`, `triggers/` (including --- write-fence attachment), `seeds/`, and the `community_write_fence.sql` --- bootstrap. This file composes all of them into one fresh database, in load --- order: types → functions → tables → partitions → triggers → seeds → fence. +-- explicit legacy write-fence attachment), and `seeds/`. This file composes +-- them into one fresh database, in load order: +-- types → functions → tables → partitions → triggers → seeds. -- -- `./bin/pgschema apply --file schema/schema.sql` resolves the `\i` includes -- relative to this file (it refuses `..`). `pgschema` skips INSERTs and some @@ -213,6 +213,3 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \i seeds/operator_listener_outbox.sql \i seeds/relay_operator_audit.sql \i seeds/storage_accounting_snapshots.sql - --- Fence any community-scoped table that lacks an explicit attach. -\i community_write_fence.sql