langgraph-checkpoint-sqlite 3.1.1__tar.gz → 3.1.2__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.
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/.gitignore +6 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/PKG-INFO +11 -35
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/README.md +8 -32
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/__init__.py +43 -27
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/_delta.py +59 -39
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/aio.py +38 -21
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/utils.py +23 -2
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/store/sqlite/aio.py +1 -1
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/store/sqlite/base.py +4 -2
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/pyproject.toml +6 -2
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_async_store.py +64 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_conformance_delta.py +3 -10
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_delta_channel_migration.py +6 -6
- langgraph_checkpoint_sqlite-3.1.2/tests/test_delta_parent_walk.py +137 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_get_delta_channel_history.py +6 -6
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_store.py +53 -9
- langgraph_checkpoint_sqlite-3.1.2/tests/test_writes_task_path_migration.py +151 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/uv.lock +87 -56
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/LICENSE +0 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/Makefile +0 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/cache/sqlite/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/checkpoint/sqlite/py.typed +0 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/langgraph/store/sqlite/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/tests/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_aiosqlite.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_sqlite.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.1 → langgraph_checkpoint_sqlite-3.1.2}/tests/test_ttl.py +0 -0
|
@@ -76,7 +76,12 @@ __pypackages__/
|
|
|
76
76
|
|
|
77
77
|
# Environments
|
|
78
78
|
.env
|
|
79
|
+
.env.*
|
|
79
80
|
.envrc
|
|
81
|
+
*.crt
|
|
82
|
+
*.key
|
|
83
|
+
*.pem
|
|
84
|
+
credentials.json
|
|
80
85
|
.venv
|
|
81
86
|
.venvs
|
|
82
87
|
env/
|
|
@@ -98,6 +103,7 @@ dmypy.json
|
|
|
98
103
|
|
|
99
104
|
.vercel
|
|
100
105
|
.turbo
|
|
106
|
+
node_modules/
|
|
101
107
|
.editorconfig
|
|
102
108
|
.scratch
|
|
103
109
|
.worktrees/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: langgraph-checkpoint-sqlite
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.2
|
|
4
4
|
Summary: Library with a SQLite implementation of LangGraph checkpoint saver.
|
|
5
5
|
Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite
|
|
6
6
|
Project-URL: Twitter, https://x.com/langchain_oss
|
|
@@ -10,7 +10,7 @@ License-Expression: MIT
|
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Python: >=3.10
|
|
12
12
|
Requires-Dist: aiosqlite>=0.20
|
|
13
|
-
Requires-Dist: langgraph-checkpoint<5.0.0,>=4.
|
|
13
|
+
Requires-Dist: langgraph-checkpoint<5.0.0,>=4.3.0
|
|
14
14
|
Requires-Dist: sqlite-vec>=0.1.6
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
|
|
@@ -56,24 +56,12 @@ with SqliteSaver.from_conn_string(":memory:") as checkpointer:
|
|
|
56
56
|
"v": 4,
|
|
57
57
|
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
58
58
|
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
59
|
-
"channel_values": {
|
|
60
|
-
|
|
61
|
-
"node": "node"
|
|
62
|
-
},
|
|
63
|
-
"channel_versions": {
|
|
64
|
-
"__start__": 2,
|
|
65
|
-
"my_key": 3,
|
|
66
|
-
"start:node": 3,
|
|
67
|
-
"node": 3
|
|
68
|
-
},
|
|
59
|
+
"channel_values": {"my_key": "meow", "node": "node"},
|
|
60
|
+
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
|
69
61
|
"versions_seen": {
|
|
70
62
|
"__input__": {},
|
|
71
|
-
"__start__": {
|
|
72
|
-
|
|
73
|
-
},
|
|
74
|
-
"node": {
|
|
75
|
-
"start:node": 2
|
|
76
|
-
}
|
|
63
|
+
"__start__": {"__start__": 1},
|
|
64
|
+
"node": {"start:node": 2},
|
|
77
65
|
},
|
|
78
66
|
}
|
|
79
67
|
|
|
@@ -97,24 +85,12 @@ async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
|
|
|
97
85
|
"v": 4,
|
|
98
86
|
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
99
87
|
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
100
|
-
"channel_values": {
|
|
101
|
-
|
|
102
|
-
"node": "node"
|
|
103
|
-
},
|
|
104
|
-
"channel_versions": {
|
|
105
|
-
"__start__": 2,
|
|
106
|
-
"my_key": 3,
|
|
107
|
-
"start:node": 3,
|
|
108
|
-
"node": 3
|
|
109
|
-
},
|
|
88
|
+
"channel_values": {"my_key": "meow", "node": "node"},
|
|
89
|
+
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
|
110
90
|
"versions_seen": {
|
|
111
91
|
"__input__": {},
|
|
112
|
-
"__start__": {
|
|
113
|
-
|
|
114
|
-
},
|
|
115
|
-
"node": {
|
|
116
|
-
"start:node": 2
|
|
117
|
-
}
|
|
92
|
+
"__start__": {"__start__": 1},
|
|
93
|
+
"node": {"start:node": 2},
|
|
118
94
|
},
|
|
119
95
|
}
|
|
120
96
|
|
|
@@ -40,24 +40,12 @@ with SqliteSaver.from_conn_string(":memory:") as checkpointer:
|
|
|
40
40
|
"v": 4,
|
|
41
41
|
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
42
42
|
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
43
|
-
"channel_values": {
|
|
44
|
-
|
|
45
|
-
"node": "node"
|
|
46
|
-
},
|
|
47
|
-
"channel_versions": {
|
|
48
|
-
"__start__": 2,
|
|
49
|
-
"my_key": 3,
|
|
50
|
-
"start:node": 3,
|
|
51
|
-
"node": 3
|
|
52
|
-
},
|
|
43
|
+
"channel_values": {"my_key": "meow", "node": "node"},
|
|
44
|
+
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
|
53
45
|
"versions_seen": {
|
|
54
46
|
"__input__": {},
|
|
55
|
-
"__start__": {
|
|
56
|
-
|
|
57
|
-
},
|
|
58
|
-
"node": {
|
|
59
|
-
"start:node": 2
|
|
60
|
-
}
|
|
47
|
+
"__start__": {"__start__": 1},
|
|
48
|
+
"node": {"start:node": 2},
|
|
61
49
|
},
|
|
62
50
|
}
|
|
63
51
|
|
|
@@ -81,24 +69,12 @@ async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
|
|
|
81
69
|
"v": 4,
|
|
82
70
|
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
83
71
|
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
84
|
-
"channel_values": {
|
|
85
|
-
|
|
86
|
-
"node": "node"
|
|
87
|
-
},
|
|
88
|
-
"channel_versions": {
|
|
89
|
-
"__start__": 2,
|
|
90
|
-
"my_key": 3,
|
|
91
|
-
"start:node": 3,
|
|
92
|
-
"node": 3
|
|
93
|
-
},
|
|
72
|
+
"channel_values": {"my_key": "meow", "node": "node"},
|
|
73
|
+
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
|
94
74
|
"versions_seen": {
|
|
95
75
|
"__input__": {},
|
|
96
|
-
"__start__": {
|
|
97
|
-
|
|
98
|
-
},
|
|
99
|
-
"node": {
|
|
100
|
-
"start:node": 2
|
|
101
|
-
}
|
|
76
|
+
"__start__": {"__start__": 1},
|
|
77
|
+
"node": {"start:node": 2},
|
|
102
78
|
},
|
|
103
79
|
}
|
|
104
80
|
|
|
@@ -29,7 +29,11 @@ from langgraph.checkpoint.sqlite._delta import (
|
|
|
29
29
|
build_delta_stage2_sql,
|
|
30
30
|
step_walk_with_row,
|
|
31
31
|
)
|
|
32
|
-
from langgraph.checkpoint.sqlite.utils import
|
|
32
|
+
from langgraph.checkpoint.sqlite.utils import (
|
|
33
|
+
load_pending_writes,
|
|
34
|
+
pending_writes_sql,
|
|
35
|
+
search_where,
|
|
36
|
+
)
|
|
33
37
|
|
|
34
38
|
_AIO_ERROR_MSG = (
|
|
35
39
|
"The SqliteSaver does not support async methods. "
|
|
@@ -77,10 +81,11 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
77
81
|
>>> result = graph.invoke(3, config)
|
|
78
82
|
>>> graph.get_state(config)
|
|
79
83
|
StateSnapshot(values=4, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '0c62ca34-ac19-445d-bbb0-5b4984975b2a'}}, parent_config=None)
|
|
80
|
-
"""
|
|
84
|
+
"""
|
|
81
85
|
|
|
82
86
|
conn: sqlite3.Connection
|
|
83
87
|
is_setup: bool
|
|
88
|
+
_has_task_path: bool = True
|
|
84
89
|
|
|
85
90
|
def __init__(
|
|
86
91
|
self,
|
|
@@ -154,6 +159,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
154
159
|
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
|
155
160
|
checkpoint_id TEXT NOT NULL,
|
|
156
161
|
task_id TEXT NOT NULL,
|
|
162
|
+
task_path TEXT NOT NULL DEFAULT '',
|
|
157
163
|
idx INTEGER NOT NULL,
|
|
158
164
|
channel TEXT NOT NULL,
|
|
159
165
|
type TEXT,
|
|
@@ -162,6 +168,19 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
162
168
|
);
|
|
163
169
|
"""
|
|
164
170
|
)
|
|
171
|
+
# sqlite has no ADD COLUMN IF NOT EXISTS; this migrates databases
|
|
172
|
+
# created before `task_path` existed and is a no-op on the rest.
|
|
173
|
+
try:
|
|
174
|
+
self.conn.execute(
|
|
175
|
+
"ALTER TABLE writes ADD COLUMN task_path TEXT NOT NULL DEFAULT ''"
|
|
176
|
+
)
|
|
177
|
+
except sqlite3.OperationalError as e:
|
|
178
|
+
# A read-only database from before the column can still be read;
|
|
179
|
+
# its rows would all read back as '' anyway.
|
|
180
|
+
if "readonly database" in str(e):
|
|
181
|
+
self._has_task_path = False
|
|
182
|
+
elif "duplicate column name" not in str(e):
|
|
183
|
+
raise
|
|
165
184
|
|
|
166
185
|
self.is_setup = True
|
|
167
186
|
|
|
@@ -222,7 +241,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
222
241
|
>>> checkpoint_tuple = memory.get_tuple(config)
|
|
223
242
|
>>> print(checkpoint_tuple)
|
|
224
243
|
CheckpointTuple(...)
|
|
225
|
-
"""
|
|
244
|
+
"""
|
|
226
245
|
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
227
246
|
with self.cursor(transaction=False) as cur:
|
|
228
247
|
# find the latest checkpoint for the thread_id
|
|
@@ -260,7 +279,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
260
279
|
}
|
|
261
280
|
# find any pending writes
|
|
262
281
|
cur.execute(
|
|
263
|
-
|
|
282
|
+
pending_writes_sql(self._has_task_path),
|
|
264
283
|
(
|
|
265
284
|
str(config["configurable"]["thread_id"]),
|
|
266
285
|
checkpoint_ns,
|
|
@@ -286,10 +305,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
286
305
|
if parent_checkpoint_id
|
|
287
306
|
else None
|
|
288
307
|
),
|
|
289
|
-
|
|
290
|
-
(task_id, channel, self.serde.loads_typed((type, value)))
|
|
291
|
-
for task_id, channel, type, value in cur
|
|
292
|
-
],
|
|
308
|
+
load_pending_writes(cur, self.serde),
|
|
293
309
|
)
|
|
294
310
|
|
|
295
311
|
def list(
|
|
@@ -351,7 +367,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
351
367
|
metadata,
|
|
352
368
|
) in cur:
|
|
353
369
|
wcur.execute(
|
|
354
|
-
|
|
370
|
+
pending_writes_sql(self._has_task_path),
|
|
355
371
|
(thread_id, checkpoint_ns, checkpoint_id),
|
|
356
372
|
)
|
|
357
373
|
yield CheckpointTuple(
|
|
@@ -378,10 +394,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
378
394
|
if parent_checkpoint_id
|
|
379
395
|
else None
|
|
380
396
|
),
|
|
381
|
-
|
|
382
|
-
(task_id, channel, self.serde.loads_typed((type, value)))
|
|
383
|
-
for task_id, channel, type, value in wcur
|
|
384
|
-
],
|
|
397
|
+
load_pending_writes(wcur, self.serde),
|
|
385
398
|
)
|
|
386
399
|
|
|
387
400
|
def put(
|
|
@@ -460,9 +473,9 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
460
473
|
task_path: Path of the task creating the writes.
|
|
461
474
|
"""
|
|
462
475
|
query = (
|
|
463
|
-
"INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
476
|
+
"INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
464
477
|
if all(w[0] in WRITES_IDX_MAP for w in writes)
|
|
465
|
-
else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
478
|
+
else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
466
479
|
)
|
|
467
480
|
with self.cursor() as cur:
|
|
468
481
|
cur.executemany(
|
|
@@ -473,6 +486,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
473
486
|
str(config["configurable"]["checkpoint_ns"]),
|
|
474
487
|
str(config["configurable"]["checkpoint_id"]),
|
|
475
488
|
task_id,
|
|
489
|
+
task_path,
|
|
476
490
|
WRITES_IDX_MAP.get(channel, idx),
|
|
477
491
|
channel,
|
|
478
492
|
*self.serde.dumps_typed(value),
|
|
@@ -507,13 +521,12 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
507
521
|
|
|
508
522
|
Two-stage query:
|
|
509
523
|
|
|
510
|
-
* Stage 1 (
|
|
511
|
-
`
|
|
512
|
-
ancestor. Sqlite has no
|
|
513
|
-
checkpoint blob and inspect
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
has found its seed or the chain is exhausted.
|
|
524
|
+
* Stage 1 (streamed): recursive CTE over `checkpoints` following
|
|
525
|
+
`parent_checkpoint_id` from the target, returning
|
|
526
|
+
`(checkpoint_id, type, checkpoint)` per ancestor. Sqlite has no
|
|
527
|
+
JSONB, so we ship the full serialized checkpoint blob and inspect
|
|
528
|
+
`channel_values` in Python. Stops reading when every channel has
|
|
529
|
+
found its seed or the chain is exhausted.
|
|
517
530
|
|
|
518
531
|
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
|
519
532
|
`writes` filtered to that channel's specific `chain_cids`. No
|
|
@@ -526,7 +539,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
526
539
|
thread_id = str(config["configurable"]["thread_id"])
|
|
527
540
|
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
528
541
|
checkpoint_id = get_checkpoint_id(config)
|
|
529
|
-
if checkpoint_id
|
|
542
|
+
if not checkpoint_id:
|
|
530
543
|
target = self.get_tuple(config)
|
|
531
544
|
if target is None:
|
|
532
545
|
return {ch: {"writes": []} for ch in channels}
|
|
@@ -538,12 +551,14 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
538
551
|
seeded: set[str] = set()
|
|
539
552
|
|
|
540
553
|
with self.cursor(transaction=False) as cur:
|
|
541
|
-
cur.execute(
|
|
554
|
+
cur.execute(
|
|
555
|
+
DELTA_STAGE1_SQL,
|
|
556
|
+
(thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
|
|
557
|
+
)
|
|
542
558
|
for row in cur:
|
|
543
|
-
cid,
|
|
559
|
+
cid, type_tag, blob = row
|
|
544
560
|
if step_walk_with_row(
|
|
545
561
|
cid=cid,
|
|
546
|
-
parent_cid=parent_cid,
|
|
547
562
|
type_tag=type_tag,
|
|
548
563
|
blob=blob,
|
|
549
564
|
target_id=checkpoint_id,
|
|
@@ -558,6 +573,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
558
573
|
|
|
559
574
|
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
|
|
560
575
|
stage2_sql = build_delta_stage2_sql(
|
|
576
|
+
has_task_path=self._has_task_path,
|
|
561
577
|
chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
|
|
562
578
|
)
|
|
563
579
|
if stage2_sql:
|
|
@@ -568,7 +584,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
568
584
|
)
|
|
569
585
|
cur.execute(stage2_sql, stage2_params)
|
|
570
586
|
stage2_rows = cast(
|
|
571
|
-
"list[tuple[str, str, str, int, str, bytes]]", cur.fetchall()
|
|
587
|
+
"list[tuple[str, str, str, int, str, bytes, str]]", cur.fetchall()
|
|
572
588
|
)
|
|
573
589
|
else:
|
|
574
590
|
stage2_rows = []
|
|
@@ -24,22 +24,45 @@ from __future__ import annotations
|
|
|
24
24
|
from collections.abc import Mapping, Sequence
|
|
25
25
|
from typing import Any
|
|
26
26
|
|
|
27
|
-
from langgraph.checkpoint.base import
|
|
27
|
+
from langgraph.checkpoint.base import (
|
|
28
|
+
DeltaChannelHistory,
|
|
29
|
+
PendingWrite,
|
|
30
|
+
writes_sort_key,
|
|
31
|
+
)
|
|
28
32
|
|
|
29
|
-
# Stage 1 streams
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
# the
|
|
33
|
-
# `BaseCheckpointSaver` contract).
|
|
33
|
+
# Stage 1 streams target, then its ancestors nearest-first, by following
|
|
34
|
+
# `parent_checkpoint_id` rather than id order: ids are only monotonic within
|
|
35
|
+
# one process, so a range scan by id can miss a parent whose id sorts above
|
|
36
|
+
# its child's. Target is the anchor row; its own writes/seed are skipped
|
|
37
|
+
# (matches the `BaseCheckpointSaver` contract).
|
|
38
|
+
#
|
|
39
|
+
# `put` is `INSERT OR REPLACE`, so re-putting an existing id under a
|
|
40
|
+
# descendant's config makes the chain a loop. `step_walk_with_row` stops on a
|
|
41
|
+
# repeated id; sqlite yields recursive rows lazily, so abandoning the cursor
|
|
42
|
+
# ends the recursion.
|
|
43
|
+
#
|
|
44
|
+
# `CROSS JOIN` pins `ancestors` as the outer loop, so each step is one primary
|
|
45
|
+
# key lookup. With a plain `JOIN` and no `ANALYZE` stats, sqlite can put
|
|
46
|
+
# `checkpoints` outside and scan the whole thread per step.
|
|
34
47
|
DELTA_STAGE1_SQL = (
|
|
48
|
+
"WITH RECURSIVE ancestors(checkpoint_id, parent_checkpoint_id, type, "
|
|
49
|
+
"checkpoint) AS ("
|
|
35
50
|
"SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
|
|
36
51
|
"FROM checkpoints "
|
|
37
|
-
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id
|
|
38
|
-
"
|
|
52
|
+
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? "
|
|
53
|
+
"UNION ALL "
|
|
54
|
+
"SELECT c.checkpoint_id, c.parent_checkpoint_id, c.type, c.checkpoint "
|
|
55
|
+
"FROM ancestors a CROSS JOIN checkpoints c "
|
|
56
|
+
"ON c.checkpoint_id = a.parent_checkpoint_id "
|
|
57
|
+
"WHERE c.thread_id = ? AND c.checkpoint_ns = ?"
|
|
58
|
+
") "
|
|
59
|
+
"SELECT checkpoint_id, type, checkpoint FROM ancestors"
|
|
39
60
|
)
|
|
40
61
|
|
|
41
62
|
|
|
42
|
-
def build_delta_stage2_sql(
|
|
63
|
+
def build_delta_stage2_sql(
|
|
64
|
+
*, chain_lens: Sequence[int], has_task_path: bool = True
|
|
65
|
+
) -> str:
|
|
43
66
|
"""Stage-2 per-channel UNION ALL fetching writes from `writes`.
|
|
44
67
|
|
|
45
68
|
One branch per channel with a non-empty chain. Each branch inlines its
|
|
@@ -53,11 +76,12 @@ def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
|
|
|
53
76
|
of a single `channel = ANY(channels)` filter when channels have
|
|
54
77
|
different chain depths — same rationale as postgres.
|
|
55
78
|
"""
|
|
79
|
+
task_path = "task_path" if has_task_path else "''"
|
|
56
80
|
branches: list[str] = []
|
|
57
81
|
for n in chain_lens:
|
|
58
82
|
cid_placeholders = ",".join("?" * n)
|
|
59
83
|
branches.append(
|
|
60
|
-
"SELECT checkpoint_id, channel, task_id, idx, type, value "
|
|
84
|
+
f"SELECT checkpoint_id, channel, task_id, idx, type, value, {task_path} "
|
|
61
85
|
"FROM writes "
|
|
62
86
|
"WHERE thread_id = ? AND checkpoint_ns = ? AND channel = ? "
|
|
63
87
|
f"AND checkpoint_id IN ({cid_placeholders})"
|
|
@@ -68,7 +92,6 @@ def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
|
|
|
68
92
|
def step_walk_with_row(
|
|
69
93
|
*,
|
|
70
94
|
cid: str,
|
|
71
|
-
parent_cid: str | None,
|
|
72
95
|
type_tag: str,
|
|
73
96
|
blob: bytes,
|
|
74
97
|
target_id: str,
|
|
@@ -81,36 +104,32 @@ def step_walk_with_row(
|
|
|
81
104
|
) -> bool:
|
|
82
105
|
"""Process one streamed stage-1 row in the merged ancestor walk.
|
|
83
106
|
|
|
84
|
-
The cursor returns (cid,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
skip it (target's own writes/seed are not part of the contract).
|
|
107
|
+
The cursor returns (cid, type, blob) rows in walk order starting at
|
|
108
|
+
target. The first row is target itself and is skipped (target's own
|
|
109
|
+
writes/seed are not part of the contract).
|
|
88
110
|
|
|
89
|
-
For each subsequent row
|
|
90
|
-
|
|
91
|
-
not-yet-seeded channel's chain, and check `channel_values` for
|
|
111
|
+
For each subsequent row we deserialize the blob, append the cid to
|
|
112
|
+
every not-yet-seeded channel's chain, and check `channel_values` for
|
|
92
113
|
seeds. The deserialized checkpoint is dropped before advancing — no
|
|
93
114
|
cross-row cache, so peak in-flight is one deserialized checkpoint.
|
|
94
115
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
Returns True when every requested channel is seeded — the caller
|
|
99
|
-
can stop iterating and close the cursor.
|
|
116
|
+
Returns True when the caller can stop iterating and close the cursor:
|
|
117
|
+
every requested channel is seeded, or the chain revisited a checkpoint.
|
|
100
118
|
"""
|
|
101
119
|
if "started" not in walk_state:
|
|
102
120
|
if cid == target_id:
|
|
103
121
|
walk_state["started"] = True
|
|
104
|
-
walk_state["cur_cid"] = parent_cid
|
|
105
122
|
walk_state["active"] = {ch for ch in channels if ch not in seeded}
|
|
123
|
+
walk_state["walked"] = {cid}
|
|
106
124
|
# Not target yet (or target not present): keep streaming.
|
|
107
125
|
return False
|
|
108
126
|
active: set[str] = walk_state["active"]
|
|
109
127
|
if not active:
|
|
110
128
|
return True
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return
|
|
129
|
+
walked: set[str] = walk_state["walked"]
|
|
130
|
+
if cid in walked:
|
|
131
|
+
return True
|
|
132
|
+
walked.add(cid)
|
|
114
133
|
for ch in active:
|
|
115
134
|
chain_by_ch[ch].append(cid)
|
|
116
135
|
ckpt = serde.loads_typed((type_tag, blob))
|
|
@@ -120,7 +139,6 @@ def step_walk_with_row(
|
|
|
120
139
|
seeded.add(ch)
|
|
121
140
|
active.discard(ch)
|
|
122
141
|
del ckpt, channel_values
|
|
123
|
-
walk_state["cur_cid"] = parent_cid
|
|
124
142
|
return not active
|
|
125
143
|
|
|
126
144
|
|
|
@@ -130,29 +148,31 @@ def build_delta_channels_writes_history(
|
|
|
130
148
|
chain_by_ch: Mapping[str, list[str]],
|
|
131
149
|
seed_val_by_ch: Mapping[str, Any],
|
|
132
150
|
seeded: set[str],
|
|
133
|
-
stage2_rows: Sequence[tuple[str, str, str, int, str, bytes]],
|
|
151
|
+
stage2_rows: Sequence[tuple[str, str, str, int, str, bytes, str]],
|
|
134
152
|
serde: Any,
|
|
135
153
|
) -> dict[str, DeltaChannelHistory]:
|
|
136
154
|
"""Demux stage-2 rows per channel; produce per-channel histories.
|
|
137
155
|
|
|
138
|
-
Stage-2 rows are
|
|
139
|
-
|
|
140
|
-
|
|
156
|
+
Stage-2 rows are
|
|
157
|
+
`(checkpoint_id, channel, task_id, idx, type, value, task_path)`.
|
|
158
|
+
Final write order is oldest→newest globally and `writes_sort_key`
|
|
159
|
+
within a checkpoint, matching the contract on
|
|
160
|
+
`DeltaChannelHistory.writes`.
|
|
141
161
|
|
|
142
162
|
`seed` is omitted when the walk reached a true root with no snapshot
|
|
143
163
|
found (channel never entered `seeded`); consumers treat absence as
|
|
144
164
|
"start empty".
|
|
145
165
|
"""
|
|
146
|
-
writes_by_ch_by_cid: dict[
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
for cid, ch, task_id, idx, type_tag, value_blob in stage2_rows:
|
|
166
|
+
writes_by_ch_by_cid: dict[
|
|
167
|
+
str, dict[str, list[tuple[str, bytes, str, int, str]]]
|
|
168
|
+
] = {ch: {} for ch in channels}
|
|
169
|
+
for cid, ch, task_id, idx, type_tag, value_blob, task_path in stage2_rows:
|
|
150
170
|
writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append(
|
|
151
|
-
(type_tag, value_blob, task_id, idx)
|
|
171
|
+
(type_tag, value_blob, task_id, idx, task_path)
|
|
152
172
|
)
|
|
153
173
|
for cid_map in writes_by_ch_by_cid.values():
|
|
154
174
|
for ws in cid_map.values():
|
|
155
|
-
ws.sort(key=lambda w: (w[2], w[3]))
|
|
175
|
+
ws.sort(key=lambda w: writes_sort_key(w[4], w[2], w[3]))
|
|
156
176
|
|
|
157
177
|
result: dict[str, DeltaChannelHistory] = {}
|
|
158
178
|
for ch in channels:
|
|
@@ -161,7 +181,7 @@ def build_delta_channels_writes_history(
|
|
|
161
181
|
collected: list[PendingWrite] = []
|
|
162
182
|
# Chain is newest-first; iterate oldest-first for the public order.
|
|
163
183
|
for cid in reversed(chain_cids):
|
|
164
|
-
for type_tag, value_blob, task_id, _idx in cid_writes.get(cid, []):
|
|
184
|
+
for type_tag, value_blob, task_id, _idx, _path in cid_writes.get(cid, []):
|
|
165
185
|
collected.append(
|
|
166
186
|
(task_id, ch, serde.loads_typed((type_tag, value_blob)))
|
|
167
187
|
)
|