sqlpush 0.4.2__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqlpush/api.py CHANGED
@@ -48,8 +48,10 @@ def _raise_typed(exc: SQLAlchemyError) -> NoReturn:
48
48
  raise SqlpushError(f"database error: {exc}") from exc
49
49
 
50
50
 
51
- def _build_plan(metadata: MetaData, engine: Engine, schemas, exclude) -> Plan:
52
- p = _engine.plan(metadata, engine, schemas=schemas, exclude=exclude)
51
+ def _build_plan(
52
+ metadata: MetaData, engine: Engine, schemas, exclude, concurrently: bool = True
53
+ ) -> Plan:
54
+ p = _engine.plan(metadata, engine, schemas=schemas, exclude=exclude, concurrently=concurrently)
53
55
  return Plan(operations=p.operations + tuple(hypertable_operations(metadata, engine)))
54
56
 
55
57
 
@@ -65,13 +67,13 @@ class _PlannerWithDirectives(DiffEngine):
65
67
  def __init__(self, engine: DiffEngine) -> None:
66
68
  self._engine = engine
67
69
 
68
- def plan(self, metadata, engine, *, schemas=None, exclude=()) -> Plan:
69
- return _build_plan(metadata, engine, schemas, exclude)
70
+ def plan(self, metadata, engine, *, schemas=None, exclude=(), concurrently=True) -> Plan:
71
+ return _build_plan(metadata, engine, schemas, exclude, concurrently)
70
72
 
71
73
 
72
- def plan(metadata, engine, *, schemas=None, exclude=()) -> Plan:
74
+ def plan(metadata, engine, *, schemas=None, exclude=(), concurrently: bool = True) -> Plan:
73
75
  try:
74
- return _build_plan(metadata, engine, schemas, exclude)
76
+ return _build_plan(metadata, engine, schemas, exclude, concurrently)
75
77
  except SQLAlchemyError as exc:
76
78
  _raise_typed(exc)
77
79
 
@@ -84,9 +86,11 @@ def push(
84
86
  allow_destructive=False,
85
87
  lock=True,
86
88
  lock_timeout=5.0,
89
+ statement_timeout=None,
87
90
  advisory_wait=30.0,
88
91
  schemas=None,
89
92
  exclude=(),
93
+ concurrently=True,
90
94
  ) -> Report:
91
95
  try:
92
96
  if lock:
@@ -100,14 +104,21 @@ def push(
100
104
  reverify=_PlannerWithDirectives(_engine),
101
105
  schemas=schemas,
102
106
  exclude=exclude,
107
+ # THREADING (pinned): the lock winner re-plans via
108
+ # reverify.plan(...) — both knobs must reach it and the
109
+ # final apply_plan, or the winner renders differently
110
+ # than requested
111
+ concurrently=concurrently,
112
+ statement_timeout=statement_timeout,
103
113
  )
104
- p = _build_plan(metadata, engine, schemas, exclude)
114
+ p = _build_plan(metadata, engine, schemas, exclude, concurrently)
105
115
  return apply_plan(
106
116
  engine,
107
117
  p,
108
118
  allow_destructive=allow_destructive,
109
119
  safe_only=safe_only,
110
120
  lock_timeout=lock_timeout,
121
+ statement_timeout=statement_timeout,
111
122
  )
112
123
  except SQLAlchemyError as exc:
113
124
  _raise_typed(exc)
@@ -126,14 +137,23 @@ def check(metadata, engine, *, schemas=None, exclude=()) -> CheckResult:
126
137
 
127
138
 
128
139
  def revision(
129
- metadata, ref_engine, *, out_dir="migrations/versions", message=None, schemas=None, exclude=()
140
+ metadata,
141
+ ref_engine,
142
+ *,
143
+ out_dir="migrations/versions",
144
+ message=None,
145
+ schemas=None,
146
+ exclude=(),
147
+ concurrently=True,
130
148
  ) -> Path:
131
149
  """Generate the next annotated-SQL migration file from models-vs-ref drift.
132
150
 
133
151
  The reference DB must sit at the chain head (caller-provided — sqlpush
134
152
  stays docker-free). Empty drift refuses loudly: no empty files.
153
+ ``concurrently`` follows :func:`plan` (default True: existing-table
154
+ indexes render CONCURRENTLY in the generated file).
135
155
  """
136
- p = plan(metadata, ref_engine, schemas=schemas, exclude=exclude)
156
+ p = plan(metadata, ref_engine, schemas=schemas, exclude=exclude, concurrently=concurrently)
137
157
  if not p.operations:
138
158
  raise SqlpushError("no drift between models and reference DB — nothing to revise")
139
159
  risk = max((op.risk for op in p.operations), key=lambda r: RISK_RANK[r])
@@ -175,6 +195,7 @@ def migrate(
175
195
  allow_destructive=False,
176
196
  advisory_wait=30.0,
177
197
  lock_timeout=5.0,
198
+ statement_timeout=None,
178
199
  ) -> MigrateReport:
179
200
  """Replay annotated-SQL chain files with gates + same-txn bookkeeping.
180
201
 
@@ -182,7 +203,10 @@ def migrate(
182
203
  via ``_sync_engine_from``; engines created here are disposed).
183
204
  ``advisory_wait`` bounds the advisory-lock wait and ``lock_timeout``
184
205
  bounds each per-file transaction's lock wait (seconds; 0 = fail
185
- immediately — same contract as ``push``). See
206
+ immediately — same contract as ``push``). ``statement_timeout``
207
+ (seconds, ``None`` = set nothing) bounds each statement's runtime —
208
+ ``SET LOCAL`` in per-file transactions, session-level on the
209
+ CONCURRENTLY autocommit connection. See
186
210
  ``chain.migrate.run_migrate`` for the execution contract.
187
211
  """
