Skip to content

fix(sqlite): use BEGIN EXCLUSIVE for migration lock; TTL sweep + heartbeat for non-migration advisory locks#2666

Merged
jeremydmiller merged 2 commits intomainfrom
fix/sqlite-migration-lock
May 4, 2026
Merged

fix(sqlite): use BEGIN EXCLUSIVE for migration lock; TTL sweep + heartbeat for non-migration advisory locks#2666
jeremydmiller merged 2 commits intomainfrom
fix/sqlite-migration-lock

Conversation

@mysticmind
Copy link
Copy Markdown
Member

@mysticmind mysticmind commented May 3, 2026

The row-based wolverine_locks scheme couldn't serialize migration: the table is created by the migration it's supposed to protect, so the first migration per tenant burned ~5.5s of failed lock retries before running unprotected. Migration now uses BEGIN EXCLUSIVE; polling keeps the row lock (by then the table exists).

From #2636 (dmytro-pryvedeniuk):

  • SqliteAdvisoryLock.TryAttainLockAsync now idempotent via HasLock
  • SqliteAdvisoryLock.DisposeAsync NRE: dropped duplicate close+dispose
  • SqliteMessageStore polling overrides delegate to SqliteAdvisoryLock (TryAttain checks affected rows; Release actually deletes the row)
  • Dropped racy "not yet delivered" assertion in scheduled-tenant test

Not picked: CreateLocksTableIfMissing in the lock hot path -- unneeded once migration uses BEGIN EXCLUSIVE.

Also skips can_send_from_one_node_to_another_by_destination on the SQLite local fixture (single-host, no second node) and adds 4 focused migration-lock tests.

non-migration advisory lock:
wolverine_locks rows aren't bound to the writing connection, so a hard-killed holder leaves a row no peer reaps. Sweep stale rows on attempt; refresh acquired_at when the live holder re-attains. Live holders re-attempt on every poll tick (HealthCheck/ScheduledJob), so the heartbeat is implicit. Default TTL 2m.

Split sqlite_migration_lock.cs: migration-lock tests stay; advisory-lock tests (idempotency, release, TTL/heartbeat) move to a new sqlite_advisory_lock.cs.

The row-based wolverine_locks scheme couldn't serialize migration: the
table is created by the migration it's supposed to protect, so the first
migration per tenant burned ~5.5s of failed lock retries before running
unprotected. Migration now uses BEGIN EXCLUSIVE; polling keeps the row
lock (by then the table exists).

From #2636 (dmytro-pryvedeniuk):
- SqliteAdvisoryLock.TryAttainLockAsync now idempotent via HasLock
- SqliteAdvisoryLock.DisposeAsync NRE: dropped duplicate close+dispose
- SqliteMessageStore polling overrides delegate to SqliteAdvisoryLock
  (TryAttain checks affected rows; Release actually deletes the row)
- Dropped racy "not yet delivered" assertion in scheduled-tenant test

Not picked: CreateLocksTableIfMissing in the lock hot path -- unneeded
once migration uses BEGIN EXCLUSIVE.

Also skips can_send_from_one_node_to_another_by_destination on the
SQLite local fixture (single-host, no second node) and adds 4 focused
migration-lock tests.
@mysticmind mysticmind mentioned this pull request May 3, 2026
wolverine_locks rows aren't bound to the writing connection, so a
hard-killed holder leaves a row no peer reaps. Sweep stale rows on
attempt; refresh acquired_at when the live holder re-attains. Live
holders re-attempt on every poll tick (HealthCheck/ScheduledJob), so
the heartbeat is implicit. Default TTL 2m.

Split sqlite_migration_lock.cs: migration-lock tests stay; advisory-
lock tests (idempotency, release, TTL/heartbeat) move to a new
sqlite_advisory_lock.cs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants