claude-sql 0.6.0__tar.gz → 0.7.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.
- {claude_sql-0.6.0 → claude_sql-0.7.0}/PKG-INFO +3 -2
- {claude_sql-0.6.0 → claude_sql-0.7.0}/pyproject.toml +4 -2
- claude_sql-0.7.0/src/claude_sql/checkpointer.py +376 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/cli.py +170 -63
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/cluster_worker.py +28 -21
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/community_worker.py +24 -19
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/config.py +23 -23
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/embed_worker.py +45 -42
- claude_sql-0.7.0/src/claude_sql/lance_store.py +261 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/retry_queue.py +76 -46
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/sql_views.py +248 -246
- claude_sql-0.6.0/src/claude_sql/checkpointer.py +0 -202
- {claude_sql-0.6.0 → claude_sql-0.7.0}/README.md +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/__init__.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/binding.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/blind_handover.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/freeze.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/friction_worker.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/install_source.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/judge_worker.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/judges.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/kappa_worker.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/llm_worker.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/logging_setup.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/output.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/parquet_shards.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/review_sheet_render.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/review_sheet_worker.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/schemas.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/session_text.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/skills_catalog.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/terms_worker.py +0 -0
- {claude_sql-0.6.0 → claude_sql-0.7.0}/src/claude_sql/ungrounded_worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: claude-sql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Zero-copy SQL + semantic search + LLM analytics over ~/.claude/ transcripts.
|
|
5
5
|
Keywords: claude,claude-code,anthropic,duckdb,sql,semantic-search,embeddings,bedrock,transcripts,analytics,observability
|
|
6
6
|
Author: Laith Al-Saadoon
|
|
@@ -21,9 +21,10 @@ Requires-Dist: anthropic>=0.40
|
|
|
21
21
|
Requires-Dist: anyio>=4.13.0
|
|
22
22
|
Requires-Dist: boto3>=1.42.91
|
|
23
23
|
Requires-Dist: cyclopts>=4.10.2
|
|
24
|
-
Requires-Dist: duckdb>=1.5.2
|
|
24
|
+
Requires-Dist: duckdb>=1.5.2,<2
|
|
25
25
|
Requires-Dist: hdbscan>=0.8.40
|
|
26
26
|
Requires-Dist: igraph>=1.0.0,<2.0
|
|
27
|
+
Requires-Dist: lancedb>=0.30,<0.31
|
|
27
28
|
Requires-Dist: leidenalg>=0.11.0,<0.12
|
|
28
29
|
Requires-Dist: loguru>=0.7.3
|
|
29
30
|
Requires-Dist: numpy>=2.4.4
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "claude-sql"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "Zero-copy SQL + semantic search + LLM analytics over ~/.claude/ transcripts."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Apache-2.0" }
|
|
@@ -31,9 +31,11 @@ dependencies = [
|
|
|
31
31
|
"anyio>=4.13.0",
|
|
32
32
|
"boto3>=1.42.91",
|
|
33
33
|
"cyclopts>=4.10.2",
|
|
34
|
-
|
|
34
|
+
# 1.5.1 floor: lance core extension (used by embeddings store) became core. Upper-bounded to <2 to stay on the 1.x train.
|
|
35
|
+
"duckdb>=1.5.2,<2",
|
|
35
36
|
"hdbscan>=0.8.40",
|
|
36
37
|
"igraph>=1.0.0,<2.0",
|
|
38
|
+
"lancedb>=0.30,<0.31",
|
|
37
39
|
"leidenalg>=0.11.0,<0.12",
|
|
38
40
|
"loguru>=0.7.3",
|
|
39
41
|
"numpy>=2.4.4",
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Per-(session_id, pipeline) checkpoint backed by a SQLite WAL file.
|
|
2
|
+
|
|
3
|
+
Tracks when each LLM pipeline last processed each session so re-runs skip
|
|
4
|
+
sessions whose transcripts have not advanced. One row per
|
|
5
|
+
``(session_id, pipeline)``; ``INSERT ... ON CONFLICT DO UPDATE`` is the upsert
|
|
6
|
+
primitive (UPSERT, supported in SQLite 3.24+).
|
|
7
|
+
|
|
8
|
+
Schema::
|
|
9
|
+
|
|
10
|
+
CREATE TABLE session_checkpoint (
|
|
11
|
+
session_id TEXT NOT NULL,
|
|
12
|
+
pipeline TEXT NOT NULL,
|
|
13
|
+
last_ts_processed TEXT,
|
|
14
|
+
last_mtime_processed TEXT,
|
|
15
|
+
completed_at TEXT NOT NULL,
|
|
16
|
+
PRIMARY KEY (session_id, pipeline)
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
All timestamps are stored as ISO-8601 UTC strings (``2026-05-11T19:32:00.001Z``).
|
|
20
|
+
They sort lexicographically. We use the stdlib ``sqlite3`` module with WAL
|
|
21
|
+
journal mode so multiple readers can run concurrently with one writer — the
|
|
22
|
+
DuckDB single-writer file lock used to force a 20× retry storm under parallel
|
|
23
|
+
classify/trajectory/conflicts pipelines; SQLite's ``busy_timeout`` pragma
|
|
24
|
+
absorbs transient writer contention transparently in microseconds.
|
|
25
|
+
|
|
26
|
+
The file lives at ``~/.claude/state.db`` (overridable via
|
|
27
|
+
``CLAUDE_SQL_CHECKPOINT_DB_PATH``). On first connect, if the legacy
|
|
28
|
+
``~/.claude/claude_sql.duckdb`` exists, its contents are migrated once.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import sqlite3
|
|
34
|
+
import threading
|
|
35
|
+
from collections.abc import Iterable
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from loguru import logger
|
|
40
|
+
|
|
41
|
+
PIPELINE_NAMES: tuple[str, ...] = ("classify", "trajectory", "conflicts", "user_friction")
|
|
42
|
+
|
|
43
|
+
_CREATE_TABLES_SQL = """
|
|
44
|
+
CREATE TABLE IF NOT EXISTS session_checkpoint (
|
|
45
|
+
session_id TEXT NOT NULL,
|
|
46
|
+
pipeline TEXT NOT NULL,
|
|
47
|
+
last_ts_processed TEXT,
|
|
48
|
+
last_mtime_processed TEXT,
|
|
49
|
+
completed_at TEXT NOT NULL,
|
|
50
|
+
PRIMARY KEY (session_id, pipeline)
|
|
51
|
+
);
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_LEGACY_DUCKDB_FILENAME = "claude_sql.duckdb"
|
|
55
|
+
_MIGRATION_SENTINEL = ".migrated_from_duckdb"
|
|
56
|
+
|
|
57
|
+
# Process-local set of paths whose schema has already been bootstrapped this
|
|
58
|
+
# process. Skipping the redundant ``CREATE TABLE IF NOT EXISTS`` on every
|
|
59
|
+
# ``_connect`` call avoids racing the writer lock when concurrent threads
|
|
60
|
+
# open the same file. Guarded by a lock so the very first concurrent opens
|
|
61
|
+
# don't both win the "not bootstrapped yet" check and double-issue DDL.
|
|
62
|
+
_SCHEMA_BOOTSTRAPPED: set[str] = set()
|
|
63
|
+
_SCHEMA_BOOTSTRAP_LOCK = threading.Lock()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _to_iso(dt: datetime | None) -> str | None:
|
|
67
|
+
"""ISO-8601 UTC string, or None."""
|
|
68
|
+
if dt is None:
|
|
69
|
+
return None
|
|
70
|
+
return dt.astimezone(UTC).isoformat()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _from_iso(s: str | None) -> datetime | None:
|
|
74
|
+
"""Parse an ISO-8601 string back to a tz-aware UTC datetime."""
|
|
75
|
+
if s is None:
|
|
76
|
+
return None
|
|
77
|
+
dt = datetime.fromisoformat(s)
|
|
78
|
+
if dt.tzinfo is None:
|
|
79
|
+
dt = dt.replace(tzinfo=UTC)
|
|
80
|
+
return dt.astimezone(UTC)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _legacy_duckdb_path(new_path: Path) -> Path:
|
|
84
|
+
return new_path.parent / _LEGACY_DUCKDB_FILENAME
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _migration_sentinel_path(new_path: Path) -> Path:
|
|
88
|
+
return new_path.parent / _MIGRATION_SENTINEL
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _migrate_from_duckdb_if_present(new_path: Path) -> None:
|
|
92
|
+
"""One-time copy from legacy DuckDB file. Idempotent.
|
|
93
|
+
|
|
94
|
+
Reads ``session_checkpoint`` and ``retry_queue`` from the legacy DuckDB,
|
|
95
|
+
writes both into the SQLite file, drops a sentinel so we don't retry on
|
|
96
|
+
every open. Failures log and skip — never block the caller.
|
|
97
|
+
"""
|
|
98
|
+
sentinel = _migration_sentinel_path(new_path)
|
|
99
|
+
if sentinel.exists():
|
|
100
|
+
return
|
|
101
|
+
legacy = _legacy_duckdb_path(new_path)
|
|
102
|
+
if not legacy.exists():
|
|
103
|
+
sentinel.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
sentinel.touch()
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
import duckdb # local import — only needed for one-time migration
|
|
108
|
+
except ImportError:
|
|
109
|
+
# No duckdb at runtime — the codebase needs it elsewhere, but if the
|
|
110
|
+
# import ever fails here we still want SQLite to come up.
|
|
111
|
+
sentinel.touch()
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
checkpoint_rows: list[tuple] = []
|
|
115
|
+
retry_rows: list[tuple] = []
|
|
116
|
+
try:
|
|
117
|
+
old = duckdb.connect(str(legacy), read_only=True)
|
|
118
|
+
try:
|
|
119
|
+
try:
|
|
120
|
+
checkpoint_rows = [
|
|
121
|
+
(
|
|
122
|
+
str(sid),
|
|
123
|
+
str(pipeline),
|
|
124
|
+
_to_iso(last_ts) if isinstance(last_ts, datetime) else last_ts,
|
|
125
|
+
_to_iso(last_mtime) if isinstance(last_mtime, datetime) else last_mtime,
|
|
126
|
+
_to_iso(completed_at)
|
|
127
|
+
if isinstance(completed_at, datetime)
|
|
128
|
+
else completed_at,
|
|
129
|
+
)
|
|
130
|
+
for sid, pipeline, last_ts, last_mtime, completed_at in old.execute(
|
|
131
|
+
"SELECT session_id, pipeline, last_ts_processed, "
|
|
132
|
+
"last_mtime_processed, completed_at FROM session_checkpoint"
|
|
133
|
+
).fetchall()
|
|
134
|
+
]
|
|
135
|
+
except duckdb.CatalogException:
|
|
136
|
+
checkpoint_rows = []
|
|
137
|
+
try:
|
|
138
|
+
retry_rows = [
|
|
139
|
+
(
|
|
140
|
+
str(pipeline),
|
|
141
|
+
str(unit_id),
|
|
142
|
+
str(error),
|
|
143
|
+
int(attempts),
|
|
144
|
+
_to_iso(next_at) if isinstance(next_at, datetime) else next_at,
|
|
145
|
+
_to_iso(created_at) if isinstance(created_at, datetime) else created_at,
|
|
146
|
+
_to_iso(completed_at)
|
|
147
|
+
if isinstance(completed_at, datetime)
|
|
148
|
+
else completed_at,
|
|
149
|
+
)
|
|
150
|
+
for pipeline, unit_id, error, attempts, next_at, created_at, completed_at in old.execute(
|
|
151
|
+
"SELECT pipeline, unit_id, error, attempts, next_attempt_at, "
|
|
152
|
+
"created_at, completed_at FROM retry_queue"
|
|
153
|
+
).fetchall()
|
|
154
|
+
]
|
|
155
|
+
except duckdb.CatalogException:
|
|
156
|
+
retry_rows = []
|
|
157
|
+
finally:
|
|
158
|
+
old.close()
|
|
159
|
+
except Exception: # noqa: BLE001 — migration is best-effort; any failure must drop the sentinel and let SQLite come up clean
|
|
160
|
+
logger.exception("Failed to read legacy DuckDB at {} for migration", legacy)
|
|
161
|
+
sentinel.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
sentinel.touch()
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
new_path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
# Open in autocommit so the PRAGMA setup runs outside a transaction
|
|
167
|
+
# (``PRAGMA journal_mode=WAL`` is a no-op inside one). After that, wrap
|
|
168
|
+
# the bulk INSERTs in a single explicit BEGIN/COMMIT — without it,
|
|
169
|
+
# autocommit treats every executemany row as its own transaction and
|
|
170
|
+
# fsyncs the WAL per row, turning a 39k-row migration into a many-minute
|
|
171
|
+
# hang. Single-transaction batch lands in <1s on the live corpus.
|
|
172
|
+
con = sqlite3.connect(str(new_path), isolation_level=None)
|
|
173
|
+
try:
|
|
174
|
+
con.execute("PRAGMA journal_mode=WAL")
|
|
175
|
+
con.execute("PRAGMA synchronous=NORMAL")
|
|
176
|
+
con.execute(_CREATE_TABLES_SQL)
|
|
177
|
+
con.execute(
|
|
178
|
+
"""
|
|
179
|
+
CREATE TABLE IF NOT EXISTS retry_queue (
|
|
180
|
+
pipeline TEXT NOT NULL,
|
|
181
|
+
unit_id TEXT NOT NULL,
|
|
182
|
+
error TEXT NOT NULL,
|
|
183
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
184
|
+
next_attempt_at TEXT NOT NULL,
|
|
185
|
+
created_at TEXT NOT NULL,
|
|
186
|
+
completed_at TEXT,
|
|
187
|
+
PRIMARY KEY (pipeline, unit_id)
|
|
188
|
+
);
|
|
189
|
+
"""
|
|
190
|
+
)
|
|
191
|
+
if checkpoint_rows or retry_rows:
|
|
192
|
+
con.execute("BEGIN")
|
|
193
|
+
try:
|
|
194
|
+
if checkpoint_rows:
|
|
195
|
+
con.executemany(
|
|
196
|
+
"INSERT OR REPLACE INTO session_checkpoint "
|
|
197
|
+
"(session_id, pipeline, last_ts_processed, last_mtime_processed, completed_at) "
|
|
198
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
199
|
+
checkpoint_rows,
|
|
200
|
+
)
|
|
201
|
+
if retry_rows:
|
|
202
|
+
con.executemany(
|
|
203
|
+
"INSERT OR REPLACE INTO retry_queue "
|
|
204
|
+
"(pipeline, unit_id, error, attempts, next_attempt_at, created_at, completed_at) "
|
|
205
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
206
|
+
retry_rows,
|
|
207
|
+
)
|
|
208
|
+
con.execute("COMMIT")
|
|
209
|
+
except Exception:
|
|
210
|
+
con.execute("ROLLBACK")
|
|
211
|
+
raise
|
|
212
|
+
finally:
|
|
213
|
+
con.close()
|
|
214
|
+
sentinel.touch()
|
|
215
|
+
logger.info(
|
|
216
|
+
"Migrated {} checkpoint rows + {} retry rows from {} to {}",
|
|
217
|
+
len(checkpoint_rows),
|
|
218
|
+
len(retry_rows),
|
|
219
|
+
legacy,
|
|
220
|
+
new_path,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _connect(path: Path, *, max_attempts: int = 20) -> sqlite3.Connection:
|
|
225
|
+
"""Open the SQLite checkpoint DB and ensure tables exist.
|
|
226
|
+
|
|
227
|
+
SQLite WAL mode allows many concurrent readers + one writer. Transient
|
|
228
|
+
write contention is absorbed by ``PRAGMA busy_timeout=5000`` (5s wait),
|
|
229
|
+
so the 20× exponential-backoff retry loop the DuckDB version needed is
|
|
230
|
+
gone. ``max_attempts`` is kept as a no-op kwarg for back-compat.
|
|
231
|
+
"""
|
|
232
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
_migrate_from_duckdb_if_present(path)
|
|
234
|
+
# Connect in autocommit (isolation_level=None) so the per-connection
|
|
235
|
+
# ``busy_timeout`` PRAGMA runs outside a transaction. Switch to deferred
|
|
236
|
+
# mode at the end so writes wrap in implicit BEGIN/COMMIT.
|
|
237
|
+
con = sqlite3.connect(str(path), isolation_level=None, timeout=30.0)
|
|
238
|
+
# Per-connection PRAGMAs (must run every open).
|
|
239
|
+
con.execute("PRAGMA busy_timeout=10000")
|
|
240
|
+
con.execute("PRAGMA foreign_keys=ON")
|
|
241
|
+
# File-level setup runs exactly once per (process, path) under a lock.
|
|
242
|
+
# ``PRAGMA journal_mode=WAL`` and ``synchronous=NORMAL`` persist in the
|
|
243
|
+
# DB header; running them on every open races concurrent cold-start
|
|
244
|
+
# writers (the WAL transition acquires the writer lock and the loser
|
|
245
|
+
# raises ``OperationalError: database is locked``). ``CREATE TABLE IF
|
|
246
|
+
# NOT EXISTS`` has the same race even though it is conceptually
|
|
247
|
+
# idempotent — the DDL still grabs the writer lock under contention.
|
|
248
|
+
# Caching the bootstrap behind ``_SCHEMA_BOOTSTRAPPED`` removes both.
|
|
249
|
+
key = str(path.resolve())
|
|
250
|
+
with _SCHEMA_BOOTSTRAP_LOCK:
|
|
251
|
+
if key not in _SCHEMA_BOOTSTRAPPED:
|
|
252
|
+
con.execute("PRAGMA journal_mode=WAL")
|
|
253
|
+
con.execute("PRAGMA synchronous=NORMAL")
|
|
254
|
+
con.execute(_CREATE_TABLES_SQL)
|
|
255
|
+
_SCHEMA_BOOTSTRAPPED.add(key)
|
|
256
|
+
con.isolation_level = (
|
|
257
|
+
"DEFERRED" # writes wrap in implicit BEGIN/COMMIT; lock contention waits on timeout
|
|
258
|
+
)
|
|
259
|
+
return con
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def load_as_map(db_path: Path, pipeline: str) -> dict[str, tuple[datetime | None, datetime | None]]:
|
|
263
|
+
"""Return ``{session_id: (last_ts, last_mtime)}`` for one pipeline.
|
|
264
|
+
|
|
265
|
+
Empty dict when the DB doesn't exist yet or the pipeline has no rows.
|
|
266
|
+
"""
|
|
267
|
+
if not db_path.exists() and not _legacy_duckdb_path(db_path).exists():
|
|
268
|
+
return {}
|
|
269
|
+
con = _connect(db_path)
|
|
270
|
+
try:
|
|
271
|
+
rows = con.execute(
|
|
272
|
+
"SELECT session_id, last_ts_processed, last_mtime_processed "
|
|
273
|
+
"FROM session_checkpoint WHERE pipeline = ?",
|
|
274
|
+
[pipeline],
|
|
275
|
+
).fetchall()
|
|
276
|
+
finally:
|
|
277
|
+
con.close()
|
|
278
|
+
return {
|
|
279
|
+
str(sid): (_from_iso(last_ts), _from_iso(last_mtime)) for sid, last_ts, last_mtime in rows
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def filter_unchanged(
|
|
284
|
+
candidates: Iterable[tuple[str, datetime | None, datetime | None]],
|
|
285
|
+
*,
|
|
286
|
+
pipeline: str,
|
|
287
|
+
checkpoint_db_path: Path,
|
|
288
|
+
) -> tuple[list[str], int]:
|
|
289
|
+
"""Drop sessions whose ``(last_ts, last_mtime)`` has not advanced.
|
|
290
|
+
|
|
291
|
+
``candidates`` is an iterable of ``(session_id, current_last_ts,
|
|
292
|
+
current_last_mtime)``. Returns ``(pending_session_ids, skipped_count)``.
|
|
293
|
+
|
|
294
|
+
A session is skipped iff a checkpoint row exists for ``pipeline`` AND
|
|
295
|
+
both ``current_last_ts <= ckpt.last_ts`` AND ``current_last_mtime <=
|
|
296
|
+
ckpt.last_mtime``. Either bound moving forward invalidates the skip.
|
|
297
|
+
"""
|
|
298
|
+
ckpt = load_as_map(checkpoint_db_path, pipeline)
|
|
299
|
+
pending: list[str] = []
|
|
300
|
+
skipped = 0
|
|
301
|
+
for sid, cur_ts, cur_mtime in candidates:
|
|
302
|
+
prev = ckpt.get(sid)
|
|
303
|
+
if prev is None:
|
|
304
|
+
pending.append(sid)
|
|
305
|
+
continue
|
|
306
|
+
prev_ts, prev_mtime = prev
|
|
307
|
+
if _stale_or_equal(cur_ts, prev_ts) and _stale_or_equal(cur_mtime, prev_mtime):
|
|
308
|
+
skipped += 1
|
|
309
|
+
continue
|
|
310
|
+
pending.append(sid)
|
|
311
|
+
return pending, skipped
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _stale_or_equal(cur: datetime | None, prev: datetime | None) -> bool:
|
|
315
|
+
"""True iff both are present and ``cur`` has not advanced past ``prev``.
|
|
316
|
+
|
|
317
|
+
Both inputs are tz-aware UTC datetimes after the boundary helpers have
|
|
318
|
+
run; we compare directly. None on either side returns False (advance).
|
|
319
|
+
"""
|
|
320
|
+
if cur is None or prev is None:
|
|
321
|
+
return False
|
|
322
|
+
cur_aware = cur.astimezone(UTC) if cur.tzinfo else cur.replace(tzinfo=UTC)
|
|
323
|
+
prev_aware = prev.astimezone(UTC) if prev.tzinfo else prev.replace(tzinfo=UTC)
|
|
324
|
+
return cur_aware <= prev_aware
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def mark_completed(
|
|
328
|
+
db_path: Path,
|
|
329
|
+
*,
|
|
330
|
+
pipeline: str,
|
|
331
|
+
rows: Iterable[tuple[str, datetime | None, datetime | None]],
|
|
332
|
+
) -> int:
|
|
333
|
+
"""Upsert checkpoint rows for ``(session_id, pipeline)``.
|
|
334
|
+
|
|
335
|
+
Each row is ``(session_id, last_ts_processed, last_mtime_processed)``.
|
|
336
|
+
The ``completed_at`` column is stamped with ``datetime.now(UTC)``.
|
|
337
|
+
|
|
338
|
+
Returns the number of upserted rows. When ``rows`` is empty, the DB is
|
|
339
|
+
left untouched.
|
|
340
|
+
"""
|
|
341
|
+
incoming = list(rows)
|
|
342
|
+
if not incoming:
|
|
343
|
+
return 0
|
|
344
|
+
now_iso = _to_iso(datetime.now(UTC))
|
|
345
|
+
payload = [
|
|
346
|
+
(sid, pipeline, _to_iso(last_ts), _to_iso(last_mtime), now_iso)
|
|
347
|
+
for sid, last_ts, last_mtime in incoming
|
|
348
|
+
]
|
|
349
|
+
con = _connect(db_path)
|
|
350
|
+
try:
|
|
351
|
+
con.executemany(
|
|
352
|
+
"INSERT INTO session_checkpoint "
|
|
353
|
+
"(session_id, pipeline, last_ts_processed, last_mtime_processed, completed_at) "
|
|
354
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
355
|
+
"ON CONFLICT(session_id, pipeline) DO UPDATE SET "
|
|
356
|
+
"last_ts_processed = excluded.last_ts_processed, "
|
|
357
|
+
"last_mtime_processed = excluded.last_mtime_processed, "
|
|
358
|
+
"completed_at = excluded.completed_at",
|
|
359
|
+
payload,
|
|
360
|
+
)
|
|
361
|
+
con.commit()
|
|
362
|
+
finally:
|
|
363
|
+
con.close()
|
|
364
|
+
return len(incoming)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def count_rows(db_path: Path) -> int:
|
|
368
|
+
"""Return the total number of checkpoint rows, or 0 when the DB is missing."""
|
|
369
|
+
if not db_path.exists() and not _legacy_duckdb_path(db_path).exists():
|
|
370
|
+
return 0
|
|
371
|
+
con = _connect(db_path)
|
|
372
|
+
try:
|
|
373
|
+
row = con.execute("SELECT count(*) FROM session_checkpoint").fetchone()
|
|
374
|
+
finally:
|
|
375
|
+
con.close()
|
|
376
|
+
return int(row[0]) if row else 0
|