188
212
  engine, dispose = _sync_engine_from(target)
@@ -193,6 +217,7 @@ def migrate(
193
217
  allow_destructive=allow_destructive,
194
218
  advisory_wait=advisory_wait,
195
219
  lock_timeout=lock_timeout,
220
+ statement_timeout=statement_timeout,
196
221
  )
197
222
  except SQLAlchemyError as exc:
198
223
  # MigrationFileError/SqlpushError (typed) pass through untouched
sqlpush/apply/executor.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import contextlib
5
5
  import time
6
+ from collections.abc import Iterator
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  from sqlalchemy import text
@@ -26,7 +27,37 @@ if TYPE_CHECKING:
26
27
 
27
28
 
28
29
  def _is_concurrent(op: PlannedOperation) -> bool:
29
- return "CONCURRENTLY" in op.sql.upper()
30
+ # Union (A5): the `concurrent` flag is authoritative for generated
31
+ # plans (set by the diff's injection); the SQL substring keeps
32
+ # hand-built Plans splitting to the autocommit segment.
33
+ return op.concurrent or "CONCURRENTLY" in op.sql.upper()
34
+
35
+
36
+ @contextlib.contextmanager
37
+ def _session_gucs(conn, **gucs: str | int) -> Iterator[None]:
38
+ """Session-level ``SET``/``RESET`` with pooled-connection hygiene.
39
+
40
+ The autocommit (CONCURRENTLY) segment cannot use ``SET LOCAL`` —
41
+ there is no surrounding transaction — so its GUCs are session-level,
42
+ and a session ``SET`` survives segment end (0.4.2 lesson). RESET runs
43
+ per GUC in ``finally``; a reset failure is suppressed (it must never
44
+ mask the segment's own outcome) and the connection is invalidated so
45
+ the pool discards it instead of handing the next borrower a session
46
+ still carrying the shrunken budget.
47
+ """
48
+ try:
49
+ for name, value in gucs.items():
50
+ # names come from sqlpush code, values are ints inlined in
51
+ # the same style as the txn segment's SET LOCAL (PostgreSQL
52
+ # does not accept bind parameters for SET)
53
+ conn.execute(text(f"SET {name} = '{value}'"))
54
+ yield
55
+ finally:
56
+ for name in gucs:
57
+ try:
58
+ conn.execute(text(f"RESET {name}"))
59
+ except Exception: # noqa: BLE001 # never mask the segment's outcome
60
+ conn.invalidate()
30
61
 
31
62
 
