sqlpush 0.4.1__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.1 → sqlpush-0.5.0}/PKG-INFO +13 -9
- {sqlpush-0.4.1 → sqlpush-0.5.0}/README.md +12 -8
- {sqlpush-0.4.1 → sqlpush-0.5.0}/pyproject.toml +1 -1
- {sqlpush-0.4.1 → sqlpush-0.5.0}/pyproject.toml.orig +1 -1
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/api.py +87 -21
- {sqlpush-0.4.1 → 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.1 → sqlpush-0.5.0}/src/sqlpush/cli.py +21 -3
- sqlpush-0.5.0/src/sqlpush/core/classify.py +28 -0
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/core/diff.py +57 -1
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/types.py +6 -0
- sqlpush-0.4.1/src/sqlpush/chain/migrate.py +0 -175
- sqlpush-0.4.1/src/sqlpush/core/classify.py +0 -23
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/__init__.py +0 -0
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/annotations.py +0 -0
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/apply/__init__.py +0 -0
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/chain/__init__.py +0 -0
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/chain/format.py +0 -0
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/core/__init__.py +0 -0
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/core/render.py +0 -0
- {sqlpush-0.4.1 → sqlpush-0.5.0}/src/sqlpush/directives/__init__.py +0 -0
- {sqlpush-0.4.1 → 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,20 +137,34 @@ 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])
|
|
140
160
|
ops = [(f"[{op.risk.name}] {op.type} {op.table or '?'}", op.sql) for op in p.operations]
|
|
141
161
|
out = Path(out_dir)
|
|
142
|
-
|
|
162
|
+
try:
|
|
163
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
# same typing as the write below: only SqlpushError subclasses
|
|
166
|
+
# escape the API surface (e.g. out_dir under a file)
|
|
167
|
+
raise SqlpushError(f"cannot create migrations directory {out}: {exc}") from exc
|
|
143
168
|
rev_id = next_revision_id(out)
|
|
144
169
|
slug = re.sub(r"[^a-z0-9_]+", "_", (message or "migration").lower())[:40]
|
|
145
170
|
path = out / f"{rev_id}_{slug}.sql"
|
|
@@ -163,16 +188,37 @@ def revision(
|
|
|
163
188
|
return path
|
|
164
189
|
|
|
165
190
|
|
|
166
|
-
def migrate(
|
|
191
|
+
def migrate(
|
|
192
|
+
target,
|
|
193
|
+
*,
|
|
194
|
+
chain_dir="migrations/versions",
|
|
195
|
+
allow_destructive=False,
|
|
196
|
+
advisory_wait=30.0,
|
|
197
|
+
lock_timeout=5.0,
|
|
198
|
+
statement_timeout=None,
|
|
199
|
+
) -> MigrateReport:
|
|
167
200
|
"""Replay annotated-SQL chain files with gates + same-txn bookkeeping.
|
|
168
201
|
|
|
169
202
|
``target`` is a DSN string, sync ``Engine`` or ``AsyncEngine`` (resolved
|
|
170
|
-
via ``_sync_engine_from``; engines created here are disposed).
|
|
203
|
+
via ``_sync_engine_from``; engines created here are disposed).
|
|
204
|
+
``advisory_wait`` bounds the advisory-lock wait and ``lock_timeout``
|
|
205
|
+
bounds each per-file transaction's lock wait (seconds; 0 = fail
|
|
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
|
|
171
210
|
``chain.migrate.run_migrate`` for the execution contract.
|
|
172
211
|
"""
|
|
173
212
|
engine, dispose = _sync_engine_from(target)
|
|
174
213
|
try:
|
|
175
|
-
return run_migrate(
|
|
214
|
+
return run_migrate(
|
|
215
|
+
engine,
|
|
216
|
+
chain_dir=chain_dir,
|
|
217
|
+
allow_destructive=allow_destructive,
|
|
218
|
+
advisory_wait=advisory_wait,
|
|
219
|
+
lock_timeout=lock_timeout,
|
|
220
|
+
statement_timeout=statement_timeout,
|
|
221
|
+
)
|
|
176
222
|
except SQLAlchemyError as exc:
|
|
177
223
|
# MigrationFileError/SqlpushError (typed) pass through untouched
|
|
178
224
|
_raise_typed(exc)
|
|
@@ -181,16 +227,19 @@ def migrate(target, *, chain_dir="migrations/versions", allow_destructive=False)
|
|
|
181
227
|
engine.dispose()
|
|
182
228
|
|
|
183
229
|
|
|
184
|
-
def stamp(target, *, chain_dir="migrations/versions") -> MigrateReport:
|
|
230
|
+
def stamp(target, *, chain_dir="migrations/versions", force: bool = False) -> MigrateReport:
|
|
185
231
|
"""Bootstrap: register chain files as applied WITHOUT executing SQL.
|
|
186
232
|
|
|
187
|
-
For adopting a DB whose schema already reflects the chain.
|
|
188
|
-
|
|
189
|
-
``
|
|
233
|
+
For adopting a DB whose schema already reflects the chain. A file
|
|
234
|
+
already registered with a different checksum (edited after apply)
|
|
235
|
+
is refused unless ``force`` is set — edit detection survives
|
|
236
|
+
re-stamping. ``target`` resolution and error typing match
|
|
237
|
+
:func:`migrate`. See ``chain.migrate.run_stamp`` for the report
|
|
238
|
+
convention.
|
|
190
239
|
"""
|
|
191
240
|
engine, dispose = _sync_engine_from(target)
|
|
192
241
|
try:
|
|
193
|
-
return run_stamp(engine, chain_dir=chain_dir)
|
|
242
|
+
return run_stamp(engine, chain_dir=chain_dir, force=force)
|
|
194
243
|
except SQLAlchemyError as exc:
|
|
195
244
|
# MigrationFileError/SqlpushError (typed) pass through untouched
|
|
196
245
|
_raise_typed(exc)
|
|
@@ -199,18 +248,35 @@ def stamp(target, *, chain_dir="migrations/versions") -> MigrateReport:
|
|
|
199
248
|
engine.dispose()
|
|
200
249
|
|
|
201
250
|
|
|
251
|
+
def _translate_asyncpg(url):
|
|
252
|
+
"""Map a ``postgresql+asyncpg`` URL onto the sync psycopg driver.
|
|
253
|
+
|
|
254
|
+
An asyncpg URL cannot back a SYNC engine (the dialect is
|
|
255
|
+
async-only), but psycopg is a runtime dependency — the translated
|
|
256
|
+
engine actually connects. asyncpg itself is never required in this
|
|
257
|
+
process. Everything but the driver (host/db/credentials/query
|
|
258
|
+
options) is preserved via the URL API, no string surgery.
|
|
259
|
+
"""
|
|
260
|
+
if url.drivername == "postgresql+asyncpg":
|
|
261
|
+
return url.set(drivername="postgresql+psycopg")
|
|
262
|
+
return url
|
|
263
|
+
|
|
264
|
+
|
|
202
265
|
def _sync_engine_from(target):
|
|
203
266
|
if isinstance(target, AsyncEngine):
|
|
204
267
|
from sqlalchemy import create_engine
|
|
205
268
|
from sqlalchemy.pool import NullPool
|
|
206
269
|
|
|
207
|
-
dsn = target.url
|
|
208
|
-
return create_engine(dsn, poolclass=NullPool), True
|
|
270
|
+
dsn = _translate_asyncpg(target.url)
|
|
271
|
+
return create_engine(dsn.render_as_string(hide_password=False), poolclass=NullPool), True
|
|
209
272
|
if isinstance(target, str):
|
|
210
273
|
from sqlalchemy import create_engine
|
|
274
|
+
from sqlalchemy.engine import make_url
|
|
211
275
|
from sqlalchemy.pool import NullPool
|
|
212
276
|
|
|
213
|
-
|
|
277
|
+
# make_url raises the same ArgumentError a bad DSN would raise
|
|
278
|
+
# inside create_engine — error typing downstream is unchanged
|
|
279
|
+
return create_engine(_translate_asyncpg(make_url(target)), poolclass=NullPool), True
|
|
214
280
|
return target, False
|
|
215
281
|
|
|
216
282
|
|
|
@@ -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
|