Skip to content

Concurrent D1 migration executors can collide on migration DDL before bookkeeping race recovery #2757

Description

@yumam0815

Summary

Two deployment migration executors started concurrently against the same completely empty remote D1 database can collide in migration DDL before either executor records the migration in _emdash_migrations.

This is distinct from #2755 (empty-D1 HTTP 400 error handling) and #954 (recovery from an already partial migration state).

Versions

  • EmDash main baseline: 6c912b50159f37bcbfbd5eea9a34182ce609c36b
  • Empty-D1 fix candidate used for the reproduction: 1bdf24f87337f8aa8f1ed4a6042949ac48fb18c3
  • Core migration identity: EmDash 0.35.0, 69 migrations
  • Remote database: a new, empty, disposable Cloudflare D1 database

Reproduction

  1. Confirm the remote D1 database has no application tables and no migration history.
  2. Start two independent deployment migration executors at the same time with the same account/database target and exact migration fingerprint.
  3. Let both run the normal migration apply path.

Actual result

  • One executor completed all 69 migrations.
  • The other executor failed during 004_plugins with a sanitized D1 error equivalent to:
table _plugin_storage already exists (migration: 004_plugins)

The final ledger can appear complete because the successful executor finishes, but the losing executor may already have executed part of a multi-statement migration before the error.

The current race recovery recognizes a migration-history UNIQUE conflict after migration work. It does not prevent both executors from entering the migration body, so a DDL collision can occur before bookkeeping recovery is reached.

Kysely's SQLite migration-lock path assumes a connection-level serialization model and does not provide a cross-process lock for independent D1 REST executors.

Expected behavior

Concurrent deployment migration attempts for the same D1 target should not both enter migration DDL. Either:

  • the adapter/runner provides a real cross-executor migration mutex; or
  • the deployment interface requires and clearly documents an external single-owner coordinator and fails closed when ownership is unavailable.

A broad catch for table already exists is not safe because the losing executor may be observing a partial schema rather than a completed migration.

Current workaround

Use an external atomic lease scoped to a hash of Cloudflare account ID plus D1 UUID. Bind the lease to the exact migration artifact and fingerprint, allow only one migration owner, fail closed if the coordinator is unavailable, and require explicit status/partial-state checks before recovering an expired lease.

No production deployment or confirmed data loss is part of this report.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions