Skip to content

fix(hardening): 0.4.2 payload - bounded migrate lock, stamp --force, asyncpg translation, per-file lock_timeout - #5

Merged
juanmicl merged 6 commits into
mainfrom
fix/0.4.2-hardening
Sep 2, 2026
Merged

fix(hardening): 0.4.2 payload - bounded migrate lock, stamp --force, asyncpg translation, per-file lock_timeout#5
juanmicl merged 6 commits into
mainfrom
fix/0.4.2-hardening

Conversation

@juanmicl

@juanmicl juanmicl commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Release-hardening cycle implementing the six fixes adjudicated in the gap analysis:

  • B3 — bounded migrate advisory lock (3612b51): _chain_session now mirrors with_advisory_lock (pg_try_advisory_lock + 0.5s poll + monotonic deadline, default 30s) instead of blocking forever on pg_advisory_lock. Exposed as api.migrate(advisory_wait=...) and --advisory-wait; stamp rides the same bounded default. The push/migrate interlock (shared advisory_key) is preserved.
  • B4 — stamp refuses edited files unless --force (a7ebb43): stamping a chain file whose recorded checksum no longer matches (edited after apply) now fails loud with an instructive error instead of silently refreshing the checksum and wiping edit-detection. First mismatch stops the walk; nothing after it registers. force=True / --force accepts the new content deliberately.
  • S2 — per-file lock timeout (a93ca95): each migrate file transaction opens with SET LOCAL lock_timeout (txn-scoped, matching push's executor), exposed as lock_timeout / --lock-timeout (default 5s). Same commit types the revision() mkdir failure as SqlpushError.
  • B2 — asyncpg DSN translation (8c95049): the sync facade (_sync_engine_from) translates postgresql+asyncpg URLs to postgresql+psycopg (URL-API driver swap, credentials preserved) instead of dying on the async-only driver. asyncpg never needs to be installed.
  • S3c — enum dedup identity (da2927e): _dedup_enum_types returns the original op when nothing was dropped (output byte-identical, object identity preserved).
  • S3a/S3b — test pins (84064c3): end-to-end push-path enum dedup test (single pg_type row, check().clean) and the TimescaleDB index-parity boundary (same name, different columns still reports drift as the drop+add pair). Both proven non-vacuous by mutation checks.

Verification

  • 144 passed, 1 xfailed (baseline 131 + 13 new) against live timescaledb-ha:pg17; DB-free subset skips cleanly
  • ruff check, ruff format --check, ty check all clean
  • Every fix written test-first and observed failing pre-implementation
  • Review chain: full-diff oracle review — verdict approve for 0.4.2 (5 non-blocking nits; asyncpg query-param translation edges deferred to issues)

Release surface

CHANGELOG [Unreleased] carries four Fixed bullets (B3, B4, S2, B2 — API and CLI surfaces named in each); CLI exit-code docstring now documents stamp/revision. This PR is the 0.4.2 payload: on merge, cut v0.4.2.

_chain_session used a blocking pg_advisory_lock: a hung or stuck
holder (wedged txn, leaked session) would hang every migrate/stamp
indefinitely. Mirror the executor's with_advisory_lock shape inside
the chain session: pg_try_advisory_lock polled every 0.5s against a
time.monotonic() deadline (default 30s), SqlpushError on exhaustion,
negative-wait validation before any connection attempt. Plumb
advisory_wait through api.migrate and add the --advisory-wait flag
to the migrate verb (same default and style as push). The shared
advisory_key import and the versions-DDL/yield/unlock structure are
untouched, so the migrate/push interlock is preserved.
run_stamp upserted unconditionally and never read recorded state, so
stamping after editing an applied file silently refreshed its
checksum — wiping the edit-detection integrity migrate gates on.
Read the recorded checksums first; a registered-but-different
checksum raises an instructive SqlpushError (file edited after
apply; pass force/--force to accept the new content) and the first
mismatch stops the walk, so nothing after it registers. With no
previous row or force=True the upsert is unchanged. Plumb force
through api.stamp, add the --force flag to the stamp verb, refresh
the run_stamp docstring, and document stamp in the CLI exit-code
block (0 registered / 1 blocked or refused via the typed-error
path).
… mkdir error

run_migrate's per-file transaction ran without any lock timeout, so a
chain file whose DDL got stuck behind another transaction's lock
queued indefinitely. Issue SET LOCAL lock_timeout inside each file's
txn — txn-scoped so it dies with the file's commit/rollback, same
inline-int style and rationale as push's transactional segment
(executor.py; PG rejects bind params for SET). Plumb lock_timeout
(5.0 default, negative rejected before any file/connection work)
through api.migrate and add the --lock-timeout flag to the migrate
verb, matching push.

Also type revision()'s output-directory creation: a bare mkdir
OSError now surfaces as SqlpushError ("cannot create migrations
directory ..."), same typing as the adjacent write_text wrap — no
behavior change in the happy path, so no changelog entry.
_sync_engine_from resolved DSN strings and AsyncEngines verbatim into a
SYNC engine; a postgresql+asyncpg URL renders fine but its dialect is
async-only, so migrate/stamp/ensure_schema failed at connect. Both
branches now route through _translate_asyncpg (URL API, driver swap
only — host/db/credentials/query preserved); psycopg is a runtime dep
so the translated engine connects, asyncpg never has to be installed.
_dedup_enum_types' slow path always reconstructed a PlannedOperation
even when every statement survived (kept == stmts). Track a dropped
flag and append the original object (identity) when nothing was
removed; only ops that actually lost a duplicate statement are rebuilt.
Behavior otherwise identical — the rebuild round-trips to byte-equal
SQL for undropped renders, so plan output is unchanged.

The identity test is DB-free: test_enum_dedup.py drops its module-level
pg mark (marks now live on the DB tests individually) so the unit test
never requires the server.
Two coverage gaps in the S1/S2 hardening:

- push-path enum dedup: the shared-native-enum dedup was pinned at plan
  level and on the migrate replay path only. An end-to-end api.push
  test proves the dedup survives the apply path (advisory-lock re-plan
  included): the push applies cleanly and pg_type holds exactly one row
  for the shared enum.

- S1 parity boundary: only the m_cols == r_cols (equal) case was
  pinned. Mirror the born-DESC construction with a same-named declared
  index on a DIFFERENT column: the drop+add pair must still surface as
  drift — owner match alone must not prune. Mutation-checked: forcing
  the prune regardless of columns fails this test while the equal-case
  twin stays green.
@juanmicl
juanmicl merged commit 28300f7 into main Sep 2, 2026
7 checks passed
@juanmicl
juanmicl deleted the fix/0.4.2-hardening branch September 2, 2026 16:37
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.

1 participant