feat(migrate): let migrations opt out of the transaction wrapper (-- taskq:no-transaction) - #35
feat(migrate): let migrations opt out of the transaction wrapper (-- taskq:no-transaction)#35rcbevans wants to merge 2 commits into
Conversation
5c5a120 to
d046f45
Compare
| for line in sql_template.splitlines(): | ||
| stripped = line.strip() | ||
| if stripped == "" or stripped.startswith("--"): | ||
| if _NO_TRANSACTION_DIRECTIVE_RE.fullmatch(stripped): |
There was a problem hiding this comment.
fullmatch + no IGNORECASE means this only fires if the line is character-exact. Poked at it:
-- taskq:no-transaction -> works
-- taskq:no-transaction needed for CIC -> silently transactional
-- TaskQ:No-Transaction -> silently transactional
/* taskq:no-transaction */ -> silently transactional
Sticking a note after the directive explaining why you opted out is probably the first thing anyone writes, and it kills it.
With CIC you find out straight away since PG refuses. What worries me more is someone going no-transaction for lock duration instead — that just quietly runs wrapped forever and nothing ever tells them.
match + IGNORECASE would cover most of it, but honestly the bigger win is warning when a header line has taskq: in it and doesn't parse.
There was a problem hiding this comment.
Fixed. The directive now prefix-matches and is case-insensitive, bounded by \b so no-transactional still does not count, and a leading-comment line that mentions taskq: without parsing logs a migration-directive-unrecognized warning naming the file. The trailing-note case works, and the silent transactional failure mode you flagged now surfaces in logs. Unit tests plus a new end-to-end test through discover() pin it.
There was a problem hiding this comment.
Verified. Trailing note and mixed case both opt out now, no-transactional correctly doesn't, and the near-miss warning fires on it. Ran all four against HEAD.
| # First keyword of a split statement, skipping any leading line/block | ||
| # comments the splitter left attached. Used to reject transaction control in | ||
| # non-transactional migrations. | ||
| _TXN_CONTROL_RE = re.compile( |
There was a problem hiding this comment.
These all sail through the guard:
SAVEPOINT sp1;
RELEASE SAVEPOINT sp1;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET LOCAL work_mem = '1GB';
CHECKPOINT;
Most are harmless no-ops. SET LOCAL isn't — SET LOCAL statement_timeout = 0; before a long CIC is a totally normal thing to write, and outside a transaction it does nothing except log a warning nobody reads. You think you've disabled the timeout, you haven't.
(I did try to break this with nested block comments and CR line endings first. Both get caught properly, the regex is fine there.)
There was a problem hiding this comment.
Extended. The guard now rejects SAVEPOINT and RELEASE up front, and SET LOCAL / SET TRANSACTION for exactly the reason you gave: outside a transaction they are silent no-ops, so an author would believe statement_timeout was disabled for a long build when it was not. Plain SET / SET SESSION and CHECKPOINT stay allowed: session-scoped, and they behave the same either way. The regex is gone in favor of a small tokenizer that skips comments in both positions, including nested block comments, so forms like SET /* tune */ LOCAL cannot slip past the new rule either.
There was a problem hiding this comment.
Verified — SET LOCAL, SAVEPOINT, RELEASE, SET TRANSACTION all rejected, CHECKPOINT/SET/SET SESSION still allowed. The tokenizer also catches SET /* x */ LOCAL, which the old regex couldn't, so this is better than what I asked for.
One gap left: SET CONSTRAINTS ALL DEFERRED is allowed. Outside a transaction it warns and does nothing — the exact rationale in your own docstring for rejecting SET LOCAL. Probably wants "constraints" in the second-word tuple.
| buf.append(ch) | ||
| buf.append(nxt) | ||
| i += 2 | ||
| elif ch == "$" and (m := _DOLLAR_TAG_RE.match(sql, i)) is not None: |
There was a problem hiding this comment.
$ inside an identifier gets read as a dollar-quote opener:
split_statements("SELECT a$b$c; SELECT 2;") -> ['SELECT a$b$c; SELECT 2;']
One chunk, second statement swallowed. And a$b$c is a real identifier as far as PG is concerned, I checked — SELECT a$b$c FROM (SELECT 1 AS a$b$c) q runs fine. U&'...' does the same thing.
Not scary in practice: the merged chunk hits PG as multi-statement, so anything CONCURRENTLY in there blows up loudly. Two boring statements would just quietly end up sharing a transaction.
A tag can't follow an identifier char, so checking the previous char before entering the $ branch should sort it.
There was a problem hiding this comment.
Fixed with the prev-char check you suggested: a dollar-quote tag can no longer follow an identifier character, so a$b$c stays one identifier and the following statement splits normally. One correction: U&'...' already splits correctly, since backslash only matters before a quote in the splitter and PG rejects a backslash-escape inside U& strings regardless. Regression pins for it added anyway next to the a$b$c test.
There was a problem hiding this comment.
a$b$c fixed, verified — 2 chunks, and real $$/$tag$ quoting still parses as one.
You're right about U&'...' and I was wrong. My test input was malformed — PG rejects SELECT U&'\''; with unterminated quoted string, so returning one chunk was correct behaviour, not a bug. Well-formed U& cases all split fine. Sorry for the noise.
| migration itself:: | ||
|
|
||
| -- taskq:no-transaction | ||
| DROP INDEX CONCURRENTLY IF EXISTS "{schema}".jobs_foo_idx; |
There was a problem hiding this comment.
That DROP INDEX CONCURRENTLY IF EXISTS is doing real work and looks like it isn't. On 17:
after interrupted CIC: indisvalid = false
re-run CIC IF NOT EXISTS: NOTICE: ... already exists, skipping
indisvalid = false <- still busted
DROP CONCURRENTLY + recreate: indisvalid = true
IF NOT EXISTS sees the invalid index and skips, so without the DROP the retry never actually fixes anything — you're left with an index the planner won't use but writers still maintain.
Someone's going to delete that line as redundant one day.
There was a problem hiding this comment.
Done. The docstring example now carries a NOT-redundant comment explaining the INVALID-index debris case, mirrored in the upgrading guide, so nobody deletes the line as dead code.
There was a problem hiding this comment.
Good — and putting it in CONTRIBUTING.md as well as the docstring is the right call, that's where someone writing a new migration will actually look.
| from older TaskQ versions need no manual step; rows applied before the column | ||
| existed read `true`. | ||
|
|
||
| ## If a migration goes wrong |
There was a problem hiding this comment.
This is still the old runbook — stop workers, restore from backup, pin the version. None of that is what you do when a no-transaction migration dies halfway. Then it's: see what actually landed, check for an INVALID index, fix forward, re-run.
Bit ironic that the section about things going wrong is the one that didn't get updated for the mode where things don't roll back.
docs/architecture.md:385 still claims migrations are always wrapped and CONCURRENTLY is impossible, too.
There was a problem hiding this comment.
Rewritten, and the manual inspection is gone entirely: the runbook no longer asks anyone to query catalogs by hand. migrate up now diagnoses itself on failure and prints which migration failed, whether it rolled back or left partial effects, any INVALID indexes found, and the single action to take. The section says that in a few lines, and architecture.md:385 is updated to match.
There was a problem hiding this comment.
The rewrite is right, and building diagnose_apply_failure rather than just fixing the prose is a bigger swing than I expected.
The subsystem has some problems though — left them as separate comments rather than burying them here. Short version: it can hang holding the advisory lock, and it prints a couple of things that aren't true.
| 'DROP INDEX CONCURRENTLY IF EXISTS "{schema}".nt_jobs_queue_idx;\n' | ||
| "CREATE INDEX CONCURRENTLY IF NOT EXISTS nt_jobs_queue_idx " | ||
| 'ON "{schema}".jobs (queue);\n', | ||
| use_transaction=False, |
There was a problem hiding this comment.
These construct Migration(use_transaction=False) by hand, so they test the runner but never the parsing that decides the flag.
Made _uses_transaction() always return True — feature completely off, everything forced transactional — and integration went 975/975 green. Three unit assertions on discover() were the only thing that noticed.
Which is how the fullmatch thing above would get through: happy path covered, near-misses not, and integration blind to the whole question.
A single integration test that writes a real .sql with the header and goes through discover() would cover both.
Flip side, since I was mutating things anyway — split_statements, _reject_transaction_control and _ensure_ledger_use_transaction_column all fail loudly when broken (18, 9, and basically the entire integration suite). Those are fine.
There was a problem hiding this comment.
Added test_discover_directive_parsing_applies_end_to_end: writes a real .sql carrying the directive with a trailing note (so it covers the fullmatch issue above too), runs it through the real discover() into apply_pending against Postgres, and asserts the concurrent index lands valid and the ledger records use_transaction=false. Repeating your _uses_transaction always-True mutation now fails loudly with ActiveSQLTransactionError.
There was a problem hiding this comment.
Closed. test_discover_directive_parsing_applies_end_to_end goes through real discover() over a real file, which is what was missing. Confirmed the mixed-case pin goes red if the regex is reverted.
Directive parsing: prefix match plus IGNORECASE so trailing notes or mixed case no longer silently disable the opt-out, and header lines mentioning taskq: that do not parse now log a warning naming the file. Guard: a small tokenizer replaces the keyword regex. SAVEPOINT and RELEASE are rejected up front, and SET LOCAL / SET TRANSACTION are rejected as silent no-ops outside a transaction, including comment-separated forms (SET /* x */ LOCAL). Plain SET and CHECKPOINT remain allowed. Splitter: a dollar sign inside an identifier (a$b$c) is no longer misread as a dollar-quote opener, so a following statement is not swallowed into the same chunk. Failures self-diagnose: migrate up and the startup path report which migration failed, whether it rolled back or left partial effects, any INVALID indexes found, and the single action to take. No tracebacks, and the failing migration is tagged on the exception so --phase runs cannot misattribute the report. Tests: a stepwise harness seeds tens of thousands of rows and applies every bundled migration one at a time against a populated database, plus an end-to-end test that drives a real .sql file through discover(). Docs: the failure runbook now points at the CLI report instead of manual catalog inspection, CONTRIBUTING.md gains a migration-authoring guide, and the stale checksum-rejects claim is corrected.
244df41 to
0747313
Compare
Directive parsing: prefix match plus IGNORECASE so trailing notes or mixed case no longer silently disable the opt-out, and header lines mentioning taskq: that do not parse now log a warning naming the file. Guard: a small tokenizer replaces the keyword regex. SAVEPOINT and RELEASE are rejected up front, and SET LOCAL / SET TRANSACTION are rejected as silent no-ops outside a transaction, including comment-separated forms (SET /* x */ LOCAL). Plain SET and CHECKPOINT remain allowed. Splitter: a dollar sign inside an identifier (a$b$c) is no longer misread as a dollar-quote opener, so a following statement is not swallowed into the same chunk. Failures self-diagnose: migrate up and the startup path report which migration failed, whether it rolled back or left partial effects, any INVALID indexes found, and the single action to take. No tracebacks, and the failing migration is tagged on the exception so --phase runs cannot misattribute the report. Tests: a stepwise harness seeds tens of thousands of rows and applies every bundled migration one at a time against a populated database, plus an end-to-end test that drives a real .sql file through discover(). Docs: the failure runbook now points at the CLI report instead of manual catalog inspection, CONTRIBUTING.md gains a migration-authoring guide, and the stale checksum-rejects claim is corrected.
0747313 to
def12d2
Compare
Adds a -- taskq:no-transaction header directive so a migration can run statement by statement with no wrapping transaction, unlocking CREATE/DROP INDEX CONCURRENTLY on hot tables (jobs, job_events) whose blocking index builds currently stall the worker fleet. - discover() parses the directive (leading comment block only) into Migration.use_transaction; the directive lives in the SQL template so checksum-drift detection covers it - apply_pending executes flagged migrations via split_statements() with Alembic autocommit-block semantics; the ledger records completion only after every statement succeeds, and failed migrations re-run - schema_migrations.use_transaction surfaces which migrations ran outside a transaction; the runner self-heals the column onto pre-upgrade ledgers (once, outside migration transactions) - transaction-control statements are rejected in flagged files before anything executes - migrate status annotates no-transaction migrations Closes #29
Directive parsing: prefix match plus IGNORECASE so trailing notes or mixed case no longer silently disable the opt-out, and header lines mentioning taskq: that do not parse now log a warning naming the file. Guard: a small tokenizer replaces the keyword regex. SAVEPOINT and RELEASE are rejected up front, and SET LOCAL / SET TRANSACTION are rejected as silent no-ops outside a transaction, including comment-separated forms (SET /* x */ LOCAL). Plain SET and CHECKPOINT remain allowed. Splitter: a dollar sign inside an identifier (a$b$c) is no longer misread as a dollar-quote opener, so a following statement is not swallowed into the same chunk. Failures self-diagnose: migrate up and the startup path report which migration failed, whether it rolled back or left partial effects, any INVALID indexes found, and the single action to take. No tracebacks, and the failing migration is tagged on the exception so --phase runs cannot misattribute the report. Tests: a stepwise harness seeds tens of thousands of rows and applies every bundled migration one at a time against a populated database, plus an end-to-end test that drives a real .sql file through discover(). Docs: the failure runbook now points at the CLI report instead of manual catalog inspection, CONTRIBUTING.md gains a migration-authoring guide, and the stale checksum-rejects claim is corrected.
def12d2 to
caa3695
Compare
Closes #29
Summary
apply_pendingwrapped every migration in a single transaction (migrate.py:189on main), which structurally forbadeCREATE INDEX CONCURRENTLY/DROP INDEX CONCURRENTLY(Postgres rejects them inside a transaction block). Every index migration was therefore a blocking build holding aSHARElock against writes for a full-table scan, harmless on tiny tables, fleet-stalling onjobs/job_events. This PR adds the per-migration opt-out the issue proposes.How it works
-- taskq:no-transactionin its leading comment block (--line comments only, before the first SQL token).discover()parses it into a newMigration.use_transactionfield (defaultTrue). The directive lives inside the SQL template, so toggling it is covered by the existing checksum-drift warning.\bkeepsno-transactionalfrom matching. A header line that mentionstaskq:without matching logs amigration-directive-unrecognizedwarning naming the file, so a typo cannot silently run transactional.apply_pendingexecutes flagged migrations statement by statement with no wrapping transaction (Alembic'sautocommit_blocksemantics). Splitting is required: a multi-statement string over Postgres' simple query protocol runs as one implicit transaction, soconn.execute(whole_file)would still defeatCONCURRENTLY.split_statements()handles quoted strings, quoted identifiers, line and nested block comments, and dollar-quoted bodies, and a$inside an identifier (a$b$c) is not misread as a dollar-quote opener.migrate up. Hence the documented contract that such migrations be idempotent and re-runnable (DROP INDEX CONCURRENTLY IF EXISTS+CREATE INDEX CONCURRENTLY IF NOT EXISTS, which is also the standard remedy for anINVALIDindex left by an interrupted build; the docstring example spells out why the DROP line is not redundant).schema_migrations.use_transactionrecords how each migration ran, andtaskq migrate statusannotates no-transaction migrations with(no transaction). The runner owns the ledger's shape (like Rails'schema_migrations/ Alembic'salembic_version) and adds the column itself, once up front for existing ledgers and lazily on the first record for fresh installs. Pre-existing rows backfill totrue.BEGIN/COMMIT/ROLLBACKand aliases,SAVEPOINT/RELEASE, andSET LOCAL/SET TRANSACTION, which are silent no-ops outside a transaction (an author would believe e.g.statement_timeoutwas disabled for a long build when it was not). A small tokenizer skips whitespace and comments, including nested block comments, so comment-separated forms likeSET /* tune */ LOCAL ...cannot slip past. PlainSET/SET SESSIONandCHECKPOINTstay allowed.Failure handling: users never inspect the database by hand
A failed transactional migration rolls back on its own. A failed no-transaction migration is not recorded and is idempotent by contract, so re-running
taskq migrate up(or restarting the worker) heals it. The tool now says so itself:taskq migrate upcatches apply failures and prints a short report instead of a traceback: which migration failed, whether it rolled back or left partial effects, anyINVALIDindexes it found in the schema, and the single action to take. The failing migration is tagged on the exception, so--phaseruns cannot misattribute the report to an earlier pending migration.apply_pending_locked) does the same and aborts with one actionable line. Connection failures get the short report too. Nothing prints a traceback.CI proves migrations are safe on a populated database
New
tests/test_migrations_populated.py: a stepwise harness that seeds a realistic dataset (tens of thousands of jobs across all statuses, plus events, attempts, archives, cron schedules, rate-limit rows) and applies every bundled migration one at a time on a real Postgres container, asserting after each step that exactly that migration applied, noINVALIDindexes exist, the ledger records how it ran, and the seeded data is intact. It finishes with a real-API smoke test (enqueue, dispatch, idempotency dedupe, cross-scope idempotency keys) and amigrate upno-op run on the populated database. Future migrations join the harness automatically, so CI fails naming the offender if any migration ever damages a populated database.Prior art consulted (as the issue requests)
disable_ddl_transaction!: per-migration macro; the migrator skips the wrapping transaction for that migration only. Ouruse_transactionfield mirrors Rails' internaluse_transaction?check.AddIndexConcurrently/atomic = False: concurrent index ops require a non-atomic migration; docs defer to the PostgreSQLINVALID-index caveats. Same contract here: author-owned idempotency.autocommit_block(): "each statement is committed immediately" in autocommit mode, withtransaction_per_migrationrecommended for the rest, exactly TaskQ's existing per-migration-transaction design plus the new per-statement escape hatch. (Why not Alembic itself: it requires SQLAlchemy as a dependency, has no pre/post phase guard for rolling deploys, and fleet-safe startup application under an advisory lock would be hand-rolled around it anyway.)Verification (real output)
(One flake in the full run,
tests/test_cancel_hook_integration.py::test_force_cancel, passed on immediate rerun and is unrelated to this PR. The 6 skips are pre-existing extras/PG-only skips.)Test coverage added
no-transactionalrejection, near-miss warning), statement splitter (dollar sign inside identifiers,U&'...'regression pins, dollar quotes after punctuation), transaction-control guard (new keywords, comment-separated and nested-comment bypass forms, allowlist pins), failure-diagnosis renderer (exact line lists for every variant), exception tagging,ApplyFailureDiagnosisinvariant.discover(); the populated-database stepwise harness; CLI failure report throughCliRunner;apply_pending_lockedfailure self-diagnosis;list_invalid_indexesround-trip on staged debris.Follow-ups (deliberately deferred)
test_bundled_migrations_are_all_transactional). The directive is there for the index work on hot tables to adopt when ready.job_attemptstojob_attempts_archiveSELECT ja.*issue from the closing note was handled on PR feat: scope idempotency_key uniqueness via idempotency_scope #27's branch, left alone here.list_appliedfetches achecksumcolumn it never uses;apply_pendingcould document that the advisory lock is opt-in (apply_pending_locked) for concurrent runners.