vecshift 0.1.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.
- vecshift/__init__.py +15 -0
- vecshift/assets/bench.css +39 -0
- vecshift/assets/eval.css +91 -0
- vecshift/assets/report.css +252 -0
- vecshift/assets/report.js +50 -0
- vecshift/bench/__init__.py +18 -0
- vecshift/bench/corpus.py +175 -0
- vecshift/bench/generate.py +106 -0
- vecshift/bench/html.py +325 -0
- vecshift/bench/metrics.py +50 -0
- vecshift/bench/runner.py +183 -0
- vecshift/cli.py +236 -0
- vecshift/cli_apply.py +283 -0
- vecshift/cli_bench.py +354 -0
- vecshift/cli_cutover.py +431 -0
- vecshift/cli_eval.py +591 -0
- vecshift/cli_plan.py +335 -0
- vecshift/cli_style.py +57 -0
- vecshift/connectors/__init__.py +1 -0
- vecshift/connectors/pgvector/__init__.py +29 -0
- vecshift/connectors/pgvector/connection.py +155 -0
- vecshift/connectors/pgvector/documents.py +96 -0
- vecshift/connectors/pgvector/inspect.py +427 -0
- vecshift/connectors/pgvector/search.py +240 -0
- vecshift/connectors/pgvector/switch.py +481 -0
- vecshift/connectors/pgvector/target.py +195 -0
- vecshift/connectors/pgvector/writer.py +431 -0
- vecshift/core/__init__.py +4 -0
- vecshift/core/capabilities.py +33 -0
- vecshift/core/contracts.py +57 -0
- vecshift/core/fingerprint.py +57 -0
- vecshift/core/record.py +75 -0
- vecshift/doctor/__init__.py +15 -0
- vecshift/doctor/checks.py +490 -0
- vecshift/doctor/findings.py +78 -0
- vecshift/doctor/html.py +493 -0
- vecshift/doctor/profile.py +69 -0
- vecshift/embeddings/__init__.py +22 -0
- vecshift/embeddings/cache.py +86 -0
- vecshift/embeddings/providers.py +244 -0
- vecshift/embeddings/spec.py +240 -0
- vecshift/eval/__init__.py +20 -0
- vecshift/eval/html.py +444 -0
- vecshift/eval/metrics.py +81 -0
- vecshift/eval/queries.py +97 -0
- vecshift/eval/runner.py +394 -0
- vecshift/html_kit.py +143 -0
- vecshift/jobs/__init__.py +5 -0
- vecshift/jobs/spec.py +202 -0
- vecshift/migrate/__init__.py +6 -0
- vecshift/migrate/engine.py +272 -0
- vecshift/migrate/state.py +50 -0
- vecshift/planning/__init__.py +14 -0
- vecshift/planning/plan.py +87 -0
- vecshift/planning/planner.py +493 -0
- vecshift/py.typed +0 -0
- vecshift-0.1.0.dist-info/METADATA +264 -0
- vecshift-0.1.0.dist-info/RECORD +62 -0
- vecshift-0.1.0.dist-info/WHEEL +4 -0
- vecshift-0.1.0.dist-info/entry_points.txt +2 -0
- vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
- vecshift-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Cutover and rollback: give the new vectors the column name the application searches.
|
|
2
|
+
|
|
3
|
+
Cutover renames the live column to ``<name>_old`` and the new column to ``<name>`` in one
|
|
4
|
+
short transaction, so every query switches at once and the application's SQL doesn't
|
|
5
|
+
change. Rollback renames them back. The sync trigger always guards whichever column isn't
|
|
6
|
+
live: before cutover it clears new vectors when text changes, and after cutover it clears
|
|
7
|
+
the old ones, so rollback can say exactly which rows lack an old-model vector.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from psycopg import errors, sql
|
|
17
|
+
|
|
18
|
+
from vecshift.connectors.pgvector.target import table_limits
|
|
19
|
+
from vecshift.connectors.pgvector.writer import SCAN_TIMEOUT_MS, Busy, PgWriter, Row, _limits
|
|
20
|
+
from vecshift.doctor.findings import Finding, Severity
|
|
21
|
+
|
|
22
|
+
SWITCH_SCAN_MS = 15_000
|
|
23
|
+
MAX_FILL = 500
|
|
24
|
+
"""Most rows cutover embeds while writes wait; more than this means catch up first."""
|
|
25
|
+
"""How long the final check may hold off writes before cutover gives up and retries."""
|
|
26
|
+
METHODS = ("hnsw", "ivfflat")
|
|
27
|
+
|
|
28
|
+
Stage = Literal["not_applied", "applied", "cut_over", "conflict"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StillPending(Exception):
|
|
32
|
+
def __init__(self, rows: int) -> None:
|
|
33
|
+
super().__init__(f"{rows} rows still need a new vector")
|
|
34
|
+
self.rows = rows
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Readiness:
|
|
39
|
+
stage: Stage
|
|
40
|
+
findings: list[Finding]
|
|
41
|
+
pending: int
|
|
42
|
+
live_dims: int | None
|
|
43
|
+
new_dims: int | None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ok(self) -> bool:
|
|
47
|
+
return not any(f.severity is Severity.ERROR for f in self.findings)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PgSwitch:
|
|
51
|
+
def __init__(self, writer: PgWriter) -> None:
|
|
52
|
+
if not writer.layout.live:
|
|
53
|
+
raise ValueError("cutover needs the live column's name")
|
|
54
|
+
self.writer = writer
|
|
55
|
+
self.conn = writer.conn
|
|
56
|
+
self.layout = writer.layout
|
|
57
|
+
|
|
58
|
+
# --- what's there
|
|
59
|
+
|
|
60
|
+
def _attribute(self, column: str) -> tuple[int, int | None] | None:
|
|
61
|
+
"""(attnum, dimensions) of a column, or None when it doesn't exist."""
|
|
62
|
+
row = self.conn.execute(
|
|
63
|
+
"""
|
|
64
|
+
SELECT attnum, atttypmod FROM pg_attribute
|
|
65
|
+
WHERE attrelid = %s::regclass AND attname = %s AND attnum > 0 AND NOT attisdropped
|
|
66
|
+
""",
|
|
67
|
+
(self.writer._regclass(), column),
|
|
68
|
+
).fetchone()
|
|
69
|
+
if row is None:
|
|
70
|
+
return None
|
|
71
|
+
return int(row[0]), (int(row[1]) if row[1] > 0 else None)
|
|
72
|
+
|
|
73
|
+
def stage(self) -> Stage:
|
|
74
|
+
lay = self.layout
|
|
75
|
+
target = self._attribute(lay.target) is not None
|
|
76
|
+
previous = self._attribute(lay.previous) is not None
|
|
77
|
+
if target and previous:
|
|
78
|
+
return "conflict"
|
|
79
|
+
if target:
|
|
80
|
+
return "applied"
|
|
81
|
+
if previous:
|
|
82
|
+
return "cut_over"
|
|
83
|
+
return "not_applied"
|
|
84
|
+
|
|
85
|
+
def dependents(self, column: str) -> list[str]:
|
|
86
|
+
"""Views and SQL-standard functions bound to a column, which a rename won't move."""
|
|
87
|
+
attribute = self._attribute(column)
|
|
88
|
+
if attribute is None:
|
|
89
|
+
return []
|
|
90
|
+
rows = self.conn.execute(
|
|
91
|
+
"""
|
|
92
|
+
SELECT DISTINCT CASE
|
|
93
|
+
WHEN d.classid = 'pg_rewrite'::regclass THEN 'view ' || r.ev_class::regclass::text
|
|
94
|
+
ELSE 'function ' || d.objid::regprocedure::text END
|
|
95
|
+
FROM pg_depend d
|
|
96
|
+
LEFT JOIN pg_rewrite r ON d.classid = 'pg_rewrite'::regclass AND r.oid = d.objid
|
|
97
|
+
WHERE d.refclassid = 'pg_class'::regclass AND d.refobjid = %s::regclass
|
|
98
|
+
AND d.refobjsubid = %s
|
|
99
|
+
AND d.classid IN ('pg_rewrite'::regclass, 'pg_proc'::regclass)
|
|
100
|
+
AND (r.ev_class IS NULL OR r.ev_class <> d.refobjid)
|
|
101
|
+
ORDER BY 1
|
|
102
|
+
""",
|
|
103
|
+
(self.writer._regclass(), attribute[0]),
|
|
104
|
+
).fetchall()
|
|
105
|
+
return [str(r[0]) for r in rows]
|
|
106
|
+
|
|
107
|
+
def _count(self, condition: sql.Composable) -> int:
|
|
108
|
+
query = sql.SQL("SELECT count(*) FROM {table} WHERE {condition}").format(
|
|
109
|
+
table=self.layout.qualified, condition=condition
|
|
110
|
+
)
|
|
111
|
+
with self.conn.transaction():
|
|
112
|
+
_limits(self.conn, SCAN_TIMEOUT_MS)
|
|
113
|
+
row = self.conn.execute(query).fetchone()
|
|
114
|
+
return int(row[0]) if row else 0
|
|
115
|
+
|
|
116
|
+
def _no_text(self) -> sql.Composable:
|
|
117
|
+
return sql.SQL("({text} IS NULL OR btrim({text}::text) = '')").format(
|
|
118
|
+
text=sql.Identifier(self.layout.text)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _index_names(self, column: str) -> list[str]:
|
|
122
|
+
rows = self.conn.execute(
|
|
123
|
+
"""
|
|
124
|
+
SELECT c.relname FROM pg_index i
|
|
125
|
+
JOIN pg_class c ON c.oid = i.indexrelid
|
|
126
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
|
|
127
|
+
WHERE i.indrelid = %s::regclass AND a.attname = %s
|
|
128
|
+
""",
|
|
129
|
+
(self.writer._regclass(), column),
|
|
130
|
+
).fetchall()
|
|
131
|
+
return [str(r[0]) for r in rows]
|
|
132
|
+
|
|
133
|
+
def _name_taken(self, name: str) -> bool:
|
|
134
|
+
row = self.conn.execute(
|
|
135
|
+
"""
|
|
136
|
+
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
137
|
+
WHERE n.nspname = %s AND c.relname = %s
|
|
138
|
+
""",
|
|
139
|
+
(self.layout.schema, name),
|
|
140
|
+
).fetchone()
|
|
141
|
+
return row is not None
|
|
142
|
+
|
|
143
|
+
def _index_renames(self, moves: list[tuple[str, str, str]]) -> list[sql.Composed]:
|
|
144
|
+
"""Index renames that follow column renames, so names keep matching columns.
|
|
145
|
+
|
|
146
|
+
Each move is (column as named now, old name, new name). Only indexes named the
|
|
147
|
+
way vecshift names them are renamed, and only into names that are free.
|
|
148
|
+
"""
|
|
149
|
+
lay = self.layout
|
|
150
|
+
statements = []
|
|
151
|
+
freed: set[str] = set()
|
|
152
|
+
claimed: set[str] = set()
|
|
153
|
+
for column, old, new in moves:
|
|
154
|
+
names = self._index_names(column)
|
|
155
|
+
for method in METHODS:
|
|
156
|
+
current, wanted = lay.index_name(method, old), lay.index_name(method, new)
|
|
157
|
+
free = wanted in freed or not self._name_taken(wanted)
|
|
158
|
+
if current in names and wanted not in claimed and free:
|
|
159
|
+
statements.append(
|
|
160
|
+
sql.SQL("ALTER INDEX {} RENAME TO {}").format(
|
|
161
|
+
sql.Identifier(lay.schema, current), sql.Identifier(wanted)
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
freed.add(current)
|
|
165
|
+
claimed.add(wanted)
|
|
166
|
+
return statements
|
|
167
|
+
|
|
168
|
+
def _rename(self, old: str, new: str) -> sql.Composed:
|
|
169
|
+
return sql.SQL("ALTER TABLE {table} RENAME COLUMN {old} TO {new}").format(
|
|
170
|
+
table=self.layout.qualified, old=sql.Identifier(old), new=sql.Identifier(new)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# --- checks
|
|
174
|
+
|
|
175
|
+
def check(self, index_method: str | None) -> Readiness:
|
|
176
|
+
lay = self.layout
|
|
177
|
+
findings: list[Finding] = []
|
|
178
|
+
stage = self.stage()
|
|
179
|
+
live = self._attribute(lay.live)
|
|
180
|
+
new = self._attribute(lay.target)
|
|
181
|
+
live_dims = live[1] if live else None
|
|
182
|
+
new_dims = new[1] if new else None
|
|
183
|
+
pending = 0
|
|
184
|
+
|
|
185
|
+
if stage == "not_applied":
|
|
186
|
+
findings.append(
|
|
187
|
+
Finding(
|
|
188
|
+
"cutover.not_applied",
|
|
189
|
+
Severity.ERROR,
|
|
190
|
+
"Nothing to cut over yet",
|
|
191
|
+
f"Column {lay.target} doesn't exist.",
|
|
192
|
+
hint="Run vecshift apply first.",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
elif stage == "cut_over":
|
|
196
|
+
findings.append(
|
|
197
|
+
Finding(
|
|
198
|
+
"cutover.done",
|
|
199
|
+
Severity.ERROR,
|
|
200
|
+
"Already cut over",
|
|
201
|
+
f"{lay.live} holds the new vectors and {lay.previous} the old ones.",
|
|
202
|
+
hint="Run vecshift rollback to undo it.",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
elif stage == "conflict":
|
|
206
|
+
findings.append(
|
|
207
|
+
Finding(
|
|
208
|
+
"cutover.previous_exists",
|
|
209
|
+
Severity.ERROR,
|
|
210
|
+
f"Column {lay.previous} already exists",
|
|
211
|
+
"Cutover keeps the old vectors under that name, so it has to be free.",
|
|
212
|
+
hint="Drop it if it's left over from an earlier migration, or rename it.",
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
if live is None:
|
|
216
|
+
findings.append(
|
|
217
|
+
Finding(
|
|
218
|
+
"cutover.no_live_column",
|
|
219
|
+
Severity.ERROR,
|
|
220
|
+
f"Column {lay.live} doesn't exist",
|
|
221
|
+
"That's the column your application searches.",
|
|
222
|
+
hint="Set source.vector_column in the job file.",
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
if stage != "applied" or live is None:
|
|
226
|
+
return Readiness(stage, findings, pending, live_dims, new_dims)
|
|
227
|
+
|
|
228
|
+
relid = self.conn.execute("SELECT %s::regclass::oid", (self.writer._regclass(),)).fetchone()
|
|
229
|
+
partitioned, row_security = (
|
|
230
|
+
table_limits(self.conn, int(relid[0])) if relid else (False, False)
|
|
231
|
+
)
|
|
232
|
+
if partitioned:
|
|
233
|
+
findings.append(
|
|
234
|
+
Finding(
|
|
235
|
+
"cutover.partitioned",
|
|
236
|
+
Severity.ERROR,
|
|
237
|
+
"Partitioned tables aren't supported yet",
|
|
238
|
+
"Cutover's final check needs a concurrent index, which PostgreSQL can't "
|
|
239
|
+
"build on a partitioned table.",
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
if row_security:
|
|
243
|
+
findings.append(
|
|
244
|
+
Finding(
|
|
245
|
+
"cutover.row_security",
|
|
246
|
+
Severity.ERROR,
|
|
247
|
+
"Row-level security hides rows from this role",
|
|
248
|
+
"Cutover couldn't confirm that every row has a new vector.",
|
|
249
|
+
hint="Connect as a role with BYPASSRLS, or turn off FORCE ROW LEVEL "
|
|
250
|
+
"SECURITY for the migration.",
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
pending = self.writer.pending_count()
|
|
255
|
+
if pending:
|
|
256
|
+
findings.append(
|
|
257
|
+
Finding(
|
|
258
|
+
"cutover.pending",
|
|
259
|
+
Severity.ERROR,
|
|
260
|
+
f"{pending:,} rows have no new vector yet",
|
|
261
|
+
"After cutover they wouldn't be found by searches.",
|
|
262
|
+
hint="Cutover embeds them first; or run vecshift apply.",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
if index_method and self.writer.index_state(index_method) != "valid":
|
|
266
|
+
findings.append(
|
|
267
|
+
Finding(
|
|
268
|
+
"cutover.no_index",
|
|
269
|
+
Severity.ERROR,
|
|
270
|
+
f"The {index_method} index on {lay.target} isn't built",
|
|
271
|
+
"Without it, searches would scan the whole table after cutover.",
|
|
272
|
+
hint="Run vecshift apply to build it.",
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
sizes = self.writer.dimensions_in_use()
|
|
276
|
+
if sizes and sizes != {lay.dims}:
|
|
277
|
+
findings.append(
|
|
278
|
+
Finding(
|
|
279
|
+
"cutover.mixed_sizes",
|
|
280
|
+
Severity.ERROR,
|
|
281
|
+
"The new column holds vectors of different sizes",
|
|
282
|
+
f"Found sizes {sorted(sizes)}; expected {lay.dims}.",
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
bound = self.dependents(lay.live) + self.dependents(lay.target)
|
|
286
|
+
if bound:
|
|
287
|
+
findings.append(
|
|
288
|
+
Finding(
|
|
289
|
+
"cutover.dependents",
|
|
290
|
+
Severity.ERROR,
|
|
291
|
+
"Views or functions are bound to the vector columns",
|
|
292
|
+
"PostgreSQL ties these to a column, not its name, so after a rename they "
|
|
293
|
+
f"would still read the old vectors: {', '.join(bound)}.",
|
|
294
|
+
hint="Drop them before cutover and recreate them after it.",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
orphans = self._count(
|
|
298
|
+
sql.SQL("{live} IS NOT NULL AND {no_text}").format(
|
|
299
|
+
live=sql.Identifier(lay.live), no_text=self._no_text()
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
if orphans:
|
|
303
|
+
findings.append(
|
|
304
|
+
Finding(
|
|
305
|
+
"cutover.no_text",
|
|
306
|
+
Severity.WARNING,
|
|
307
|
+
f"{orphans:,} rows have a vector but no text",
|
|
308
|
+
"They can't be re-embedded, so they'll have no vector after cutover.",
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
if live_dims and new_dims and live_dims != new_dims:
|
|
312
|
+
findings.append(
|
|
313
|
+
Finding(
|
|
314
|
+
"cutover.size_change",
|
|
315
|
+
Severity.WARNING,
|
|
316
|
+
f"Vectors change size, from {live_dims} to {new_dims}",
|
|
317
|
+
"Searches and inserts that still use the old model will fail after cutover "
|
|
318
|
+
"until your application switches to the new one.",
|
|
319
|
+
hint="Switch the model in your application's settings at cutover time.",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
findings.append(
|
|
324
|
+
Finding(
|
|
325
|
+
"cutover.model_switch",
|
|
326
|
+
Severity.INFO,
|
|
327
|
+
"Switch your application's model at cutover",
|
|
328
|
+
"Queries embedded with the old model would still run, but match poorly, "
|
|
329
|
+
"until your application embeds them with the new one.",
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
return Readiness(stage, findings, pending, live_dims, new_dims)
|
|
333
|
+
|
|
334
|
+
# --- the switch
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def _pending_index(self) -> str:
|
|
338
|
+
return f"{self.layout.table}_{self.layout.target}_pending_idx"[:63]
|
|
339
|
+
|
|
340
|
+
def prepare(self) -> None:
|
|
341
|
+
"""Index the rows still missing a vector, so the final check under lock is instant."""
|
|
342
|
+
lay = self.layout
|
|
343
|
+
self.finish()
|
|
344
|
+
self.conn.execute("SET statement_timeout = 0")
|
|
345
|
+
try:
|
|
346
|
+
self.conn.execute(
|
|
347
|
+
sql.SQL(
|
|
348
|
+
"CREATE INDEX CONCURRENTLY {name} ON {table} ({pk}) WHERE {target} IS NULL"
|
|
349
|
+
).format(
|
|
350
|
+
name=sql.Identifier(self._pending_index),
|
|
351
|
+
table=lay.qualified,
|
|
352
|
+
pk=sql.Identifier(lay.pk),
|
|
353
|
+
target=sql.Identifier(lay.target),
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
finally:
|
|
357
|
+
self.conn.execute("RESET statement_timeout")
|
|
358
|
+
|
|
359
|
+
def finish(self) -> None:
|
|
360
|
+
self.conn.execute(
|
|
361
|
+
sql.SQL("DROP INDEX CONCURRENTLY IF EXISTS {}").format(
|
|
362
|
+
sql.Identifier(self.layout.schema, self._pending_index)
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def _transaction(
|
|
367
|
+
self, run: Callable[[], None], what: str, sleep: Callable[[float], None] | None = None
|
|
368
|
+
) -> None:
|
|
369
|
+
writer = self.writer
|
|
370
|
+
pause = sleep or writer._sleep
|
|
371
|
+
delay = 1.0
|
|
372
|
+
for attempt in range(1, writer.lock_retries + 1):
|
|
373
|
+
try:
|
|
374
|
+
with self.conn.transaction():
|
|
375
|
+
_limits(self.conn, SWITCH_SCAN_MS, writer.lock_timeout_ms)
|
|
376
|
+
run()
|
|
377
|
+
return
|
|
378
|
+
except errors.LockNotAvailable as exc:
|
|
379
|
+
if attempt == writer.lock_retries:
|
|
380
|
+
raise Busy(
|
|
381
|
+
f"Couldn't {what}: the table stayed busy. Try again when it's quieter."
|
|
382
|
+
) from exc
|
|
383
|
+
pause(delay)
|
|
384
|
+
delay = min(delay * 2, 30)
|
|
385
|
+
|
|
386
|
+
def cutover(
|
|
387
|
+
self,
|
|
388
|
+
allow_missing: int = 0,
|
|
389
|
+
fill: Callable[[list[Row]], list[tuple[Row, list[float]]]] | None = None,
|
|
390
|
+
exclude: Sequence[str] = (),
|
|
391
|
+
) -> int:
|
|
392
|
+
"""Switch in one transaction, after a final check that every row has a new vector.
|
|
393
|
+
|
|
394
|
+
Writes wait while the check runs (it uses the index from :meth:`prepare`); reads
|
|
395
|
+
continue until the renames, which take milliseconds. On a busy table a few rows
|
|
396
|
+
always arrive after the last catch-up: up to ``MAX_FILL`` of them are embedded with
|
|
397
|
+
``fill`` while writes wait, so cutover doesn't chase them forever. ``exclude`` lists
|
|
398
|
+
rows the provider rejected, which aren't sent again. Raises :class:`StillPending`
|
|
399
|
+
if more rows are missing, leaving everything unchanged. Returns rows filled.
|
|
400
|
+
"""
|
|
401
|
+
lay = self.layout
|
|
402
|
+
writer = self.writer
|
|
403
|
+
filled = 0
|
|
404
|
+
|
|
405
|
+
def missing_count() -> int:
|
|
406
|
+
row = self.conn.execute(
|
|
407
|
+
sql.SQL("SELECT count(*) FROM {table} WHERE {pending}").format(
|
|
408
|
+
table=lay.qualified, pending=writer._pending_filter()
|
|
409
|
+
)
|
|
410
|
+
).fetchone()
|
|
411
|
+
return int(row[0]) if row else 0
|
|
412
|
+
|
|
413
|
+
def run() -> None:
|
|
414
|
+
nonlocal filled
|
|
415
|
+
filled = 0
|
|
416
|
+
self.conn.execute(
|
|
417
|
+
sql.SQL("LOCK TABLE {} IN SHARE ROW EXCLUSIVE MODE").format(lay.qualified)
|
|
418
|
+
)
|
|
419
|
+
missing = missing_count()
|
|
420
|
+
if missing > allow_missing and fill is not None:
|
|
421
|
+
rows = writer.fetch(None, MAX_FILL + 1, exclude)
|
|
422
|
+
if 0 < len(rows) <= MAX_FILL:
|
|
423
|
+
filled = writer.write(fill(rows))
|
|
424
|
+
missing = missing_count()
|
|
425
|
+
if missing > allow_missing:
|
|
426
|
+
raise StillPending(missing)
|
|
427
|
+
statements = [
|
|
428
|
+
*writer.drop_sync_sql(lay.target),
|
|
429
|
+
*self._index_renames(
|
|
430
|
+
[(lay.live, lay.live, lay.previous), (lay.target, lay.target, lay.live)]
|
|
431
|
+
),
|
|
432
|
+
self._rename(lay.live, lay.previous),
|
|
433
|
+
self._rename(lay.target, lay.live),
|
|
434
|
+
writer.sync_function_sql(lay.previous),
|
|
435
|
+
writer.sync_trigger_sql(lay.previous),
|
|
436
|
+
]
|
|
437
|
+
for statement in statements:
|
|
438
|
+
self.conn.execute(statement)
|
|
439
|
+
|
|
440
|
+
self._transaction(run, "cut over")
|
|
441
|
+
return filled
|
|
442
|
+
|
|
443
|
+
def rollback(self) -> int:
|
|
444
|
+
"""Rename the columns back. Returns rows that have no old-model vector."""
|
|
445
|
+
lay = self.layout
|
|
446
|
+
writer = self.writer
|
|
447
|
+
|
|
448
|
+
def run() -> None:
|
|
449
|
+
statements = [
|
|
450
|
+
*writer.drop_sync_sql(lay.previous),
|
|
451
|
+
*self._index_renames(
|
|
452
|
+
[(lay.live, lay.live, lay.target), (lay.previous, lay.previous, lay.live)]
|
|
453
|
+
),
|
|
454
|
+
self._rename(lay.live, lay.target),
|
|
455
|
+
self._rename(lay.previous, lay.live),
|
|
456
|
+
writer.sync_function_sql(lay.target),
|
|
457
|
+
writer.sync_trigger_sql(lay.target),
|
|
458
|
+
]
|
|
459
|
+
for statement in statements:
|
|
460
|
+
self.conn.execute(statement)
|
|
461
|
+
|
|
462
|
+
self._transaction(run, "roll back")
|
|
463
|
+
return self._count(
|
|
464
|
+
sql.SQL("{live} IS NULL AND NOT {no_text}").format(
|
|
465
|
+
live=sql.Identifier(lay.live), no_text=self._no_text()
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
def cleanup(self) -> None:
|
|
470
|
+
"""Drop the old vectors, their index, and the trigger guarding them. Irreversible."""
|
|
471
|
+
lay = self.layout
|
|
472
|
+
writer = self.writer
|
|
473
|
+
drop = sql.SQL("ALTER TABLE {table} DROP COLUMN {col}").format(
|
|
474
|
+
table=lay.qualified, col=sql.Identifier(lay.previous)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def run() -> None:
|
|
478
|
+
for statement in [*writer.drop_sync_sql(lay.previous), drop]:
|
|
479
|
+
self.conn.execute(statement)
|
|
480
|
+
|
|
481
|
+
self._transaction(run, "drop the old column")
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""What the target of a side-by-side migration looks like, and the SQL to create it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
|
|
10
|
+
from vecshift.connectors.pgvector.inspect import (
|
|
11
|
+
VectorColumn,
|
|
12
|
+
_extension_schema,
|
|
13
|
+
find_vector_columns,
|
|
14
|
+
select_column,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
OPCLASS_SUFFIX = {"cosine": "cosine_ops", "inner_product": "ip_ops", "l2": "l2_ops"}
|
|
18
|
+
|
|
19
|
+
_SIMPLE = re.compile(r"^[a-z_][a-z0-9_$]*$")
|
|
20
|
+
|
|
21
|
+
# PostgreSQL's reserved and type/function-name keywords, used when the server's own list
|
|
22
|
+
# isn't available. They must be quoted to be used as names.
|
|
23
|
+
_KEYWORDS = (
|
|
24
|
+
"all analyse analyze and any array as asc asymmetric authorization binary both case "
|
|
25
|
+
"cast check collate collation column concurrently constraint create cross "
|
|
26
|
+
"current_catalog current_date current_role current_schema current_time "
|
|
27
|
+
"current_timestamp current_user default deferrable desc distinct do else end except "
|
|
28
|
+
"false fetch for foreign freeze from full grant group having ilike in initially inner "
|
|
29
|
+
"intersect into is isnull join lateral leading left like limit localtime "
|
|
30
|
+
"localtimestamp natural not notnull null offset on only or order outer overlaps "
|
|
31
|
+
"placing primary references returning right select session_user similar some "
|
|
32
|
+
"symmetric system_user table tablesample then to trailing true union unique user "
|
|
33
|
+
"using variadic verbose when where window with"
|
|
34
|
+
)
|
|
35
|
+
FALLBACK_KEYWORDS = frozenset(_KEYWORDS.split())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def quote_ident(name: str, keywords: frozenset[str] = FALLBACK_KEYWORDS) -> str:
|
|
39
|
+
"""Quote an identifier exactly when PostgreSQL needs it, like its quote_ident()."""
|
|
40
|
+
if _SIMPLE.match(name) and name not in keywords:
|
|
41
|
+
return name
|
|
42
|
+
return '"' + name.replace('"', '""') + '"'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class TargetState:
|
|
47
|
+
source: VectorColumn
|
|
48
|
+
extension_schema: str
|
|
49
|
+
owner: bool
|
|
50
|
+
"""Whether the connecting role owns the table, which adding a column requires."""
|
|
51
|
+
column_exists: bool
|
|
52
|
+
column_type: str | None
|
|
53
|
+
column_dimensions: int | None
|
|
54
|
+
maintenance_work_mem: int
|
|
55
|
+
"""Bytes available to an index build before it slows down."""
|
|
56
|
+
keywords: frozenset[str] = FALLBACK_KEYWORDS
|
|
57
|
+
"""Words this server needs quoted when used as names."""
|
|
58
|
+
primary_key: tuple[str, ...] = ()
|
|
59
|
+
"""Primary key columns, in order. Apply needs exactly one."""
|
|
60
|
+
text_column: str | None = None
|
|
61
|
+
previous_column_exists: bool = False
|
|
62
|
+
"""Whether ``<column>_old`` exists: a cutover happened and hasn't been cleaned up."""
|
|
63
|
+
partitioned: bool = False
|
|
64
|
+
"""A partitioned table, which apply can't index concurrently yet."""
|
|
65
|
+
row_security: bool = False
|
|
66
|
+
"""Row-level security applies to the connecting role, so it can't see or write every row."""
|
|
67
|
+
|
|
68
|
+
def q(self, name: str) -> str:
|
|
69
|
+
return quote_ident(name, self.keywords)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def table_limits(conn: psycopg.Connection, relid: int) -> tuple[bool, bool]:
|
|
73
|
+
"""(partitioned, row security applies to the current role).
|
|
74
|
+
|
|
75
|
+
Superusers and BYPASSRLS roles always see every row; the table's owner (or a role
|
|
76
|
+
inheriting its privileges) does too, unless the table forces row-level security.
|
|
77
|
+
"""
|
|
78
|
+
row = conn.execute(
|
|
79
|
+
"""
|
|
80
|
+
SELECT c.relkind = 'p',
|
|
81
|
+
c.relrowsecurity AND NOT (r.rolbypassrls OR r.rolsuper)
|
|
82
|
+
AND (c.relforcerowsecurity OR NOT pg_has_role(current_user, c.relowner, 'USAGE'))
|
|
83
|
+
FROM pg_class c, pg_roles r
|
|
84
|
+
WHERE c.oid = %s AND r.rolname = current_user
|
|
85
|
+
""",
|
|
86
|
+
(relid,),
|
|
87
|
+
).fetchone()
|
|
88
|
+
return (bool(row[0]), bool(row[1])) if row else (False, False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def inspect_target(
|
|
92
|
+
conn: psycopg.Connection, table: str, vector_column: str | None, target_column: str
|
|
93
|
+
) -> TargetState:
|
|
94
|
+
source = select_column(find_vector_columns(conn), table, vector_column)
|
|
95
|
+
ext = _extension_schema(conn)
|
|
96
|
+
owner_row = conn.execute(
|
|
97
|
+
"SELECT pg_has_role(current_user, relowner, 'MEMBER') FROM pg_class WHERE oid = %s",
|
|
98
|
+
(source.relid,),
|
|
99
|
+
).fetchone()
|
|
100
|
+
col = conn.execute(
|
|
101
|
+
"""
|
|
102
|
+
SELECT t.typname, a.atttypmod FROM pg_attribute a
|
|
103
|
+
JOIN pg_type t ON t.oid = a.atttypid
|
|
104
|
+
WHERE a.attrelid = %s AND a.attname = %s AND a.attnum > 0 AND NOT a.attisdropped
|
|
105
|
+
""",
|
|
106
|
+
(source.relid, target_column),
|
|
107
|
+
).fetchone()
|
|
108
|
+
mem = conn.execute(
|
|
109
|
+
"SELECT setting::bigint, unit FROM pg_settings WHERE name = 'maintenance_work_mem'"
|
|
110
|
+
).fetchone()
|
|
111
|
+
words = conn.execute("SELECT word FROM pg_get_keywords() WHERE catcode <> 'U'").fetchall()
|
|
112
|
+
pk_rows = conn.execute(
|
|
113
|
+
"""
|
|
114
|
+
SELECT a.attname FROM pg_index i
|
|
115
|
+
CROSS JOIN LATERAL unnest(i.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)
|
|
116
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
|
|
117
|
+
WHERE i.indrelid = %s AND i.indisprimary
|
|
118
|
+
ORDER BY k.ord
|
|
119
|
+
""",
|
|
120
|
+
(source.relid,),
|
|
121
|
+
).fetchall()
|
|
122
|
+
partitioned, row_security = table_limits(conn, source.relid)
|
|
123
|
+
units = {"kB": 1024, "MB": 1024**2, "8kB": 8192, "B": 1}
|
|
124
|
+
mem_bytes = int(mem[0]) * units.get(mem[1] or "kB", 1024) if mem else 64 * 1024**2
|
|
125
|
+
return TargetState(
|
|
126
|
+
source=source,
|
|
127
|
+
extension_schema=ext,
|
|
128
|
+
owner=bool(owner_row and owner_row[0]),
|
|
129
|
+
column_exists=col is not None,
|
|
130
|
+
column_type=str(col[0]) if col else None,
|
|
131
|
+
column_dimensions=int(col[1]) if col and col[1] > 0 else None,
|
|
132
|
+
maintenance_work_mem=mem_bytes,
|
|
133
|
+
keywords=frozenset(w[0] for w in words) or FALLBACK_KEYWORDS,
|
|
134
|
+
primary_key=tuple(str(r[0]) for r in pk_rows),
|
|
135
|
+
partitioned=partitioned,
|
|
136
|
+
row_security=row_security,
|
|
137
|
+
previous_column_exists=conn.execute(
|
|
138
|
+
"""
|
|
139
|
+
SELECT 1 FROM pg_attribute
|
|
140
|
+
WHERE attrelid = %s AND attname = %s AND attnum > 0 AND NOT attisdropped
|
|
141
|
+
""",
|
|
142
|
+
(source.relid, f"{source.column}_old"[:63]),
|
|
143
|
+
).fetchone()
|
|
144
|
+
is not None,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_source_column(
|
|
149
|
+
conn: psycopg.Connection, table: str, vector_column: str | None, target_column: str
|
|
150
|
+
) -> str:
|
|
151
|
+
"""The column the application searches.
|
|
152
|
+
|
|
153
|
+
It ignores the target column a previous run may have added, and the ``<name>_old``
|
|
154
|
+
column a cutover keeps for rollback.
|
|
155
|
+
"""
|
|
156
|
+
if vector_column:
|
|
157
|
+
return vector_column
|
|
158
|
+
columns = find_vector_columns(conn)
|
|
159
|
+
schema, _, name = table.rpartition(".")
|
|
160
|
+
table_columns = [c for c in columns if c.table == name and (not schema or c.schema == schema)]
|
|
161
|
+
names = {c.column for c in table_columns}
|
|
162
|
+
in_table = [
|
|
163
|
+
c
|
|
164
|
+
for c in table_columns
|
|
165
|
+
if c.column != target_column
|
|
166
|
+
and not (c.column.endswith("_old") and c.column.removesuffix("_old") in names)
|
|
167
|
+
]
|
|
168
|
+
if len(in_table) == 1:
|
|
169
|
+
return in_table[0].column
|
|
170
|
+
return select_column(columns, table, None).column # raises a helpful error
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def add_column_sql(state: TargetState, column: str, vector_type: str, dims: int) -> str:
|
|
174
|
+
if vector_type not in {"vector", "halfvec"}:
|
|
175
|
+
raise ValueError(f"unsupported vector type {vector_type!r}")
|
|
176
|
+
table = f"{state.q(state.source.schema)}.{state.q(state.source.table)}"
|
|
177
|
+
kind = f"{state.q(state.extension_schema)}.{state.q(vector_type)}({int(dims)})"
|
|
178
|
+
return f"ALTER TABLE {table} ADD COLUMN {state.q(column)} {kind};"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def index_sql(
|
|
182
|
+
state: TargetState, column: str, vector_type: str, method: str, metric: str, rows: int
|
|
183
|
+
) -> str:
|
|
184
|
+
if method not in {"hnsw", "ivfflat"} or vector_type not in {"vector", "halfvec"}:
|
|
185
|
+
raise ValueError(f"unsupported index {method!r} on {vector_type!r}")
|
|
186
|
+
table = f"{state.q(state.source.schema)}.{state.q(state.source.table)}"
|
|
187
|
+
name = state.q(f"{state.source.table}_{column}_{method}_idx"[:63])
|
|
188
|
+
ops = state.q(f"{vector_type}_{OPCLASS_SUFFIX[metric]}")
|
|
189
|
+
target = f"{state.q(column)} {state.q(state.extension_schema)}.{ops}"
|
|
190
|
+
using = f"CREATE INDEX CONCURRENTLY {name} ON {table} USING {method} ({target})"
|
|
191
|
+
if method == "ivfflat":
|
|
192
|
+
# pgvector's guidance: rows / 1000 lists up to a million rows, sqrt(rows) beyond.
|
|
193
|
+
lists = max(1, rows // 1000 if rows <= 1_000_000 else int(rows**0.5))
|
|
194
|
+
using += f" WITH (lists = {lists})"
|
|
195
|
+
return using + ";"
|