Skip to content

Fix: SuppressTransaction support for ConcurrentIndex correctness - #48

Merged
DJGosnell merged 2 commits into
masterfrom
fix/34-suppress-transaction-concurrent-index
Mar 21, 2026
Merged

Fix: SuppressTransaction support for ConcurrentIndex correctness#48
DJGosnell merged 2 commits into
masterfrom
fix/34-suppress-transaction-concurrent-index

Conversation

@DJGosnell

@DJGosnell DJGosnell commented Mar 20, 2026

Copy link
Copy Markdown
Member

Summary

ConcurrentIndex() on PostgreSQL was broken — CREATE INDEX CONCURRENTLY cannot run inside a transaction block, but MigrationRunner wrapped all operations in a single DbTransaction. This PR adds a SuppressTransaction() fluent modifier and partitions runner execution into transactional and non-transactional phases, with PostgreSQL auto-suppression for ConcurrentIndex().

Reason for Change

MigrationRunner.ApplyMigrationAsync() wrapped the entire migration in a single transaction. PostgreSQL errors with CREATE INDEX CONCURRENTLY cannot run inside a transaction block, making the existing ConcurrentIndex() modifier unusable on PostgreSQL. This is a correctness bug — the feature silently fails or errors.

Impact

  • MigrationBuilder gains a new .SuppressTransaction() fluent modifier
  • MigrationRunner now partitions operations into transactional (Phase 1) and non-transactional (Phase 2) phases
  • PostgreSQL ConcurrentIndex() operations are automatically suppressed from transactions — no user action needed
  • SQL Server ConcurrentIndex() (WITH (ONLINE = ON)) is unaffected — it still runs inside transactions as before
  • Warning logs emitted when migrations contain non-transactional operations

Plan items implemented as specified

  • MigrationOperation: Added bool SuppressTransaction property
  • MigrationBuilder: Added .SuppressTransaction() fluent modifier
  • MigrationBuilder: Added BuildPartitionedSql() returning (txSql, nonTxSql, allSql) for phase splitting
  • MigrationRunner.ApplyMigrationAsync(): Split into transactional phase (Phase 1) then non-transactional phase (Phase 2)
  • MigrationRunner.RollbackMigrationAsync(): Reverse order — non-transactional first, then transactional
  • Auto-imply: ConcurrentIndex() on PostgreSQL automatically implies SuppressTransaction

Deviations from plan implemented

  • None

Gaps in original plan implemented

  • BuildPartitionedSql returns a 3-tuple (txSql, nonTxSql, allSql) — combined SQL derived from partitioned results to avoid redundant rendering passes through DdlRenderer
  • Warning logs (NonTransactionalWarning, NonTransactionalSqlGenerated) emitted when migrations contain suppressed operations
  • During rollback, non-transactional operations execute first (reverse of apply order) to safely undo the last-applied ops before rolling back the transactional phase
  • History row is always recorded during the transactional phase with actual elapsed milliseconds
  • Unified transaction block in ApplyMigrationAsync — single code path handles both mixed and non-tx-only migrations

Migration Steps

No migration needed — this is a runtime behavior change in MigrationRunner.

Performance Considerations

  • Negligible: one list partition per migration execution
  • No additional database round-trips unless the migration actually contains suppressed operations
  • Eliminated redundant SQL rendering — BuildPartitionedSql derives combined SQL from partitioned results instead of calling DdlRenderer separately

Security Considerations

  • No new attack surface. SQL generation is unchanged; only execution phasing is modified.

Breaking Changes

Consumer-facing

  • None. Existing migrations work identically. The new .SuppressTransaction() modifier is opt-in.

Internal

  • MigrationBuilder.BuildPartitionedSql() returns a 3-tuple (string, string, string) (internal method)
  • MigrationBuilder.HasNonTransactionalOperations() added (internal method)
  • MigrationOperation.SuppressTransaction property added

DJGosnell and others added 2 commits March 20, 2026 18:21
ConcurrentIndex() on PostgreSQL requires execution outside a transaction
block, but MigrationRunner wrapped all operations in a single transaction.

- Add SuppressTransaction property to MigrationOperation
- Add .SuppressTransaction() fluent modifier to MigrationBuilder
- Add BuildPartitionedSql() to separate transactional/non-transactional ops
- Auto-imply SuppressTransaction for PostgreSQL ConcurrentIndex operations
- Split MigrationRunner.ApplyMigrationAsync into two phases:
  1. Transactional phase (inside BeginTransactionAsync)
  2. Non-transactional phase (direct connection, no transaction)
- Reverse phase order for RollbackMigrationAsync (non-tx first, then tx)
- Add warning log when migrations contain non-transactional operations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lication

- Fix execution_time_ms regression: record actual elapsed milliseconds in
  history row instead of hardcoded 0
- Eliminate triple SQL rendering: BuildPartitionedSql now returns a 3-tuple
  (txSql, nonTxSql, allSql), deriving combined SQL from partitioned results
  instead of calling BuildSql separately
- Remove redundant else branch in ApplyMigrationAsync: unified into a single
  transaction block that always writes the history row
- Add test: only-non-transactional migration still records history
- Add test: Phase 2 failure leaves history row committed (partial-apply state)
- Add test: execution_time_ms is recorded correctly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@DJGosnell
DJGosnell merged commit 37f9a9b into master Mar 21, 2026
1 check passed
@DJGosnell
DJGosnell deleted the fix/34-suppress-transaction-concurrent-index branch March 28, 2026 05:44
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.

Fix: SuppressTransaction support for ConcurrentIndex correctness

1 participant