sqlpush 0.4.2__tar.gz → 0.5.0__tar.gz
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-0.4.2 → sqlpush-0.5.0}/PKG-INFO +13 -9
- {sqlpush-0.4.2 → sqlpush-0.5.0}/README.md +12 -8
- {sqlpush-0.4.2 → sqlpush-0.5.0}/pyproject.toml +1 -1
- {sqlpush-0.4.2 → sqlpush-0.5.0}/pyproject.toml.orig +1 -1
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/api.py +35 -10
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/apply/executor.py +69 -12
- sqlpush-0.5.0/src/sqlpush/chain/migrate.py +404 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/cli.py +8 -0
- sqlpush-0.5.0/src/sqlpush/core/classify.py +28 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/core/diff.py +49 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/types.py +6 -0
- sqlpush-0.4.2/src/sqlpush/chain/migrate.py +0 -226
- sqlpush-0.4.2/src/sqlpush/core/classify.py +0 -23
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/__init__.py +0 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/annotations.py +0 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/apply/__init__.py +0 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/chain/__init__.py +0 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/chain/format.py +0 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/core/__init__.py +0 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/core/render.py +0 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/directives/__init__.py +0 -0
- {sqlpush-0.4.2 → sqlpush-0.5.0}/src/sqlpush/directives/timescale.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlpush
|
|
3
|
-
Version: 0.
|
|
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`).
|
|
137
|
-
|
|
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:
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
@@ -104,8 +104,11 @@ repeated `--schema` / `--exclude` options.
|
|
|
104
104
|
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
105
105
|
|
|
106
106
|
`push --safe-only` runs only safe operations and skips the rest
|
|
107
|
-
informationally (exit `0`).
|
|
108
|
-
|
|
107
|
+
informationally (exit `0`). Indexes on existing tables build
|
|
108
|
+
`CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
|
|
109
|
+
`CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
|
|
110
|
+
instead of silently half-applying, and leaves an INVALID index — drop it
|
|
111
|
+
(`DROP INDEX CONCURRENTLY`) and re-push.
|
|
109
112
|
|
|
110
113
|
## FastAPI / SQLModel: replace `create_all`
|
|
111
114
|
|
|
@@ -140,9 +143,11 @@ flowchart LR
|
|
|
140
143
|
internals included) before reflection even starts.
|
|
141
144
|
- **Classifier** maps each operation to a risk class; unknown operations
|
|
142
145
|
are `risky`, never silently safe.
|
|
143
|
-
- **Executor** splits the plan:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
- **Executor** splits the plan: existing-table indexes render
|
|
147
|
+
`CREATE INDEX CONCURRENTLY` and run one-per-transaction on autocommit
|
|
148
|
+
(`--no-concurrently` opts out; indexes on tables the same plan creates
|
|
149
|
+
stay in the atomic transaction), everything else applies in a single
|
|
150
|
+
atomic transaction with a bounded `lock_timeout`.
|
|
146
151
|
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
147
152
|
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
148
153
|
|
|
@@ -172,12 +177,11 @@ deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
|
|
|
172
177
|
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
173
178
|
of the same database contend for the same lock.
|
|
174
179
|
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
175
|
-
tooling; additive changes only within a version
|
|
180
|
+
tooling; additive changes only within a version (operations now carry
|
|
181
|
+
a `concurrent` boolean).
|
|
176
182
|
|
|
177
183
|
## Roadmap (0.1.x)
|
|
178
184
|
|
|
179
|
-
- `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
|
|
180
|
-
- asyncpg DSN translation in `ensure_schema(AsyncEngine)`
|
|
181
185
|
- jsonschema-validated `--json` output
|
|
182
186
|
|
|
183
187
|
## License
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlpush"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlpush"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -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(
|
|
52
|
-
|
|
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,
|
|
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``).
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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;
|
|
87
|
-
#
|
|
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(
|
|
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
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""migrate/stamp: chain replay + bootstrap with gates and bookkeeping.
|
|
2
|
+
|
|
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.
|
|
24
|
+
|
|
25
|
+
Fail-loud ordering: any blocked file (parse error, checksum mismatch,
|
|
26
|
+
destructive gate, SQL failure) stops the chain — nothing later runs (R4).
|
|
27
|
+
The advisory lock wait is BOUNDED (--advisory-wait, default 30s): an
|
|
28
|
+
exhausted budget raises a typed error instead of hanging on a stuck
|
|
29
|
+
holder — diagnose the holder via pg_locks / pg_stat_activity.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import contextlib
|
|
35
|
+
import re
|
|
36
|
+
import time
|
|
37
|
+
from collections.abc import Iterator
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from sqlalchemy import text
|
|
41
|
+
from sqlalchemy.engine import Connection, Engine
|
|
42
|
+
|
|
43
|
+
from sqlpush.apply.executor import _session_gucs, advisory_key
|
|
44
|
+
from sqlpush.chain.format import MigrationFileError, checksum, parse_migration_file
|
|
45
|
+
from sqlpush.types import MigrateReport, RiskClass, SqlpushError
|
|
46
|
+
|
|
47
|
+
_VERSIONS_DDL = (
|
|
48
|
+
"CREATE TABLE IF NOT EXISTS public.sqlpush_versions ("
|
|
49
|
+
"name TEXT PRIMARY KEY, sha256 TEXT NOT NULL, "
|
|
50
|
+
"applied_at timestamptz NOT NULL DEFAULT now())"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _chain_files(chain_dir: str | Path) -> list[Path]:
|
|
55
|
+
chain_path = Path(chain_dir)
|
|
56
|
+
if not chain_path.is_dir():
|
|
57
|
+
# a typo'd --dir silently no-op'ing is the footgun; an empty-but-
|
|
58
|
+
# EXISTING dir is a legitimate idle run (versions table still ensured)
|
|
59
|
+
raise MigrationFileError(f"chain dir not found: {chain_path}")
|
|
60
|
+
return sorted(chain_path.glob("*.sql"))
|
|
61
|
+
|
|
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
|
+
|
|
158
|
+
@contextlib.contextmanager
|
|
159
|
+
def _chain_session(engine: Engine, *, advisory_wait: float = 30.0) -> Iterator[Connection]:
|
|
160
|
+
"""Session-scoped advisory lock + versions table, shared by every verb.
|
|
161
|
+
|
|
162
|
+
Same key derivation as push (fnv1a_32(b"sqlpush") ^ db oid): serializes
|
|
163
|
+
concurrent chain workers and excludes push on the same database. The
|
|
164
|
+
wait is BOUNDED, mirroring executor.with_advisory_lock:
|
|
165
|
+
``pg_try_advisory_lock`` polled every 0.5 s against a
|
|
166
|
+
``time.monotonic()`` deadline; an exhausted budget raises
|
|
167
|
+
:class:`SqlpushError` — a blocking ``pg_advisory_lock`` would hang
|
|
168
|
+
forever on a stuck holder. The txn opened by the key/probe queries
|
|
169
|
+
is committed right away — session advisory locks survive
|
|
170
|
+
COMMIT/ROLLBACK, and an idle-in-transaction session is exposed to
|
|
171
|
+
idle_in_transaction_session_timeout (executor.py note).
|
|
172
|
+
"""
|
|
173
|
+
if advisory_wait < 0:
|
|
174
|
+
raise SqlpushError(f"advisory_wait must be >= 0, got {advisory_wait}")
|
|
175
|
+
with engine.connect() as conn:
|
|
176
|
+
key = advisory_key(conn)
|
|
177
|
+
deadline = time.monotonic() + advisory_wait
|
|
178
|
+
locked = conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
|
|
179
|
+
while not locked and time.monotonic() < deadline:
|
|
180
|
+
time.sleep(0.5)
|
|
181
|
+
locked = conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
|
|
182
|
+
if not locked:
|
|
183
|
+
raise SqlpushError(
|
|
184
|
+
f"another sqlpush worker holds the advisory lock after {advisory_wait}s"
|
|
185
|
+
)
|
|
186
|
+
conn.commit()
|
|
187
|
+
try:
|
|
188
|
+
conn.execute(text(_VERSIONS_DDL))
|
|
189
|
+
conn.commit()
|
|
190
|
+
yield conn
|
|
191
|
+
finally:
|
|
192
|
+
# best-effort unlock (executor pattern): a secondary failure here
|
|
193
|
+
# must never mask the primary outcome; the session lock dies with
|
|
194
|
+
# the connection even if this fails
|
|
195
|
+
if not conn.closed:
|
|
196
|
+
with contextlib.suppress(Exception):
|
|
197
|
+
conn.rollback()
|
|
198
|
+
conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
|
|
199
|
+
conn.commit()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def run_migrate(
|
|
203
|
+
engine: Engine,
|
|
204
|
+
*,
|
|
205
|
+
chain_dir: str | Path,
|
|
206
|
+
allow_destructive: bool,
|
|
207
|
+
advisory_wait: float = 30.0,
|
|
208
|
+
lock_timeout: float = 5.0,
|
|
209
|
+
statement_timeout: float | None = None,
|
|
210
|
+
) -> MigrateReport:
|
|
211
|
+
if lock_timeout < 0:
|
|
212
|
+
# same contract as push (executor.with_advisory_lock): budgets
|
|
213
|
+
# are typed floats, never user input, and a negative one must
|
|
214
|
+
# fail before any file or connection work
|
|
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}")
|
|
218
|
+
applied: list[str] = []
|
|
219
|
+
skipped: list[str] = []
|
|
220
|
+
blocked: list[str] = []
|
|
221
|
+
notes: list[str] = []
|
|
222
|
+
partial = False
|
|
223
|
+
chain = _chain_files(chain_dir)
|
|
224
|
+
with _chain_session(engine, advisory_wait=advisory_wait) as conn:
|
|
225
|
+
recorded = {
|
|
226
|
+
row[0]: row[1]
|
|
227
|
+
for row in conn.execute(text("SELECT name, sha256 FROM public.sqlpush_versions"))
|
|
228
|
+
}
|
|
229
|
+
conn.commit()
|
|
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:
|
|
253
|
+
blocked.append(f.name)
|
|
254
|
+
notes.append(f"{f.name}: DESTRUCTIVE requires --allow-destructive")
|
|
255
|
+
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()
|
|
339
|
+
return MigrateReport(
|
|
340
|
+
applied=tuple(applied),
|
|
341
|
+
skipped=tuple(skipped),
|
|
342
|
+
blocked=tuple(blocked),
|
|
343
|
+
partial_failure=partial,
|
|
344
|
+
notes=tuple(notes),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def run_stamp(engine: Engine, *, chain_dir: str | Path, force: bool = False) -> MigrateReport:
|
|
349
|
+
"""Register every parseable chain file WITHOUT executing any SQL.
|
|
350
|
+
|
|
351
|
+
Bootstrap seam (spec §5 R7): adopt a DB whose schema already reflects
|
|
352
|
+
the chain. Only the header must parse (fail-loud on THAT) — invalid SQL
|
|
353
|
+
in a body still registers, because stamp never executes anything. A
|
|
354
|
+
file already recorded with a DIFFERENT checksum is refused — it was
|
|
355
|
+
edited after apply/stamp, and silently refreshing would wipe the
|
|
356
|
+
edit-detection integrity — unless ``force`` is set; the first mismatch
|
|
357
|
+
raises and nothing after it registers (strict order, same as migrate).
|
|
358
|
+
Unrecorded files, unchanged re-stamps and forced re-stamps upsert
|
|
359
|
+
idempotently (``ON CONFLICT (name) DO UPDATE``).
|
|
360
|
+
|
|
361
|
+
Report convention: registered files are listed in ``skipped`` (stamp
|
|
362
|
+
never populates ``applied`` and never sets ``partial_failure``); a
|
|
363
|
+
header that fails to parse goes to ``blocked`` + ``notes`` and stops
|
|
364
|
+
the walk (strict order, same as migrate).
|
|
365
|
+
"""
|
|
366
|
+
skipped: list[str] = []
|
|
367
|
+
blocked: list[str] = []
|
|
368
|
+
notes: list[str] = []
|
|
369
|
+
chain = _chain_files(chain_dir)
|
|
370
|
+
with _chain_session(engine) as conn:
|
|
371
|
+
recorded = {
|
|
372
|
+
row[0]: row[1]
|
|
373
|
+
for row in conn.execute(text("SELECT name, sha256 FROM public.sqlpush_versions"))
|
|
374
|
+
}
|
|
375
|
+
conn.commit()
|
|
376
|
+
for f in chain:
|
|
377
|
+
raw = f.read_text()
|
|
378
|
+
try:
|
|
379
|
+
parse_migration_file(raw, name=f.name)
|
|
380
|
+
except MigrationFileError as exc:
|
|
381
|
+
blocked.append(f.name)
|
|
382
|
+
notes.append(f"{f.name}: {exc}")
|
|
383
|
+
break # orden estricto: nada posterior se registra
|
|
384
|
+
if f.name in recorded and recorded[f.name] != checksum(raw) and not force:
|
|
385
|
+
raise SqlpushError(
|
|
386
|
+
f"{f.name}: checksum mismatch (file edited after apply?); "
|
|
387
|
+
"pass force=True/--force to accept the new content"
|
|
388
|
+
)
|
|
389
|
+
with conn.begin():
|
|
390
|
+
conn.execute(
|
|
391
|
+
text(
|
|
392
|
+
"INSERT INTO public.sqlpush_versions (name, sha256) VALUES (:n, :s) "
|
|
393
|
+
"ON CONFLICT (name) DO UPDATE SET sha256 = EXCLUDED.sha256"
|
|
394
|
+
),
|
|
395
|
+
{"n": f.name, "s": checksum(raw)},
|
|
396
|
+
)
|
|
397
|
+
skipped.append(f.name)
|
|
398
|
+
return MigrateReport(
|
|
399
|
+
applied=(),
|
|
400
|
+
skipped=tuple(skipped),
|
|
401
|
+
blocked=tuple(blocked),
|
|
402
|
+
partial_failure=False,
|
|
403
|
+
notes=tuple(notes),
|
|
404
|
+
)
|
|
@@ -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()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# src/sqlpush/core/classify.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from sqlpush.types import RiskClass
|
|
5
|
+
|
|
6
|
+
SAFE = frozenset({"add_table", "add_column", "add_constraint", "create_hypertable"})
|
|
7
|
+
DESTRUCTIVE = frozenset({"drop_column", "drop_table", "drop_index", "drop_constraint"})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def classify(op_type: str) -> RiskClass:
|
|
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)."""
|
|
23
|
+
if op_type in DESTRUCTIVE:
|
|
24
|
+
return RiskClass.DESTRUCTIVE
|
|
25
|
+
if op_type in SAFE:
|
|
26
|
+
return RiskClass.SAFE
|
|
27
|
+
# modify_*, add_index, raw_sql, and anything unknown
|
|
28
|
+
return RiskClass.RISKY
|
|
@@ -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]:
|
|
@@ -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,226 +0,0 @@
|
|
|
1
|
-
"""migrate/stamp: chain replay + bootstrap with gates and bookkeeping.
|
|
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).
|
|
11
|
-
|
|
12
|
-
Fail-loud ordering: any blocked file (parse error, checksum mismatch,
|
|
13
|
-
destructive gate, SQL failure) stops the chain — nothing later runs (R4).
|
|
14
|
-
The advisory lock wait is BOUNDED (--advisory-wait, default 30s): an
|
|
15
|
-
exhausted budget raises a typed error instead of hanging on a stuck
|
|
16
|
-
holder — diagnose the holder via pg_locks / pg_stat_activity.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import contextlib
|
|
22
|
-
import time
|
|
23
|
-
from collections.abc import Iterator
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
|
|
26
|
-
from sqlalchemy import text
|
|
27
|
-
from sqlalchemy.engine import Connection, Engine
|
|
28
|
-
|
|
29
|
-
from sqlpush.apply.executor import advisory_key
|
|
30
|
-
from sqlpush.chain.format import MigrationFileError, checksum, parse_migration_file
|
|
31
|
-
from sqlpush.types import MigrateReport, RiskClass, SqlpushError
|
|
32
|
-
|
|
33
|
-
_VERSIONS_DDL = (
|
|
34
|
-
"CREATE TABLE IF NOT EXISTS public.sqlpush_versions ("
|
|
35
|
-
"name TEXT PRIMARY KEY, sha256 TEXT NOT NULL, "
|
|
36
|
-
"applied_at timestamptz NOT NULL DEFAULT now())"
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _chain_files(chain_dir: str | Path) -> list[Path]:
|
|
41
|
-
chain_path = Path(chain_dir)
|
|
42
|
-
if not chain_path.is_dir():
|
|
43
|
-
# a typo'd --dir silently no-op'ing is the footgun; an empty-but-
|
|
44
|
-
# EXISTING dir is a legitimate idle run (versions table still ensured)
|
|
45
|
-
raise MigrationFileError(f"chain dir not found: {chain_path}")
|
|
46
|
-
return sorted(chain_path.glob("*.sql"))
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@contextlib.contextmanager
|
|
50
|
-
def _chain_session(engine: Engine, *, advisory_wait: float = 30.0) -> Iterator[Connection]:
|
|
51
|
-
"""Session-scoped advisory lock + versions table, shared by every verb.
|
|
52
|
-
|
|
53
|
-
Same key derivation as push (fnv1a_32(b"sqlpush") ^ db oid): serializes
|
|
54
|
-
concurrent chain workers and excludes push on the same database. The
|
|
55
|
-
wait is BOUNDED, mirroring executor.with_advisory_lock:
|
|
56
|
-
``pg_try_advisory_lock`` polled every 0.5 s against a
|
|
57
|
-
``time.monotonic()`` deadline; an exhausted budget raises
|
|
58
|
-
:class:`SqlpushError` — a blocking ``pg_advisory_lock`` would hang
|
|
59
|
-
forever on a stuck holder. The txn opened by the key/probe queries
|
|
60
|
-
is committed right away — session advisory locks survive
|
|
61
|
-
COMMIT/ROLLBACK, and an idle-in-transaction session is exposed to
|
|
62
|
-
idle_in_transaction_session_timeout (executor.py note).
|
|
63
|
-
"""
|
|
64
|
-
if advisory_wait < 0:
|
|
65
|
-
raise SqlpushError(f"advisory_wait must be >= 0, got {advisory_wait}")
|
|
66
|
-
with engine.connect() as conn:
|
|
67
|
-
key = advisory_key(conn)
|
|
68
|
-
deadline = time.monotonic() + advisory_wait
|
|
69
|
-
locked = conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
|
|
70
|
-
while not locked and time.monotonic() < deadline:
|
|
71
|
-
time.sleep(0.5)
|
|
72
|
-
locked = conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
|
|
73
|
-
if not locked:
|
|
74
|
-
raise SqlpushError(
|
|
75
|
-
f"another sqlpush worker holds the advisory lock after {advisory_wait}s"
|
|
76
|
-
)
|
|
77
|
-
conn.commit()
|
|
78
|
-
try:
|
|
79
|
-
conn.execute(text(_VERSIONS_DDL))
|
|
80
|
-
conn.commit()
|
|
81
|
-
yield conn
|
|
82
|
-
finally:
|
|
83
|
-
# best-effort unlock (executor pattern): a secondary failure here
|
|
84
|
-
# must never mask the primary outcome; the session lock dies with
|
|
85
|
-
# the connection even if this fails
|
|
86
|
-
if not conn.closed:
|
|
87
|
-
with contextlib.suppress(Exception):
|
|
88
|
-
conn.rollback()
|
|
89
|
-
conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
|
|
90
|
-
conn.commit()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def run_migrate(
|
|
94
|
-
engine: Engine,
|
|
95
|
-
*,
|
|
96
|
-
chain_dir: str | Path,
|
|
97
|
-
allow_destructive: bool,
|
|
98
|
-
advisory_wait: float = 30.0,
|
|
99
|
-
lock_timeout: float = 5.0,
|
|
100
|
-
) -> MigrateReport:
|
|
101
|
-
if lock_timeout < 0:
|
|
102
|
-
# same contract as push (executor.with_advisory_lock): budgets
|
|
103
|
-
# are typed floats, never user input, and a negative one must
|
|
104
|
-
# fail before any file or connection work
|
|
105
|
-
raise SqlpushError(f"lock_timeout must be >= 0, got {lock_timeout}")
|
|
106
|
-
applied: list[str] = []
|
|
107
|
-
skipped: list[str] = []
|
|
108
|
-
blocked: list[str] = []
|
|
109
|
-
notes: list[str] = []
|
|
110
|
-
partial = False
|
|
111
|
-
chain = _chain_files(chain_dir)
|
|
112
|
-
with _chain_session(engine, advisory_wait=advisory_wait) as conn:
|
|
113
|
-
recorded = {
|
|
114
|
-
row[0]: row[1]
|
|
115
|
-
for row in conn.execute(text("SELECT name, sha256 FROM public.sqlpush_versions"))
|
|
116
|
-
}
|
|
117
|
-
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):
|
|
128
|
-
blocked.append(f.name)
|
|
129
|
-
notes.append(f"{f.name}: checksum mismatch (edited after apply?)")
|
|
130
|
-
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
|
|
161
|
-
return MigrateReport(
|
|
162
|
-
applied=tuple(applied),
|
|
163
|
-
skipped=tuple(skipped),
|
|
164
|
-
blocked=tuple(blocked),
|
|
165
|
-
partial_failure=partial,
|
|
166
|
-
notes=tuple(notes),
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def run_stamp(engine: Engine, *, chain_dir: str | Path, force: bool = False) -> MigrateReport:
|
|
171
|
-
"""Register every parseable chain file WITHOUT executing any SQL.
|
|
172
|
-
|
|
173
|
-
Bootstrap seam (spec §5 R7): adopt a DB whose schema already reflects
|
|
174
|
-
the chain. Only the header must parse (fail-loud on THAT) — invalid SQL
|
|
175
|
-
in a body still registers, because stamp never executes anything. A
|
|
176
|
-
file already recorded with a DIFFERENT checksum is refused — it was
|
|
177
|
-
edited after apply/stamp, and silently refreshing would wipe the
|
|
178
|
-
edit-detection integrity — unless ``force`` is set; the first mismatch
|
|
179
|
-
raises and nothing after it registers (strict order, same as migrate).
|
|
180
|
-
Unrecorded files, unchanged re-stamps and forced re-stamps upsert
|
|
181
|
-
idempotently (``ON CONFLICT (name) DO UPDATE``).
|
|
182
|
-
|
|
183
|
-
Report convention: registered files are listed in ``skipped`` (stamp
|
|
184
|
-
never populates ``applied`` and never sets ``partial_failure``); a
|
|
185
|
-
header that fails to parse goes to ``blocked`` + ``notes`` and stops
|
|
186
|
-
the walk (strict order, same as migrate).
|
|
187
|
-
"""
|
|
188
|
-
skipped: list[str] = []
|
|
189
|
-
blocked: list[str] = []
|
|
190
|
-
notes: list[str] = []
|
|
191
|
-
chain = _chain_files(chain_dir)
|
|
192
|
-
with _chain_session(engine) as conn:
|
|
193
|
-
recorded = {
|
|
194
|
-
row[0]: row[1]
|
|
195
|
-
for row in conn.execute(text("SELECT name, sha256 FROM public.sqlpush_versions"))
|
|
196
|
-
}
|
|
197
|
-
conn.commit()
|
|
198
|
-
for f in chain:
|
|
199
|
-
raw = f.read_text()
|
|
200
|
-
try:
|
|
201
|
-
parse_migration_file(raw, name=f.name)
|
|
202
|
-
except MigrationFileError as exc:
|
|
203
|
-
blocked.append(f.name)
|
|
204
|
-
notes.append(f"{f.name}: {exc}")
|
|
205
|
-
break # orden estricto: nada posterior se registra
|
|
206
|
-
if f.name in recorded and recorded[f.name] != checksum(raw) and not force:
|
|
207
|
-
raise SqlpushError(
|
|
208
|
-
f"{f.name}: checksum mismatch (file edited after apply?); "
|
|
209
|
-
"pass force=True/--force to accept the new content"
|
|
210
|
-
)
|
|
211
|
-
with conn.begin():
|
|
212
|
-
conn.execute(
|
|
213
|
-
text(
|
|
214
|
-
"INSERT INTO public.sqlpush_versions (name, sha256) VALUES (:n, :s) "
|
|
215
|
-
"ON CONFLICT (name) DO UPDATE SET sha256 = EXCLUDED.sha256"
|
|
216
|
-
),
|
|
217
|
-
{"n": f.name, "s": checksum(raw)},
|
|
218
|
-
)
|
|
219
|
-
skipped.append(f.name)
|
|
220
|
-
return MigrateReport(
|
|
221
|
-
applied=(),
|
|
222
|
-
skipped=tuple(skipped),
|
|
223
|
-
blocked=tuple(blocked),
|
|
224
|
-
partial_failure=False,
|
|
225
|
-
notes=tuple(notes),
|
|
226
|
-
)
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# src/sqlpush/core/classify.py
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
from sqlpush.types import RiskClass
|
|
5
|
-
|
|
6
|
-
SAFE = frozenset({"add_table", "add_column", "add_constraint", "create_hypertable"})
|
|
7
|
-
DESTRUCTIVE = frozenset({"drop_column", "drop_table", "drop_index", "drop_constraint"})
|
|
8
|
-
|
|
9
|
-
|
|
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."""
|
|
18
|
-
if op_type in DESTRUCTIVE:
|
|
19
|
-
return RiskClass.DESTRUCTIVE
|
|
20
|
-
if op_type in SAFE:
|
|
21
|
-
return RiskClass.SAFE
|
|
22
|
-
# modify_*, add_index, raw_sql, and anything unknown
|
|
23
|
-
return RiskClass.RISKY
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|