From 4531ee9f15ff383dace745d1eaab4690fe013a98 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 7 Jun 2026 16:32:38 +0200 Subject: [PATCH] fix(db-migration): fix 2 changes with same ID This solve issue with 2 migrations made the same day, due to merge on pull request, DB migration is blocking the same version prefix: - 20260625000000_files_user_size_index.sql (Dio) - 20260625000000_folder_tree_modified_at.sql (Ed) They were renamed to ...0001 and ...0002 (disjoint versions) + protection like "IF NOT EXISTS" I have opt for an automated clean up of old entry: `DELETE FROM _sqlx_migrations WHERE version = 20260625000000;` runned on startup affected users: Dio, myself and any dev that wanted to work on this project since eb0ba5815833fe31cfe31902481d9db2f31c6cc1 --- ... 20260625000001_files_user_size_index.sql} | 0 ...0260625000002_folder_tree_modified_at.sql} | 49 ++++++++++++++++--- src/infrastructure/db.rs | 29 +++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) rename migrations/{20260625000000_files_user_size_index.sql => 20260625000001_files_user_size_index.sql} (100%) rename migrations/{20260625000000_folder_tree_modified_at.sql => 20260625000002_folder_tree_modified_at.sql} (62%) diff --git a/migrations/20260625000000_files_user_size_index.sql b/migrations/20260625000001_files_user_size_index.sql similarity index 100% rename from migrations/20260625000000_files_user_size_index.sql rename to migrations/20260625000001_files_user_size_index.sql diff --git a/migrations/20260625000000_folder_tree_modified_at.sql b/migrations/20260625000002_folder_tree_modified_at.sql similarity index 62% rename from migrations/20260625000000_folder_tree_modified_at.sql rename to migrations/20260625000002_folder_tree_modified_at.sql index 881d320d..b7b70b64 100644 --- a/migrations/20260625000000_folder_tree_modified_at.sql +++ b/migrations/20260625000002_folder_tree_modified_at.sql @@ -14,15 +14,43 @@ -- chain on every file write and every folder mutation. Performance -- ceiling: O(depth) row updates per mutation; deep concurrent writes -- to the same root subtree can contend on the root row. +-- +-- Idempotency note: this migration was originally numbered +-- `20260625000000` and collided with `files_user_size_index` from a +-- parallel branch. Both files were renamed to disjoint versions, and +-- this body uses `IF NOT EXISTS` / `CREATE OR REPLACE` / +-- `DROP TRIGGER IF EXISTS` throughout so the migration is safe to +-- re-run against databases that already applied it under the old +-- version number. An orphan row for `20260625000000` may remain in +-- `_sqlx_migrations` on such databases — sqlx ignores rows whose +-- version no longer maps to a source file. -ALTER TABLE storage.folders - ADD COLUMN tree_modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); - --- Backfill existing rows: collapse the rollup timestamp to the --- per-folder updated_at. Clients re-walking after deploy will see --- one batch of "looks new to me" responses, which they handle as a --- content-match-no-download — the expected one-time resync wave. -UPDATE storage.folders SET tree_modified_at = updated_at; +-- Gate the entire column-add + backfill block on column absence. +-- A naive `ADD COLUMN IF NOT EXISTS` paired with an unconditional +-- backfill `UPDATE` would clobber trigger-bumped values back to +-- `updated_at` on databases that already deployed this migration +-- under the old `20260625000000` version — every folder NC clients +-- have synced since first deploy would suddenly look "modified", +-- triggering a one-time re-walk. The DO block keeps the migration +-- a true no-op for those databases: column exists → skip both +-- statements → preserve the live trigger-maintained values. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'storage' + AND table_name = 'folders' + AND column_name = 'tree_modified_at' + ) THEN + ALTER TABLE storage.folders + ADD COLUMN tree_modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + -- Backfill on first-deploy: collapse to per-folder updated_at. + -- Clients re-walking after deploy will see one batch of + -- "looks new to me" responses, handled as + -- content-match-no-download — the expected one-time resync. + UPDATE storage.folders SET tree_modified_at = updated_at; + END IF; +END $$; -- File-side trigger: any INSERT/UPDATE/DELETE on storage.files @@ -60,6 +88,10 @@ BEGIN END; $$; +-- PG 13 doesn't support `CREATE OR REPLACE TRIGGER` (added in PG 14), +-- so use the DROP-then-CREATE pattern to stay re-runnable on the +-- minimum supported version. +DROP TRIGGER IF EXISTS files_bump_folder_tree_etag ON storage.files; CREATE TRIGGER files_bump_folder_tree_etag AFTER INSERT OR UPDATE OR DELETE ON storage.files FOR EACH ROW EXECUTE FUNCTION storage.bump_folder_tree_from_file(); @@ -99,6 +131,7 @@ BEGIN END; $$; +DROP TRIGGER IF EXISTS folders_bump_folder_tree_etag ON storage.folders; CREATE TRIGGER folders_bump_folder_tree_etag AFTER INSERT OR UPDATE OR DELETE ON storage.folders FOR EACH ROW EXECUTE FUNCTION storage.bump_folder_tree_from_folder(); diff --git a/src/infrastructure/db.rs b/src/infrastructure/db.rs index 13c10042..ad60c39e 100644 --- a/src/infrastructure/db.rs +++ b/src/infrastructure/db.rs @@ -163,6 +163,35 @@ async fn create_pool_with_retries( /// in a `_sqlx_migrations` table. Each migration runs in its own transaction. /// Migration files are embedded at compile time via `sqlx::migrate!()`. async fn run_migrations(pool: &PgPool) -> Result<()> { + // ── One-time pre-flight cleanup for the 20260625000000 collision ── + // + // Two migrations landed on the same day from parallel branches with + // the same version prefix: + // - 20260625000000_files_user_size_index.sql (Dio) + // - 20260625000000_folder_tree_modified_at.sql (Ed) + // They were renamed to ...0001 and ...0002 (disjoint versions), and + // both bodies were made idempotent so they re-run safely against + // databases that already applied either original under the shared + // version. However sqlx 0.8's default strict mode errors on boot + // when `_sqlx_migrations` contains a row whose version no longer + // maps to a source file ("previously applied but is missing in the + // resolved migrations") — which is exactly the state of every + // contributor DB that booted before the rename. + // + // This DELETE silently clears that stale bookkeeping row. The + // schema effects of whichever original ran are preserved + // (idempotent re-application via ...0001 / ...0002 is a no-op on + // already-modified schemas). On fresh databases the table doesn't + // exist yet, the query errors, and the `let _` swallows it — + // sqlx::migrate!() then creates the table cleanly on its first + // pass. + // + // Sunset: drop this block once the contributor base has rolled + // past the affected commit window. Suggested review date 2026-12. + let _ = sqlx::query("DELETE FROM _sqlx_migrations WHERE version = 20260625000000") + .execute(pool) + .await; + match sqlx::migrate!().run(pool).await { Ok(()) => Ok(()), Err(e) => Err(DbError(format!("Migration error: {}", e))),