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
vecshift/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from vecshift import __version__
|
|
13
|
+
from vecshift.cli_style import STYLE as _STYLE
|
|
14
|
+
from vecshift.cli_style import warn_if_password_on_command_line
|
|
15
|
+
from vecshift.cli_style import wrap as _wrap
|
|
16
|
+
from vecshift.core.fingerprint import EmbeddingFingerprint
|
|
17
|
+
from vecshift.doctor import Report, Severity, run_checks
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="vecshift",
|
|
21
|
+
# Tracebacks must never print local variables: they can hold connection strings and keys.
|
|
22
|
+
pretty_exceptions_show_locals=False,
|
|
23
|
+
help="Safe, observable embedding migrations for any vector store.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _print_version(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
typer.echo(f"vecshift {__version__}")
|
|
32
|
+
raise typer.Exit()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback()
|
|
36
|
+
def main(
|
|
37
|
+
version: Annotated[
|
|
38
|
+
bool,
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--version",
|
|
41
|
+
callback=_print_version,
|
|
42
|
+
is_eager=True,
|
|
43
|
+
help="Show the version and exit.",
|
|
44
|
+
),
|
|
45
|
+
] = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Safe, observable embedding migrations for any vector store."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def fingerprint(
|
|
52
|
+
provider: Annotated[str, typer.Option(help="Embedding provider, e.g. openai.")],
|
|
53
|
+
model: Annotated[str, typer.Option(help="Model name, e.g. text-embedding-3-small.")],
|
|
54
|
+
dimensions: Annotated[int, typer.Option(help="Output dimensions.")],
|
|
55
|
+
version: Annotated[str | None, typer.Option(help="Model version, if pinned.")] = None,
|
|
56
|
+
task: Annotated[str | None, typer.Option(help="Task type, e.g. retrieval_document.")] = None,
|
|
57
|
+
prefix: Annotated[str | None, typer.Option(help="Text prefix, e.g. 'passage: '.")] = None,
|
|
58
|
+
normalized: Annotated[
|
|
59
|
+
bool,
|
|
60
|
+
typer.Option("--normalized/--not-normalized", help="Whether vectors are L2-normalized."),
|
|
61
|
+
] = True,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Print the model tag that identifies an embedding configuration's vector space."""
|
|
64
|
+
try:
|
|
65
|
+
fp = EmbeddingFingerprint(
|
|
66
|
+
provider=provider,
|
|
67
|
+
model=model,
|
|
68
|
+
dimensions=dimensions,
|
|
69
|
+
version=version,
|
|
70
|
+
task=task,
|
|
71
|
+
prefix=prefix,
|
|
72
|
+
normalized=normalized,
|
|
73
|
+
)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
76
|
+
typer.echo(fp.model_tag)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FailOn(StrEnum):
|
|
80
|
+
NEVER = "never"
|
|
81
|
+
WARNING = "warning"
|
|
82
|
+
ERROR = "error"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _render(report: Report, connection: str) -> None:
|
|
86
|
+
dims = f"({report.declared_dimensions})" if report.declared_dimensions else ""
|
|
87
|
+
rows = f"~{report.estimated_rows:,}" if report.estimated_rows is not None else "unknown"
|
|
88
|
+
typer.secho(f"vecshift doctor · {report.store} via {connection}", bold=True)
|
|
89
|
+
typer.echo(f"Target {report.target} {report.vector_type}{dims}")
|
|
90
|
+
typer.echo(f"Rows {rows} (inspected {report.sample_rows:,}, {report.sample_method})")
|
|
91
|
+
typer.echo()
|
|
92
|
+
for finding in report.sorted_findings():
|
|
93
|
+
label, color, _ = _STYLE[finding.severity]
|
|
94
|
+
typer.secho(f"{label} ", fg=color, bold=True, nl=False)
|
|
95
|
+
typer.secho(finding.title, bold=True)
|
|
96
|
+
typer.echo(_wrap(finding.detail))
|
|
97
|
+
if finding.hint:
|
|
98
|
+
typer.secho(_wrap(f"→ {finding.hint}"), dim=True)
|
|
99
|
+
typer.echo()
|
|
100
|
+
counts = [(report.count(sev), name) for sev, (_, _, name) in _STYLE.items()]
|
|
101
|
+
summary = ", ".join(
|
|
102
|
+
f"{n} {name}{'s' if n != 1 and name in ('error', 'warning') else ''}" for n, name in counts
|
|
103
|
+
)
|
|
104
|
+
typer.echo(f"Summary: {summary}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def doctor(
|
|
109
|
+
ctx: typer.Context,
|
|
110
|
+
dsn: Annotated[
|
|
111
|
+
str,
|
|
112
|
+
typer.Option(
|
|
113
|
+
envvar=["VECSHIFT_DSN", "DATABASE_URL"],
|
|
114
|
+
help="PostgreSQL connection string. Prefer the VECSHIFT_DSN environment variable "
|
|
115
|
+
"so the password stays out of your shell history.",
|
|
116
|
+
show_default=False,
|
|
117
|
+
),
|
|
118
|
+
],
|
|
119
|
+
table: Annotated[
|
|
120
|
+
str | None, typer.Option(help="Table to inspect, as table or schema.table.")
|
|
121
|
+
] = None,
|
|
122
|
+
column: Annotated[
|
|
123
|
+
str | None, typer.Option(help="Vector column, if there's more than one.")
|
|
124
|
+
] = None,
|
|
125
|
+
text_column: Annotated[
|
|
126
|
+
str | None, typer.Option(help="Column holding the source text, if not detected.")
|
|
127
|
+
] = None,
|
|
128
|
+
sample_size: Annotated[
|
|
129
|
+
int, typer.Option(min=1, max=100_000, help="Maximum rows to inspect.")
|
|
130
|
+
] = 2000,
|
|
131
|
+
timeout: Annotated[int, typer.Option(min=1, help="Statement timeout in seconds.")] = 60,
|
|
132
|
+
output_json: Annotated[bool, typer.Option("--json", help="Print the report as JSON.")] = False,
|
|
133
|
+
html: Annotated[
|
|
134
|
+
Path | None,
|
|
135
|
+
typer.Option(
|
|
136
|
+
"--html",
|
|
137
|
+
dir_okay=False,
|
|
138
|
+
help="Also write a self-contained HTML report to this file.",
|
|
139
|
+
show_default=False,
|
|
140
|
+
),
|
|
141
|
+
] = None,
|
|
142
|
+
fail_on: Annotated[
|
|
143
|
+
FailOn, typer.Option(help="Exit with status 1 if any finding is this severe or worse.")
|
|
144
|
+
] = FailOn.NEVER,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Inspect a pgvector index and report problems. Read-only."""
|
|
147
|
+
warn_if_password_on_command_line(ctx, dsn)
|
|
148
|
+
try:
|
|
149
|
+
from vecshift.connectors import pgvector
|
|
150
|
+
except ImportError as exc: # pragma: no cover - a broken install
|
|
151
|
+
typer.secho(
|
|
152
|
+
"The PostgreSQL driver is missing. Reinstall: pip install --force-reinstall vecshift",
|
|
153
|
+
err=True,
|
|
154
|
+
fg=typer.colors.RED,
|
|
155
|
+
)
|
|
156
|
+
raise typer.Exit(2) from exc
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
settings = pgvector.prepare(dsn)
|
|
160
|
+
conn = pgvector.connect(settings, statement_timeout_s=timeout)
|
|
161
|
+
except pgvector.ConnectError as exc:
|
|
162
|
+
typer.secho(f"Couldn't connect: {exc}", err=True, fg=typer.colors.RED)
|
|
163
|
+
if exc.hint:
|
|
164
|
+
typer.echo(f"→ {exc.hint}", err=True)
|
|
165
|
+
raise typer.Exit(2) from exc
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
profile = pgvector.inspect(
|
|
169
|
+
conn, table=table, column=column, text_column=text_column, sample_size=sample_size
|
|
170
|
+
)
|
|
171
|
+
except pgvector.TargetSelectionError as exc:
|
|
172
|
+
typer.secho(str(exc), err=True, fg=typer.colors.RED)
|
|
173
|
+
if exc.candidates:
|
|
174
|
+
typer.echo("Vector columns found:", err=True)
|
|
175
|
+
for c in exc.candidates:
|
|
176
|
+
dims = f"({c.dimensions})" if c.dimensions else ""
|
|
177
|
+
typer.echo(f" {c.qualified} {c.type}{dims}", err=True)
|
|
178
|
+
raise typer.Exit(2) from exc
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
import psycopg
|
|
181
|
+
|
|
182
|
+
if isinstance(exc, psycopg.Error):
|
|
183
|
+
typer.secho(f"Inspection failed: {exc}", err=True, fg=typer.colors.RED)
|
|
184
|
+
raise typer.Exit(2) from exc
|
|
185
|
+
raise
|
|
186
|
+
finally:
|
|
187
|
+
conn.rollback()
|
|
188
|
+
conn.close()
|
|
189
|
+
|
|
190
|
+
report = run_checks(profile)
|
|
191
|
+
if html is not None:
|
|
192
|
+
from vecshift.doctor.html import render_html
|
|
193
|
+
|
|
194
|
+
page = render_html(
|
|
195
|
+
report,
|
|
196
|
+
connection=settings.display,
|
|
197
|
+
connection_kind=settings.description,
|
|
198
|
+
version=__version__,
|
|
199
|
+
)
|
|
200
|
+
try:
|
|
201
|
+
html.write_text(page, encoding="utf-8")
|
|
202
|
+
except OSError as exc:
|
|
203
|
+
typer.secho(f"Couldn't write {html}: {exc.strerror}", err=True, fg=typer.colors.RED)
|
|
204
|
+
raise typer.Exit(2) from exc
|
|
205
|
+
|
|
206
|
+
if output_json:
|
|
207
|
+
typer.echo(json.dumps({"connection": settings.display, **report.to_dict()}, indent=2))
|
|
208
|
+
else:
|
|
209
|
+
_render(report, settings.description)
|
|
210
|
+
if html is not None:
|
|
211
|
+
typer.echo(f"HTML report written to {html}", err=output_json)
|
|
212
|
+
|
|
213
|
+
if fail_on is not FailOn.NEVER and report.worst.rank >= Severity(fail_on.value).rank:
|
|
214
|
+
raise typer.Exit(1)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
from vecshift.cli_bench import bench # noqa: E402
|
|
218
|
+
|
|
219
|
+
app.command()(bench)
|
|
220
|
+
|
|
221
|
+
from vecshift.cli_plan import init, plan # noqa: E402
|
|
222
|
+
|
|
223
|
+
app.command()(init)
|
|
224
|
+
app.command()(plan)
|
|
225
|
+
|
|
226
|
+
from vecshift.cli_apply import apply # noqa: E402
|
|
227
|
+
from vecshift.cli_cutover import cleanup, cutover, rollback # noqa: E402
|
|
228
|
+
|
|
229
|
+
app.command()(apply)
|
|
230
|
+
app.command()(cutover)
|
|
231
|
+
app.command()(rollback)
|
|
232
|
+
app.command()(cleanup)
|
|
233
|
+
|
|
234
|
+
from vecshift.cli_eval import eval_ # noqa: E402
|
|
235
|
+
|
|
236
|
+
app.command(name="eval")(eval_)
|
vecshift/cli_apply.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""The ``vecshift apply`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import FrameType
|
|
12
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from vecshift.cli_plan import DEFAULT_JOB, _fail, _money, _size, prepare
|
|
17
|
+
from vecshift.cli_style import finding_lines
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from vecshift.migrate import ApplyResult, Event
|
|
21
|
+
|
|
22
|
+
EXIT_STOPPED = 3
|
|
23
|
+
"""Stopped on purpose (Ctrl-C or budget): run apply again to continue."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Stop:
|
|
27
|
+
"""First Ctrl-C finishes the current batch and stops cleanly; a second one aborts."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.requested = False
|
|
31
|
+
|
|
32
|
+
def __call__(self, signum: int, frame: FrameType | None) -> None:
|
|
33
|
+
if self.requested:
|
|
34
|
+
raise KeyboardInterrupt
|
|
35
|
+
self.requested = True
|
|
36
|
+
typer.secho(
|
|
37
|
+
"\nStopping after the current batch. Press Ctrl-C again to abort now.",
|
|
38
|
+
err=True,
|
|
39
|
+
fg=typer.colors.YELLOW,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Progress:
|
|
44
|
+
def __init__(self, live: bool) -> None:
|
|
45
|
+
self.live = live
|
|
46
|
+
self.started = time.monotonic()
|
|
47
|
+
self.pending = 0
|
|
48
|
+
self.last_print = 0.0
|
|
49
|
+
|
|
50
|
+
def __call__(self, event: Event) -> None:
|
|
51
|
+
d = event.data
|
|
52
|
+
if event.kind == "start":
|
|
53
|
+
self.pending = d["pending"]
|
|
54
|
+
typer.echo(f"{self.pending:,} rows need a new vector.")
|
|
55
|
+
elif event.kind == "column" and d["added"]:
|
|
56
|
+
typer.echo("✔ Added the new column.")
|
|
57
|
+
elif event.kind == "trigger" and d["added"]:
|
|
58
|
+
typer.echo("✔ Added the sync trigger.")
|
|
59
|
+
elif event.kind == "batch":
|
|
60
|
+
self._batch(d)
|
|
61
|
+
elif event.kind == "pass" and d["number"] > 1 and self.live:
|
|
62
|
+
typer.echo()
|
|
63
|
+
elif event.kind == "index":
|
|
64
|
+
if self.live:
|
|
65
|
+
typer.echo()
|
|
66
|
+
if d["state"] == "building":
|
|
67
|
+
typer.echo(f"Building the {d['method']} index concurrently. Writes continue…")
|
|
68
|
+
else:
|
|
69
|
+
typer.echo(f"✔ Index {d['state']}.")
|
|
70
|
+
|
|
71
|
+
def _batch(self, d: dict[str, Any]) -> None:
|
|
72
|
+
done = d["rows_written"]
|
|
73
|
+
elapsed = max(time.monotonic() - self.started, 1e-6)
|
|
74
|
+
rate = done / elapsed
|
|
75
|
+
left = max(self.pending - done, 0)
|
|
76
|
+
eta = f"~{_eta(left / rate)} left" if rate > 0 and left else ""
|
|
77
|
+
cost = f" · {_money(d['spent_usd'])}" if d["spent_usd"] is not None else ""
|
|
78
|
+
line = f" {done:,} / {self.pending:,} rows · {rate:,.0f} rows/s{cost} {eta}"
|
|
79
|
+
if self.live:
|
|
80
|
+
typer.echo("\r" + line.ljust(78), nl=False)
|
|
81
|
+
elif time.monotonic() - self.last_print > 15 or not left:
|
|
82
|
+
typer.echo(line)
|
|
83
|
+
self.last_print = time.monotonic()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _eta(seconds: float) -> str:
|
|
87
|
+
if seconds < 90:
|
|
88
|
+
return f"{max(1, round(seconds))} s"
|
|
89
|
+
if seconds < 5400:
|
|
90
|
+
return f"{round(seconds / 60)} min"
|
|
91
|
+
return f"{seconds / 3600:.1f} h"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _summary(result: ApplyResult) -> None:
|
|
95
|
+
color = {"complete": typer.colors.GREEN, "failed": typer.colors.RED}.get(
|
|
96
|
+
result.status, typer.colors.YELLOW
|
|
97
|
+
)
|
|
98
|
+
typer.echo()
|
|
99
|
+
typer.secho(f"Apply {result.status}.", fg=color, bold=True)
|
|
100
|
+
if result.message:
|
|
101
|
+
typer.echo(result.message)
|
|
102
|
+
spent = _money(result.spent_usd) if result.spent_usd is not None else "unknown"
|
|
103
|
+
total = (
|
|
104
|
+
f" ({_money(result.total_spent_usd)} across runs)"
|
|
105
|
+
if result.total_spent_usd is not None
|
|
106
|
+
else ""
|
|
107
|
+
)
|
|
108
|
+
typer.echo(
|
|
109
|
+
f" Rows written {result.rows_written:,} · tokens {result.tokens:,} · spent {spent}"
|
|
110
|
+
f"{total} · {result.seconds:,.0f} s"
|
|
111
|
+
)
|
|
112
|
+
if result.rows_failed:
|
|
113
|
+
typer.echo(f" Rejected by the provider: {result.rows_failed:,} rows (retried next run)")
|
|
114
|
+
if result.status == "complete":
|
|
115
|
+
typer.echo(
|
|
116
|
+
"\nNext: check it's safe to switch with `vecshift cutover --check`, then run "
|
|
117
|
+
"`vecshift cutover`.\nIt embeds any rows added or edited since this run first."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def apply(
|
|
122
|
+
job_file: Annotated[Path, typer.Argument(help="The job file.")] = DEFAULT_JOB,
|
|
123
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't ask before starting.")] = False,
|
|
124
|
+
no_index: Annotated[
|
|
125
|
+
bool, typer.Option("--no-index", help="Backfill only; build the index on a later run.")
|
|
126
|
+
] = False,
|
|
127
|
+
until: Annotated[
|
|
128
|
+
int | None,
|
|
129
|
+
typer.Option(
|
|
130
|
+
"--until",
|
|
131
|
+
min=1,
|
|
132
|
+
max=99,
|
|
133
|
+
metavar="PERCENT",
|
|
134
|
+
help="Stop once this share of rows has a new vector, to check before continuing.",
|
|
135
|
+
),
|
|
136
|
+
] = None,
|
|
137
|
+
output_json: Annotated[
|
|
138
|
+
bool, typer.Option("--json", help="Print progress as JSON lines, for scripts and UIs.")
|
|
139
|
+
] = False,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Run (or resume) a migration: add the column, embed every row, and build the index."""
|
|
142
|
+
import psycopg
|
|
143
|
+
|
|
144
|
+
from vecshift.connectors import pgvector
|
|
145
|
+
from vecshift.connectors.pgvector.writer import AlreadyRunning, Busy, Layout, PgWriter
|
|
146
|
+
from vecshift.embeddings import EmbeddingError, create_embedder
|
|
147
|
+
from vecshift.embeddings.providers import CONCURRENCY
|
|
148
|
+
from vecshift.migrate import JobState
|
|
149
|
+
|
|
150
|
+
ready = prepare(job_file)
|
|
151
|
+
plan, job, spec = ready.plan, ready.job, ready.spec
|
|
152
|
+
if not plan.ok:
|
|
153
|
+
typer.secho("The plan has errors, so nothing was changed.", fg=typer.colors.RED, bold=True)
|
|
154
|
+
finding_lines([f for f in plan.findings if f.severity.rank >= 2])
|
|
155
|
+
raise typer.Exit(1)
|
|
156
|
+
if plan.dimensions is None:
|
|
157
|
+
raise _fail(
|
|
158
|
+
"The new vector size is unknown.",
|
|
159
|
+
"Add dims= to the model spec, or check it with: vecshift plan --probe",
|
|
160
|
+
)
|
|
161
|
+
if job.limits.budget_usd is not None and spec.price is None:
|
|
162
|
+
raise _fail(
|
|
163
|
+
"A budget is set, but the model has no price, so it can't be enforced.",
|
|
164
|
+
"Add price= (USD per million tokens) to the model spec.",
|
|
165
|
+
)
|
|
166
|
+
if len(ready.target.primary_key) != 1 or ready.profile.text_field is None:
|
|
167
|
+
raise _fail("Apply needs a single-column primary key and a text column.")
|
|
168
|
+
|
|
169
|
+
state = JobState.for_job(job_file, job.name)
|
|
170
|
+
est = plan.estimates
|
|
171
|
+
warnings = [f for f in plan.findings if f.severity.rank == 2]
|
|
172
|
+
if not output_json:
|
|
173
|
+
typer.secho(f"vecshift apply · {job.name}", bold=True)
|
|
174
|
+
for change in plan.changes:
|
|
175
|
+
if change.kind != "cutover":
|
|
176
|
+
typer.echo(f" · {change.summary}")
|
|
177
|
+
cost = _money(est.cost_usd)
|
|
178
|
+
budget = f", budget ${job.limits.budget_usd:,.2f}" if job.limits.budget_usd else ""
|
|
179
|
+
typer.echo(f" Estimated cost {cost}{budget}; new column {_size(est.new_bytes)}.")
|
|
180
|
+
if state.runs:
|
|
181
|
+
typer.echo(
|
|
182
|
+
f" Resuming: {state.rows_written:,} rows written in earlier runs, "
|
|
183
|
+
f"{_money(state.spent_usd)} spent."
|
|
184
|
+
)
|
|
185
|
+
if warnings:
|
|
186
|
+
finding_lines(warnings)
|
|
187
|
+
if not yes:
|
|
188
|
+
where = "your machine" if spec.is_local else spec.url
|
|
189
|
+
typer.echo(
|
|
190
|
+
f"\nThis changes {plan.source.split()[0]} and sends row text to {where}.",
|
|
191
|
+
err=True,
|
|
192
|
+
)
|
|
193
|
+
if not sys.stdin.isatty():
|
|
194
|
+
raise _fail("Not changing the database without confirmation.", "Pass --yes to proceed.")
|
|
195
|
+
if not typer.confirm("Apply?", err=True):
|
|
196
|
+
raise typer.Exit(1)
|
|
197
|
+
|
|
198
|
+
layout = Layout(
|
|
199
|
+
schema=ready.target.source.schema,
|
|
200
|
+
table=ready.target.source.table,
|
|
201
|
+
pk=ready.target.primary_key[0],
|
|
202
|
+
text=ready.profile.text_field,
|
|
203
|
+
target=job.target.column,
|
|
204
|
+
vector_type=job.target.vector_type.value,
|
|
205
|
+
dims=plan.dimensions,
|
|
206
|
+
extension_schema=ready.target.extension_schema,
|
|
207
|
+
)
|
|
208
|
+
index = None
|
|
209
|
+
if not no_index and job.target.index.value != "none":
|
|
210
|
+
index = (job.target.index.value, plan.metric)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
conn = pgvector.connect_writer(ready.settings)
|
|
214
|
+
except pgvector.ConnectError as exc:
|
|
215
|
+
raise _fail(f"Couldn't connect: {exc}", exc.hint) from exc
|
|
216
|
+
|
|
217
|
+
stop = _Stop()
|
|
218
|
+
previous = signal.signal(signal.SIGINT, stop)
|
|
219
|
+
if output_json:
|
|
220
|
+
|
|
221
|
+
def on_event(event: Event) -> None:
|
|
222
|
+
typer.echo(json.dumps({"event": event.kind, **event.data}))
|
|
223
|
+
|
|
224
|
+
else:
|
|
225
|
+
on_event = _Progress(live=sys.stdout.isatty())
|
|
226
|
+
try:
|
|
227
|
+
embedder = create_embedder(spec)
|
|
228
|
+
result = asyncio.run(
|
|
229
|
+
_run(
|
|
230
|
+
PgWriter(conn, layout),
|
|
231
|
+
embedder,
|
|
232
|
+
state,
|
|
233
|
+
dims=plan.dimensions,
|
|
234
|
+
price_per_million=spec.price,
|
|
235
|
+
budget_usd=job.limits.budget_usd,
|
|
236
|
+
chunk_rows=spec.batch_size * CONCURRENCY,
|
|
237
|
+
index=index,
|
|
238
|
+
on_event=on_event,
|
|
239
|
+
should_stop=lambda: stop.requested,
|
|
240
|
+
until=until / 100 if until else None,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
except AlreadyRunning as exc:
|
|
244
|
+
raise _fail(str(exc), "Wait for it to finish, or stop it first.") from exc
|
|
245
|
+
except (Busy, EmbeddingError) as exc:
|
|
246
|
+
raise _fail(f"{exc}", "Progress is saved; run apply again to continue.") from exc
|
|
247
|
+
except psycopg.Error as exc:
|
|
248
|
+
detail = str(exc).strip().splitlines()[0] if str(exc).strip() else type(exc).__name__
|
|
249
|
+
hint = "Every finished batch is saved; run apply again to continue."
|
|
250
|
+
if "shared memory segment" in detail:
|
|
251
|
+
hint = (
|
|
252
|
+
"The parallel index build ran out of shared memory, sized by "
|
|
253
|
+
"maintenance_work_mem. In Docker, /dev/shm is 64 MB unless the container "
|
|
254
|
+
"runs with --shm-size (e.g. 2g); or lower maintenance_work_mem. " + hint
|
|
255
|
+
)
|
|
256
|
+
raise _fail(f"The database reported an error: {detail}", hint) from exc
|
|
257
|
+
finally:
|
|
258
|
+
signal.signal(signal.SIGINT, previous)
|
|
259
|
+
conn.close()
|
|
260
|
+
|
|
261
|
+
if output_json:
|
|
262
|
+
typer.echo(json.dumps({"event": "result", **_result_dict(result)}))
|
|
263
|
+
else:
|
|
264
|
+
_summary(result)
|
|
265
|
+
if result.status == "failed":
|
|
266
|
+
raise typer.Exit(1)
|
|
267
|
+
if result.status in {"stopped", "budget"}:
|
|
268
|
+
raise typer.Exit(EXIT_STOPPED)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def _run(writer: Any, embedder: Any, state: Any, **kwargs: Any) -> ApplyResult:
|
|
272
|
+
from vecshift.migrate import apply as run_apply
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
return await run_apply(writer, embedder, state, **kwargs)
|
|
276
|
+
finally:
|
|
277
|
+
await embedder.aclose()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _result_dict(result: ApplyResult) -> dict[str, Any]:
|
|
281
|
+
from dataclasses import asdict
|
|
282
|
+
|
|
283
|
+
return asdict(result)
|