32
63
  def apply_plan(
@@ -36,8 +67,11 @@ def apply_plan(
36
67
  allow_destructive: bool = False,
37
68
  safe_only: bool = False,
38
69
  lock_timeout: float = 5.0,
70
+ statement_timeout: float | None = None,
39
71
  ) -> Report:
40
72
  start = time.monotonic()
73
+ if statement_timeout is not None and statement_timeout < 0:
74
+ raise SqlpushError(f"statement_timeout must be >= 0, got {statement_timeout}")
41
75
 
42
76
  blocked = tuple(op for op in plan.operations if op.risk is RiskClass.DESTRUCTIVE)
43
77
  if blocked and not allow_destructive:
@@ -69,13 +103,21 @@ def apply_plan(
69
103
  concurrent = [op for op in runnable if _is_concurrent(op)]
70
104
  if concurrent:
71
105
  with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
72
- for op in concurrent:
73
- try:
74
- conn.execute(text(op.sql))
75
- applied.append(AppliedOperation(op.type, "applied"))
76
- except Exception: # noqa: BLE001 # concurrent ops record any DB failure and continue
77
- applied.append(AppliedOperation(op.type, "failed"))
78
- partial_failure = True
106
+ # A7: the concurrent segment gets the SAME lock budget as the
107
+ # txn segment (session-level: autocommit has no surrounding
108
+ # transaction for SET LOCAL), plus the optional statement
109
+ # budget; both RESET before the connection returns to the pool
110
+ session_gucs: dict[str, int] = {"lock_timeout": int(lock_timeout * 1000)}
111
+ if statement_timeout is not None:
112
+ session_gucs["statement_timeout"] = int(statement_timeout * 1000)
113
+ with _session_gucs(conn, **session_gucs):
114
+ for op in concurrent:
115
+ try:
116
+ conn.execute(text(op.sql))
117
+ applied.append(AppliedOperation(op.type, "applied"))
118
+ except Exception: # noqa: BLE001 # concurrent ops record any DB failure and continue
119
+ applied.append(AppliedOperation(op.type, "failed"))
120
+ partial_failure = True
79
121
 
80
122
  # --- transactional segment: atomic ----------------------------------
81
123
  plain = [op for op in runnable if not _is_concurrent(op)]
@@ -83,9 +125,13 @@ def apply_plan(
83
125
  try:
84
126
  with engine.begin() as conn:
85
127
  # NOTE: PostgreSQL does not accept bind parameters for SET
86
- # (utility statement), so the int is inlined; lock_timeout is
87
- # a typed float parameter, not user input.
128
+ # (utility statement), so the int is inlined; both timeouts
129
+ # are typed float parameters, not user input.
88
130
  conn.execute(text(f"SET LOCAL lock_timeout = {int(lock_timeout * 1000)}"))
131
+ if statement_timeout is not None:
132
+ conn.execute(
133
+ text(f"SET LOCAL statement_timeout = {int(statement_timeout * 1000)}")
134
+ )
89
135
  for op in plain:
90
136
  conn.execute(text(op.sql))
91
137
  applied.extend(AppliedOperation(op.type, "applied") for op in plain)
@@ -146,6 +192,8 @@ def with_advisory_lock(
146
192
  reverify: DiffEngine | None = None,
147
193
  schemas: Sequence[str] | None = None,
148
194
  exclude: Sequence[str] = (),
195
+ concurrently: bool = True,
196
+ statement_timeout: float | None = None,
149
197
  ) -> Report:
150
198
  """Winner migrates; losers block (bounded), then re-verify.
151
199
 
@@ -153,7 +201,9 @@ def with_advisory_lock(
153
201
  ``time.monotonic()`` deadline; once the lock is acquired the winner
154
202
  path re-plans (covering the case where the previous winner died
155
203
  mid-push). Raises :class:`SqlpushError` if the wait budget is
156
- exhausted.
204
+ exhausted. ``concurrently`` and ``statement_timeout`` thread into
205
+ BOTH the winner's re-plan and its apply — the re-plan must render
206
+ exactly what the caller asked for.
157
207
  """
158
208
  if reverify is None:
159
209
  raise SqlpushError(
@@ -179,7 +229,13 @@ def with_advisory_lock(
179
229
  # (killed mid-push = lock silently released)
180
230
  conn.rollback()
181
231
  try:
182
- plan = reverify.plan(metadata, engine, schemas=schemas, exclude=exclude)
232
+ plan = reverify.plan(
233
+ metadata,
234
+ engine,
235
+ schemas=schemas,
236
+ exclude=exclude,
237
+ concurrently=concurrently,
238
+ )
183
239
  if not plan.drift:
184
240
  return Report()
185
241
  return apply_plan(
@@ -188,6 +244,7 @@ def with_advisory_lock(
188
244
  allow_destructive=allow_destructive,
189
245
  safe_only=safe_only,
190
246
  lock_timeout=timeout,
247
+ statement_timeout=statement_timeout,
191
248
  )
192
249
  finally:
193
250
  # best-effort unlock: a secondary failure here (e.g. the
sqlpush/chain/migrate.py CHANGED
@@ -1,13 +1,26 @@
1
1
  """migrate/stamp: chain replay + bootstrap with gates and bookkeeping.
2
2
 
3
- migrate execution contract (spec 2026-09-02 §5): each file's WHOLE TEXT is
4
- replayed in ONE ``exec_driver_sql()`` call inside a per-file transaction —
5
- the parsed ``ops`` are for gating/display only, NEVER reconstructed for
6
- execution (psycopg3 accepts multi-statement strings; nothing is tokenized,
7
- so dollar-quoted bodies are safe). The checksum row is inserted INSIDE the
8
- same transaction as the file's SQL — bookkeeping in a separate txn would
9
- let a crash between apply and registry re-apply the file forever
10
- (crash-loop over existing objects).
3
+ migrate execution contract (spec 2026-09-02 §5, 0.5.0 hybrid): a file
4
+ whose text contains no CONCURRENTLY replays its WHOLE TEXT in ONE
5
+ ``exec_driver_sql()`` call inside a per-file transaction — nothing is
6
+ tokenized, so dollar-quoted bodies (and their internal ``--`` lines)
7
+ are safe; that is the 0.4.2 fast path, byte-identical. A file that
8
+ DOES contain CONCURRENTLY replays per-op on the op-label delimiters
9
+ (``mf.ops``): the plain segment runs FIRST in one transaction (chain
10
+ files can create→index within one file — a table made by a plain op
11
+ must exist before a concurrent index on it; push's concurrent-first
12
+ order does NOT apply here), then concurrent ops run per-op on a
13
+ dedicated autocommit connection, and only after all of them succeed
14
+ is the versions row written. Known cost (chain spec §7): hand-edits
15
+ bypass the label mechanism — a label-less body containing
16
+ CONCURRENTLY routes the whole body to the autocommit lane (executed
17
+ statement-by-statement: the server runs a multi-statement string as
18
+ one implicit transaction, which CONCURRENTLY refuses), and lines
19
+ starting ``--`` are stripped from per-op parsing (they exist inside
20
+ dollar-quoted bodies at the author's risk). Concurrent-free files
21
+ are immune: they take the raw fast path. The crash window (plain
22
+ committed, concurrent applied, no row yet) re-runs loud on existing
23
+ objects — the documented chain-side cost of per-op replay.
11
24
 
12
25
  Fail-loud ordering: any blocked file (parse error, checksum mismatch,
13
26
  destructive gate, SQL failure) stops the chain — nothing later runs (R4).
@@ -19,6 +32,7 @@ holder — diagnose the holder via pg_locks / pg_stat_activity.
19
32
  from __future__ import annotations
20
33
 
21
34
  import contextlib
35
+ import re
22
36
  import time
23
37
  from collections.abc import Iterator
24
38
  from pathlib import Path
@@ -26,7 +40,7 @@ from pathlib import Path
26
40
  from sqlalchemy import text
27
41
  from sqlalchemy.engine import Connection, Engine
28
42
 
29
- from sqlpush.apply.executor import advisory_key
43
+ from sqlpush.apply.executor import _session_gucs, advisory_key
30
44
  from sqlpush.chain.format import MigrationFileError, checksum, parse_migration_file
31
45
  from sqlpush.types import MigrateReport, RiskClass, SqlpushError
32
46
 
@@ -46,6 +60,101 @@ def _chain_files(chain_dir: str | Path) -> list[Path]:
46
60
  return sorted(chain_path.glob("*.sql"))
47
61
 
48
62
 
63
+ def _set_local_gucs(
64
+ conn: Connection, *, lock_timeout: float, statement_timeout: float | None
65
+ ) -> None:
66
+ # txn-scoped: SET LOCAL dies with the surrounding transaction (style:
67
+ # push's transactional segment, executor.py). NOTE: PostgreSQL does
68
+ # not accept bind parameters for SET (utility statement), so ints
69
+ # are inlined; both timeouts are typed float parameters, not user
70
+ # input. A chain file blocked behind another transaction's lock
71
+ # fails fast instead of queuing.
72
+ conn.execute(text(f"SET LOCAL lock_timeout = {int(lock_timeout * 1000)}"))
73
+ if statement_timeout is not None:
74
+ conn.execute(text(f"SET LOCAL statement_timeout = {int(statement_timeout * 1000)}"))
75
+
76
+
77
+ def _record_version(conn: Connection, name: str, sha: str) -> None:
78
+ conn.execute(
79
+ text("INSERT INTO public.sqlpush_versions (name, sha256) VALUES (:n, :s)"),
80
+ {"n": name, "s": sha},
81
+ )
82
+
83
+
84
+ _DOLLAR_TAG_RE = re.compile(r"\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$")
85
+
86
+
87
+ def _split_statements(sql: str) -> list[str]:
88
+ """Split a concurrent-lane op into single statements.
89
+
90
+ Physical constraint (live-verified): the server executes a
91
+ multi-statement simple-protocol string inside ONE implicit
92
+ transaction block, even on an autocommit connection — so
93
+ ``CREATE INDEX CONCURRENTLY`` cannot ride a multi-statement
94
+ ``exec_driver_sql`` call; the autocommit lane needs one statement
95
+ per execute. Split on top-level ``;`` only, treating single-quoted
96
+ strings (``''`` doubling), double-quoted identifiers, dollar-quoted
97
+ bodies (``$tag$...$tag$``) and ``--`` / ``/* */`` comments as
98
+ opaque (psql-equivalent boundaries). Generated ops are single
99
+ statements — the splitter is a no-op for them; only hand-edited
100
+ label-less bodies exercise it (chain spec §7: at the author's
101
+ risk). The plain segment and the fast path NEVER split.
102
+ """
103
+ statements: list[str] = []
104
+ buf: list[str] = []
105
+ i, n = 0, len(sql)
106
+ while i < n:
107
+ ch = sql[i]
108
+ nxt = sql[i + 1] if i + 1 < n else ""
109
+ if ch == "-" and nxt == "-": # line comment
110
+ j = sql.find("\n", i)
111
+ j = n if j == -1 else j + 1
112
+ buf.append(sql[i:j])
113
+ i = j
114
+ elif ch == "/" and nxt == "*": # block comment
115
+ j = sql.find("*/", i + 2)
116
+ j = n if j == -1 else j + 2
117
+ buf.append(sql[i:j])
118
+ i = j
119
+ elif ch == "'": # string literal, '' doubling
120
+ j = i + 1
121
+ while j < n:
122
+ if sql[j] == "'" and not (j + 1 < n and sql[j + 1] == "'"):
123
+ break
124
+ j += 2 if sql[j] == "'" else 1
125
+ j = min(j + 1, n)
126
+ buf.append(sql[i:j])
127
+ i = j
128
+ elif ch == '"': # quoted identifier
129
+ j = sql.find('"', i + 1)
130
+ j = n if j == -1 else j + 1
131
+ buf.append(sql[i:j])
132
+ i = j
133
+ elif ch == "$":
134
+ m = _DOLLAR_TAG_RE.match(sql, i)
135
+ if m: # dollar-quoted body: opaque through the closing tag
136
+ end = sql.find(m.group(0), m.end())
137
+ j = n if end == -1 else end + len(m.group(0))
138
+ buf.append(sql[i:j])
139
+ i = j
140
+ else:
141
+ buf.append(ch)
142
+ i += 1
143
+ elif ch == ";":
144
+ stmt = "".join(buf).strip()
145
+ if stmt:
146
+ statements.append(stmt)
147
+ buf = []
148
+ i += 1
149
+ else:
150
+ buf.append(ch)
151
+ i += 1
152
+ tail = "".join(buf).strip()
153
+ if tail:
154
+ statements.append(tail)
155
+ return statements
156
+
157
+
49
158
  @contextlib.contextmanager
50
159
  def _chain_session(engine: Engine, *, advisory_wait: float = 30.0) -> Iterator[Connection]:
51
160
  """Session-scoped advisory lock + versions table, shared by every verb.
@@ -97,12 +206,15 @@ def run_migrate(
97
206
  allow_destructive: bool,
98
207
  advisory_wait: float = 30.0,
99
208
  lock_timeout: float = 5.0,
209
+ statement_timeout: float | None = None,
100
210
  ) -> MigrateReport:
101
211
  if lock_timeout < 0:
102
212
  # same contract as push (executor.with_advisory_lock): budgets
103
213
  # are typed floats, never user input, and a negative one must
104
214
  # fail before any file or connection work
105
215
  raise SqlpushError(f"lock_timeout must be >= 0, got {lock_timeout}")
216
+ if statement_timeout is not None and statement_timeout < 0:
217
+ raise SqlpushError(f"statement_timeout must be >= 0, got {statement_timeout}")
106
218
  applied: list[str] = []
107
219
  skipped: list[str] = []
108
220
  blocked: list[str] = []
@@ -115,49 +227,115 @@ def run_migrate(
115
227
  for row in conn.execute(text("SELECT name, sha256 FROM public.sqlpush_versions"))
116
228
  }
117
229
  conn.commit()
118
- for f in chain:
119
- raw = f.read_text()
120
- try:
121
- mf = parse_migration_file(raw, name=f.name)
122
- except MigrationFileError as exc:
123
- blocked.append(f.name)
124
- notes.append(f"{f.name}: {exc}")
125
- break # orden estricto: nada posterior corre
126
- if f.name in recorded:
127
- if recorded[f.name] != checksum(raw):
230
+ # B11: ONE lazily-created autocommit connection for the whole
231
+ # walk, GUC-pinned for its entire lifetime. ExitStack ordering:
232
+ # _session_gucs' finally (per-GUC RESET, suppress + invalidate
233
+ # on reset failure) unwinds BEFORE the connection close (LIFO).
234
+ concurrent_stack = contextlib.ExitStack()
235
+ concurrent_conn: Connection | None = None
236
+ try:
237
+ for f in chain:
238
+ raw = f.read_text()
239
+ try:
240
+ mf = parse_migration_file(raw, name=f.name)
241
+ except MigrationFileError as exc:
242
+ blocked.append(f.name)
243
+ notes.append(f"{f.name}: {exc}")
244
+ break # orden estricto: nada posterior corre
245
+ if f.name in recorded:
246
+ if recorded[f.name] != checksum(raw):
247
+ blocked.append(f.name)
248
+ notes.append(f"{f.name}: checksum mismatch (edited after apply?)")
249
+ break
250
+ skipped.append(f.name)
251
+ continue
252
+ if mf.risk is RiskClass.DESTRUCTIVE and not allow_destructive:
128
253
  blocked.append(f.name)
129
- notes.append(f"{f.name}: checksum mismatch (edited after apply?)")
254
+ notes.append(f"{f.name}: DESTRUCTIVE requires --allow-destructive")
130
255
  break
131
- skipped.append(f.name)
132
- continue
133
- if mf.risk is RiskClass.DESTRUCTIVE and not allow_destructive:
134
- blocked.append(f.name)
135
- notes.append(f"{f.name}: DESTRUCTIVE requires --allow-destructive")
136
- break
137
- try:
138
- with conn.begin():
139
- # txn-scoped: SET LOCAL dies with the per-file txn
140
- # (style: push's transactional segment, executor.py).
141
- # NOTE: PostgreSQL does not accept bind parameters for
142
- # SET (utility statement), so the int is inlined;
143
- # lock_timeout is a typed float parameter, not user
144
- # input. A chain file blocked behind another
145
- # transaction's lock fails fast instead of queuing.
146
- conn.execute(text(f"SET LOCAL lock_timeout = {int(lock_timeout * 1000)}"))
147
- # whole-file replay: exec_driver_sql bypasses text()'s
148
- # bind-param parsing entirely — ":casts" and ":=" in
149
- # hand-edited SQL must reach the server verbatim
150
- conn.exec_driver_sql(raw)
151
- conn.execute(
152
- text("INSERT INTO public.sqlpush_versions (name, sha256) VALUES (:n, :s)"),
153
- {"n": f.name, "s": checksum(raw)},
154
- )
155
- applied.append(f.name)
156
- except Exception as exc: # noqa: BLE001 — report, no mask
157
- blocked.append(f.name)
158
- notes.append(f"{f.name}: {exc}")
159
- partial = True
160
- break
256
+ if "CONCURRENTLY" not in raw.upper():
257
+ # fast path — byte-identical to 0.4.2: whole text,
258
+ # one txn, one exec_driver_sql call (":casts" and
259
+ # ":=" reach the server verbatim; dollar-quoted
260
+ # bodies and their internal `--` lines are safe —
261
+ # the parser's line-stripping quirks never execute
262
+ # on this path). Checksum row rides the SAME txn.
263
+ try:
264
+ with conn.begin():
265
+ _set_local_gucs(
266
+ conn,
267
+ lock_timeout=lock_timeout,
268
+ statement_timeout=statement_timeout,
269
+ )
270
+ conn.exec_driver_sql(raw)
271
+ _record_version(conn, f.name, checksum(raw))
272
+ applied.append(f.name)
273
+ except Exception as exc: # noqa: BLE001 — report, no mask
274
+ blocked.append(f.name)
275
+ notes.append(f"{f.name}: {exc}")
276
+ partial = True
277
+ break
278
+ continue
279
+ # mixed path — per-op replay on the op-label delimiters.
280
+ # PLAIN SEGMENT FIRST (create→index dependencies inside
281
+ # one file); concurrent ops afterwards, per-op on the
282
+ # dedicated autocommit connection; versions row LAST.
283
+ plain = [sql for _, sql in mf.ops if "CONCURRENTLY" not in sql.upper()]
284
+ conc = [sql for _, sql in mf.ops if "CONCURRENTLY" in sql.upper()]
285
+ plain_committed = False
286
+ try:
287
+ if plain:
288
+ with conn.begin():
289
+ _set_local_gucs(
290
+ conn,
291
+ lock_timeout=lock_timeout,
292
+ statement_timeout=statement_timeout,
293
+ )
294
+ for sql in plain:
295
+ conn.exec_driver_sql(sql)
296
+ plain_committed = True
297
+ for sql in conc:
298
+ if concurrent_conn is None:
299
+ concurrent_conn = engine.connect().execution_options(
300
+ isolation_level="AUTOCOMMIT"
301
+ )
302
+ concurrent_stack.callback(concurrent_conn.close)
303
+ session_gucs: dict[str, int] = {
304
+ "lock_timeout": int(lock_timeout * 1000)
305
+ }
306
+ if statement_timeout is not None:
307
+ session_gucs["statement_timeout"] = int(statement_timeout * 1000)
308
+ concurrent_stack.enter_context(
309
+ _session_gucs(concurrent_conn, **session_gucs)
310
+ )
311
+ # statement-per-execute: a multi-statement string
312
+ # is one implicit server txn (CONCURRENTLY refuses
313
+ # it) — generated ops are single statements, so
314
+ # this split only fires for hand-edited bodies
315
+ for stmt in _split_statements(sql):
316
+ concurrent_conn.exec_driver_sql(stmt)
317
+ # never record before the concurrent ops succeed: a
318
+ # row for a file whose concurrent op then failed would
319
+ # be silent divergence. The crash window this leaves
320
+ # (plain committed + concurrent applied + no row)
321
+ # re-runs loud on existing objects — documented cost.
322
+ with conn.begin():
323
+ _record_version(conn, f.name, checksum(raw))
324
+ applied.append(f.name)
325
+ except Exception as exc: # noqa: BLE001 — report, no mask
326
+ blocked.append(f.name)
327
+ notes.append(f"{f.name}: {exc}")
328
+ if plain_committed:
329
+ # honest report: the plain segment of THIS file is
330
+ # already committed (same class as push's partial
331
+ # failure) and no versions row was recorded
332
+ notes.append(
333
+ f"{f.name}: plain segment already committed; no versions row recorded"
334
+ )
335
+ partial = True
336
+ break
337
+ finally:
338
+ concurrent_stack.close()
161
339
  return MigrateReport(
162
340
  applied=tuple(applied),
163
341
  skipped=tuple(skipped),
sqlpush/cli.py CHANGED
@@ -148,6 +148,8 @@ def push(
148
148
  no_lock: bool = typer.Option(False, "--no-lock"),
149
149
  lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
150
150
  advisory_wait: float = typer.Option(30.0, "--advisory-wait"),
151
+ no_concurrently: bool = typer.Option(False, "--no-concurrently"),
152
+ statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
151
153
  verbose: bool = typer.Option(False, "--verbose"),
152
154
  quiet: bool = typer.Option(False, "--quiet"),
153
155
  schema: SchemaOpt = None,
@@ -165,6 +167,8 @@ def push(
165
167
  lock=not no_lock,
166
168
  lock_timeout=lock_timeout,
167
169
  advisory_wait=advisory_wait,
170
+ concurrently=not no_concurrently,
171
+ statement_timeout=statement_timeout,
168
172
  schemas=schema,
169
173
  exclude=exclude or (),
170
174
  )
@@ -223,6 +227,7 @@ def revision(
223
227
  ref_dsn: str = typer.Option(..., "--ref-dsn"),
224
228
  message: str | None = typer.Option(None, "--message", "-m"),
225
229
  out_dir: DirOpt = Path("migrations/versions"),
230
+ no_concurrently: bool = typer.Option(False, "--no-concurrently"),
226
231
  schema: SchemaOpt = None,
227
232
  exclude: ExcludeOpt = None,
228
233
  ):
@@ -238,6 +243,7 @@ def revision(
238
243
  engine,
239
244
  out_dir=out_dir,
240
245
  message=message,
246
+ concurrently=not no_concurrently,
241
247
  schemas=schema,
242
248
  exclude=exclude or (),
243
249
  )
@@ -253,6 +259,7 @@ def migrate(
253
259
  allow_destructive: bool = typer.Option(False, "--allow-destructive"),
254
260
  advisory_wait: float = typer.Option(30.0, "--advisory-wait"),
255
261
  lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
262
+ statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
256
263
  out_dir: DirOpt = Path("migrations/versions"),
257
264
  ):
258
265
  """Replay pending migration files (gates + checksum bookkeeping)."""
@@ -264,6 +271,7 @@ def migrate(
264
271
  allow_destructive=allow_destructive,
265
272
  advisory_wait=advisory_wait,
266
273
  lock_timeout=lock_timeout,
274
+ statement_timeout=statement_timeout,
267
275
  )
268
276
  finally:
269
277
  engine.dispose()
sqlpush/core/classify.py CHANGED
@@ -8,13 +8,18 @@ DESTRUCTIVE = frozenset({"drop_column", "drop_table", "drop_index", "drop_constr
8
8
 
9
9
 
10
10
  def classify(op_type: str) -> RiskClass:
11
- """add_index renders standalone: on alembic 1.19.1 even plain
12
- declared indexes of NEW tables arrive standalone
13
- (CreateTableOp.from_table captures columns+constraints, not
14
- indexes; only instrumentation-embedded ones ride inside the
15
- add_table render, and the diff dedups those away). What survives
16
- runs CREATE INDEX alone — a SHARE lock that blocks writes, hence
17
- risky."""
11
+ """add_index stays RISKY in BOTH renderings. The plain form takes a
12
+ SHARE lock that blocks writes for the whole build. CONCURRENTLY
13
+ (0.5 default for existing-table indexes) trades that write-block
14
+ for a different risk profile: the build is concurrent and
15
+ non-transactional (no rollback — a failure leaves no index behind
16
+ to retry with, but an aborted build can leave an INVALID index),
17
+ so the op is still flagged, never silently safe. Standalone
18
+ rendering note: on alembic 1.19.1 even plain declared indexes of
19
+ NEW tables arrive standalone (CreateTableOp.from_table captures
20
+ columns+constraints, not indexes; only instrumentation-embedded
21
+ ones ride inside the add_table render, and the diff dedups those
22
+ away)."""
18
23
  if op_type in DESTRUCTIVE:
19
24
  return RiskClass.DESTRUCTIVE
20
25
  if op_type in SAFE:
sqlpush/core/diff.py CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import dataclasses
5
6
  import fnmatch
6
7
  import io
8
+ import re
7
9
  from collections.abc import Sequence
8
10
 
9
11
  from alembic.autogenerate import produce_migrations
@@ -464,6 +466,43 @@ def _render_op_sql(op, engine: Engine) -> str:
464
466
  return buf.getvalue().strip().rstrip(";")
465
467
 
466
468
 
469
+ # A1: match the CREATE [UNIQUE] INDEX head of an op's (single-statement)
470
+ # SQL. Anchored with no MULTILINE and sub'd with count=1: only the op's
471
+ # own leading statement is touched. The lookahead makes it idempotent —
472
+ # a second pass over an already-injected render finds CONCURRENTLY right
473
+ # after the consumed whitespace and declines. IF NOT EXISTS lands after
474
+ # the inserted keyword, which is the correct PostgreSQL order.
475
+ _CONCURRENTLY_RE = re.compile(
476
+ r"^(CREATE\s+(?:UNIQUE\s+)?INDEX)\s+(?!CONCURRENTLY\s)", re.IGNORECASE
477
+ )
478
+
479
+
480
+ def _render_concurrent(ops: list[PlannedOperation]) -> list[PlannedOperation]:
481
+ """Render standalone ``add_index`` ops on EXISTING tables CONCURRENTLY.
482
+
483
+ Plain ``CREATE INDEX`` takes a SHARE lock that blocks all writes on
484
+ the table for the whole build; ``CREATE INDEX CONCURRENTLY`` is the
485
+ production-safe form for tables that already carry traffic. Indexes
486
+ on tables CREATED in the same plan stay plain: a brand-new table has
487
+ no concurrent writers to protect, and CONCURRENTLY cannot run inside
488
+ the plan's transactional create. The new-table guard keys on bare
489
+ table names (``op.table``), which is conservative-correct: a false
490
+ "new" (cross-schema bare-name collision) yields a plain
491
+ ``CREATE INDEX`` — always executable — while the dangerous direction
492
+ (injecting on a new table) cannot happen.
493
+ """
494
+ new_tables = {op.table for op in ops if op.type == "add_table"}
495
+ out: list[PlannedOperation] = []
496
+ for op in ops:
497
+ if op.type == "add_index" and op.table not in new_tables:
498
+ sql = _CONCURRENTLY_RE.sub(r"\1 CONCURRENTLY ", op.sql, count=1)
499
+ if sql != op.sql:
500
+ out.append(dataclasses.replace(op, sql=sql, concurrent=True))
501
+ continue
502
+ out.append(op)
503
+ return out
504
+
505
+
467
506
  class DiffEngine:
468
507
  def plan(
469
508
  self,
@@ -472,6 +511,7 @@ class DiffEngine:
472
511
  *,
473
512
  schemas: Sequence[str] | None = None,
474
513
  exclude: Sequence[str] = (),
514
+ concurrently: bool = True,
475
515
  ) -> Plan:
476
516
  exclude = tuple(exclude)
477
517
  # Typed `str | None` by SQLAlchemy; a dialect without a default
@@ -516,6 +556,15 @@ class DiffEngine:
516
556
  ops.extend(self._translate(op, engine, exclude))
517
557
  ops = _dedup_embedded_indexes(ops)
518
558
  ops = _dedup_enum_types(ops)
559
+ # ORDERING INVARIANT: CONCURRENTLY injection must run AFTER both
560
+ # dedups, as the final pipeline stage. _dedup_embedded_indexes
561
+ # matches standalone renders against the add_table render by
562
+ # byte containment — injecting first would rewrite only the
563
+ # standalone copy, break the byte-identity, and let the pair
564
+ # execute twice. By construction the dedup never sees a
565
+ # CONCURRENTLY render.
566
+ if concurrently:
567
+ ops = _render_concurrent(ops)
519
568
  return Plan(operations=tuple(ops))
520
569
 
521
570
  def _translate(self, op, engine: Engine, exclude: tuple[str, ...]) -> list[PlannedOperation]:
sqlpush/types.py CHANGED
@@ -19,6 +19,11 @@ class PlannedOperation:
19
19
  sql: str
20
20
  table: str | None = None
21
21
  column: str | None = None
22
+ # True when the op's SQL was rendered with CONCURRENTLY (the
23
+ # executor's authoritative routing signal for generated plans;
24
+ # hand-built plans keep the SQL-substring fallback). Defaults False
25
+ # so every pre-existing construction site is unchanged.
26
+ concurrent: bool = False
22
27
 
23
28
 
24
29
  @dataclass(frozen=True)
@@ -44,6 +49,7 @@ class Plan:
44
49
  "table": op.table,
45
50
  "column": op.column,
46
51
  "sql": op.sql,
52
+ "concurrent": op.concurrent,
47
53
  }
48
54
  for op in self.operations
49
55
  ],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlpush
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
5
5
  Keywords: sqlalchemy,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
6
6
  Author: Juan Miguel Contreras
@@ -133,8 +133,11 @@ repeated `--schema` / `--exclude` options.
133
133
  | `push` | applied | destructive blocked | error (incl. partial failure) | |
134
134
 
135
135
  `push --safe-only` runs only safe operations and skips the rest
136
- informationally (exit `0`). A failed `CREATE INDEX CONCURRENTLY` marks the
137
- run as partial failure (exit `2`) instead of silently half-applying.
136
+ informationally (exit `0`). Indexes on existing tables build
137
+ `CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
138
+ `CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
139
+ instead of silently half-applying, and leaves an INVALID index — drop it
140
+ (`DROP INDEX CONCURRENTLY`) and re-push.
138
141
 
139
142
  ## FastAPI / SQLModel: replace `create_all`
140
143
 
@@ -169,9 +172,11 @@ flowchart LR
169
172
  internals included) before reflection even starts.
170
173
  - **Classifier** maps each operation to a risk class; unknown operations
171
174
  are `risky`, never silently safe.
172
- - **Executor** splits the plan: `CONCURRENTLY` statements run one-per-
173
- transaction on autocommit, everything else applies in a single atomic
174
- transaction with a bounded `lock_timeout`.
175
+ - **Executor** splits the plan: existing-table indexes render
176
+ `CREATE INDEX CONCURRENTLY` and run one-per-transaction on autocommit
177
+ (`--no-concurrently` opts out; indexes on tables the same plan creates
178
+ stay in the atomic transaction), everything else applies in a single
179
+ atomic transaction with a bounded `lock_timeout`.
175
180
  - **Typed errors**: only `SqlpushError` / `ConnectFailed` /
176
181
  `MetadataImportError` escape the API, never raw driver exceptions.
177
182
 
@@ -201,12 +206,11 @@ deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
201
206
  - The advisory-lock key derives from the database OID: two DSN spellings
202
207
  of the same database contend for the same lock.
203
208
  - `--json` output is a versioned contract (`"version": 1`) meant for
204
- tooling; additive changes only within a version.
209
+ tooling; additive changes only within a version (operations now carry
210
+ a `concurrent` boolean).
205
211
 
206
212
  ## Roadmap (0.1.x)
207
213
 
208
- - `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
209
- - asyncpg DSN translation in `ensure_schema(AsyncEngine)`
210
214
  - jsonschema-validated `--json` output
211
215
 
212
216
  ## License
@@ -0,0 +1,20 @@
1
+ sqlpush/__init__.py,sha256=Px0jJFHbwwM1-UHtDwD3xQlRifEMW4StSXQs5DD9z6w,1697
2
+ sqlpush/annotations.py,sha256=xe5EoYPvraLJy-WvMkFK_zOIKquB3-VsrAzy6HE_hiA,4652
3
+ sqlpush/api.py,sha256=JTIDn-O50j9ytVTMRXJVu6eZcGbCX_v-03zqJ23laig,11235
4
+ sqlpush/apply/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ sqlpush/apply/executor.py,sha256=qHvfxB1sWQAQ0oMPDGuEr-HRaJwOYoVSB_s-7x-CZTc,10087
6
+ sqlpush/chain/__init__.py,sha256=AJ96P3I63mX6-uMuK0paCpKtaRexufnCqjUuElbyAsM,293
7
+ sqlpush/chain/format.py,sha256=uj0iMQfFWa9HpKhrokfJu_5B_FesiD4qCZxAGDJ3bfQ,4938
8
+ sqlpush/chain/migrate.py,sha256=P2grgrg8dkLJCBhL6CzNwa4xG8-VuFLqgN7XuvjFhwQ,18575
9
+ sqlpush/cli.py,sha256=7bjfh_YcFs4yIJtTS44YWr2YWQwGNuccHirkdorJXm4,11099
10
+ sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ sqlpush/core/classify.py,sha256=xjBFIPAV8lxMsZZRiOQ1Yxlpgz4r-ZpCtnNXA4KAw0o,1290
12
+ sqlpush/core/diff.py,sha256=OkH0fLl9NLOyWFnefknvBK8SWeN6bnJz4kQJfK3-wFA,26552
13
+ sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
14
+ sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ sqlpush/directives/timescale.py,sha256=W_Cd_stdhKZlzlMum7Bdzq99VfXOulYxq0B-sukz-xE,2645
16
+ sqlpush/types.py,sha256=hXetdVqMzYcBQi0M_x3qmCIOa3QzhuwxYNJkZRWz4qM,3280
17
+ sqlpush-0.5.0.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
18
+ sqlpush-0.5.0.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
19
+ sqlpush-0.5.0.dist-info/METADATA,sha256=7pDHwPL5eTzOHoS5rpp6259RlcEYU-y0OXcIw7HtcHY,8609
20
+ sqlpush-0.5.0.dist-info/RECORD,,
@@ -1,20 +0,0 @@
1
- sqlpush/__init__.py,sha256=Px0jJFHbwwM1-UHtDwD3xQlRifEMW4StSXQs5DD9z6w,1697
2
- sqlpush/annotations.py,sha256=xe5EoYPvraLJy-WvMkFK_zOIKquB3-VsrAzy6HE_hiA,4652
3
- sqlpush/api.py,sha256=7fHsy1AHd2ffca-wtQDowfpt70P7c6L0_s720k8gRcU,10166
4
- sqlpush/apply/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- sqlpush/apply/executor.py,sha256=-HgmscUO6M_xQ5HPBQhJkUo6XwEPPqBig7_G9aWQJAU,7242
6
- sqlpush/chain/__init__.py,sha256=AJ96P3I63mX6-uMuK0paCpKtaRexufnCqjUuElbyAsM,293
7
- sqlpush/chain/format.py,sha256=uj0iMQfFWa9HpKhrokfJu_5B_FesiD4qCZxAGDJ3bfQ,4938
8
- sqlpush/chain/migrate.py,sha256=P7I07Zv3xRmRYgFTTLAqkrUdbjsGOGNQZG2j-eP5Hgk,10055
9
- sqlpush/cli.py,sha256=7TBKMxmvkpwSH6gyqs2fDQ5Tyec1yMOLGJCUJb-2Af0,10599
10
- sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- sqlpush/core/classify.py,sha256=-HNPQ1Kfnr0MMYLoP26_VC14JjcQHravrbT7lPGqlIU,927
12
- sqlpush/core/diff.py,sha256=q0f7NtiKQ8N33UbI40ttxgiNbsAsBpqa0XJ15oTLSBY,24147
13
- sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
14
- sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- sqlpush/directives/timescale.py,sha256=W_Cd_stdhKZlzlMum7Bdzq99VfXOulYxq0B-sukz-xE,2645
16
- sqlpush/types.py,sha256=IMA1HRLIumat7tpt8Yscv-9g2U5U_--xxnfcf_pfkyo,2938
17
- sqlpush-0.4.2.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
18
- sqlpush-0.4.2.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
19
- sqlpush-0.4.2.dist-info/METADATA,sha256=KwaHMTxd1Upr_Mf2GbulnbnY3MY58NfBLNBacSNQjWE,8373
20
- sqlpush-0.4.2.dist-info/RECORD,,