thread-archive 0.0.2__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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2340 @@
|
|
|
1
|
+
"""JSONL truth-log: the durable source of truth for the archive.
|
|
2
|
+
|
|
3
|
+
The storage model is **JSONL-as-truth + SQLite-as-rebuildable-index**: the JSONL
|
|
4
|
+
directory on disk is the only authoritative store, and ``index.db`` is a pure
|
|
5
|
+
projection of it — ``rm index.db && archive reindex`` reconstructs the whole index
|
|
6
|
+
from the JSONL, and ``cp``/``rsync`` of the directory *is* the backup. Nothing
|
|
7
|
+
durable lives only in SQLite.
|
|
8
|
+
|
|
9
|
+
**One file per thread.** Each conversation (and each topic — topics are threads
|
|
10
|
+
too) is its own ``threads/<id>.jsonl``, the same shape as a Claude Code session
|
|
11
|
+
file: a ``{"type": "thread", ...}`` metadata record (latest wins) followed by one
|
|
12
|
+
``{"type": "event", ...}`` line per event, in order. A new event appends to that
|
|
13
|
+
one thread's file; you can open, diff, or ``cp`` a single conversation. The two
|
|
14
|
+
cross-thread overlays — ``thread_links`` (the topic graph's edges) and
|
|
15
|
+
``topic_messages`` (topic evidence) — aren't any one conversation's content, so
|
|
16
|
+
they stay as snapshot files (``thread_links.jsonl`` / ``topic_messages.jsonl``).
|
|
17
|
+
|
|
18
|
+
**Adaptive sharding.** A flat ``threads/`` directory is comfortable to ~16k files;
|
|
19
|
+
past that, tooling (file trees, ``ls``, completion) drags. ``manifest.json`` records
|
|
20
|
+
a ``shard_depth``: 0 = flat (``threads/<id>.jsonl``), 1 = 256 id-bucketed subdirs
|
|
21
|
+
(``threads/<id%256>/<id>.jsonl``, good to a few million), 2 = two levels. The path
|
|
22
|
+
resolver computes a thread's location from the recorded depth, so reads and writes
|
|
23
|
+
always agree; crossing the threshold triggers an auto-rebalance, and once sharded
|
|
24
|
+
every checkpoint re-homes any straggler file. The rebalance is crash-safe by
|
|
25
|
+
construction: the new depth is persisted *before* any file moves, a file whose home
|
|
26
|
+
already exists is merged (appended) rather than overwritten, and the sweep is
|
|
27
|
+
serialized across processes by a flock — see :func:`_maybe_rebalance`. Small
|
|
28
|
+
archives stay flat with zero ceremony.
|
|
29
|
+
|
|
30
|
+
Writes preserve the **JSONL ⊇ SQLite** invariant: a row is staged on the session
|
|
31
|
+
and flushed **and fsynced** to its thread file *before* the COMMIT it belongs to, so
|
|
32
|
+
the projection can never hold a row the truth lacks — the truth append meets the
|
|
33
|
+
same durability bar as SQLite's own WAL commit (plain ``fsync``, the same call
|
|
34
|
+
SQLite issues; neither uses ``F_FULLFSYNC``). Each append batch is framed by a
|
|
35
|
+
drain-intent undo journal, so a power loss mid-batch is rolled back to the
|
|
36
|
+
pre-batch baselines on the next write (see the drain-intent section).
|
|
37
|
+
:func:`reindex` is the recovery
|
|
38
|
+
primitive — it rebuilds the SQLite store from the JSONL directory as a
|
|
39
|
+
**build-and-swap**: the new index is built in a temp file and atomically renamed
|
|
40
|
+
over ``index.db``, so a killed reindex leaves the old index fully intact; a
|
|
41
|
+
corrupted or deleted index is never a data-loss event.
|
|
42
|
+
:func:`rebuild_truth_from_store` is the inverse: it re-emits the whole per-thread
|
|
43
|
+
truth from the current store (used once to migrate an older monolithic
|
|
44
|
+
``events.jsonl`` into per-thread files).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import fcntl
|
|
50
|
+
import json
|
|
51
|
+
import logging
|
|
52
|
+
import os
|
|
53
|
+
import shutil
|
|
54
|
+
import sqlite3
|
|
55
|
+
import uuid
|
|
56
|
+
from collections import OrderedDict
|
|
57
|
+
from collections.abc import Generator
|
|
58
|
+
from contextlib import contextmanager
|
|
59
|
+
from datetime import datetime, timezone
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import TextIO
|
|
62
|
+
|
|
63
|
+
from sqlalchemy import DateTime, event, insert, select
|
|
64
|
+
from sqlalchemy.orm import Session
|
|
65
|
+
|
|
66
|
+
from .._store import (
|
|
67
|
+
Event,
|
|
68
|
+
ImportState,
|
|
69
|
+
KgEvent,
|
|
70
|
+
Thread,
|
|
71
|
+
ThreadLink,
|
|
72
|
+
TopicMessage,
|
|
73
|
+
build_engine,
|
|
74
|
+
get_engine,
|
|
75
|
+
get_session,
|
|
76
|
+
init_db,
|
|
77
|
+
use_engine,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
logger = logging.getLogger(__name__)
|
|
81
|
+
|
|
82
|
+
# Per-thread files live under truth/threads/; the cross-thread overlays are
|
|
83
|
+
# snapshot files (full-rewritten at checkpoint) since they aren't one thread's
|
|
84
|
+
# content. thread_links / topic_messages FK threads.id, so they load after threads.
|
|
85
|
+
THREADS_SUBDIR = "threads"
|
|
86
|
+
_CROSS_THREAD: dict[str, type] = {"thread_links": ThreadLink, "topic_messages": TopicMessage}
|
|
87
|
+
|
|
88
|
+
# The curatorial event log (see KgEvent): an append-only file of every topic/link/
|
|
89
|
+
# evidence mutation. It is the *source of truth* for the knowledge layer — the
|
|
90
|
+
# thread_links / topic_messages projections are folded from it on reindex. Any
|
|
91
|
+
# legacy _CROSS_THREAD snapshot is treated as a reindex seed the replay reconciles
|
|
92
|
+
# on top (upsert + tombstone), so the two coexist during the snapshot→log transition.
|
|
93
|
+
KG_EVENTS_FILE = "kg_events.jsonl"
|
|
94
|
+
|
|
95
|
+
# Flat until a directory would exceed this many files, then shard by id buckets.
|
|
96
|
+
_FLAT_MAX = int(os.environ.get("THREAD_ARCHIVE_SHARD_FLAT_MAX", "16384"))
|
|
97
|
+
_BUCKET = 256 # children per shard level
|
|
98
|
+
|
|
99
|
+
# Cap on simultaneously-open per-thread append handles (LRU-evicted). A bulk import
|
|
100
|
+
# usually touches one thread at a time, so the working set is tiny; the cap only
|
|
101
|
+
# bounds a pathological fan-out.
|
|
102
|
+
_MAX_OPEN_HANDLES = 256
|
|
103
|
+
_handles: "OrderedDict[str, TextIO]" = OrderedDict()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── config ──────────────────────────────────────────────────────────────────
|
|
107
|
+
def log_dir() -> Path:
|
|
108
|
+
"""The JSONL truth directory for this archive instance (``<home>/truth``)."""
|
|
109
|
+
from .._config import resolve_paths
|
|
110
|
+
|
|
111
|
+
return resolve_paths().truth_dir
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _now_iso() -> str:
|
|
115
|
+
return datetime.now(timezone.utc).isoformat()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fsync_dir(d: Path) -> None:
|
|
119
|
+
"""fsync a directory so a just-created (or just-renamed-in) file's dirent is
|
|
120
|
+
durable. Without it, a power loss can drop a fully-fsynced new file from the
|
|
121
|
+
directory while the SQLite commit that depended on it survives — index ⊃ truth,
|
|
122
|
+
the one direction the invariant forbids."""
|
|
123
|
+
try:
|
|
124
|
+
fd = os.open(d, os.O_RDONLY)
|
|
125
|
+
except OSError: # pragma: no cover — directory vanished / unreadable
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
os.fsync(fd)
|
|
129
|
+
except OSError: # pragma: no cover — fs doesn't support dir fsync
|
|
130
|
+
pass
|
|
131
|
+
finally:
|
|
132
|
+
os.close(fd)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── manifest (shard depth + last-checkpoint watermark) ───────────────────────
|
|
136
|
+
|
|
137
|
+
# The on-disk truth format version, recorded as ``version`` in manifest.json and
|
|
138
|
+
# specified in docs/format.md. Bump only for a change an existing reader would
|
|
139
|
+
# misinterpret (record shapes, file layout, sharding semantics) — additive
|
|
140
|
+
# optional fields don't count. Code that meets a *newer* version must refuse
|
|
141
|
+
# the archive rather than guess (see :func:`_read_manifest`).
|
|
142
|
+
TRUTH_FORMAT_VERSION = 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TruthFormatError(RuntimeError):
|
|
146
|
+
"""The truth directory declares a format version newer than this code reads."""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _manifest_path(d: Path) -> Path:
|
|
150
|
+
return d / "manifest.json"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _infer_shard_depth(d: Path) -> int:
|
|
154
|
+
"""Infer the shard depth from the directory shape — the fallback when the
|
|
155
|
+
manifest is unreadable. A sharded layout is unmistakable: ``threads/`` contains
|
|
156
|
+
two-hex-digit bucket directories (which may themselves contain buckets). Walking
|
|
157
|
+
the first bucket chain recovers the depth, so a corrupt or deleted manifest on a
|
|
158
|
+
sharded archive can never silently reset writers to the flat layout (which would
|
|
159
|
+
grow flat twins of sharded files and let stale metadata shadow fresh records on
|
|
160
|
+
reindex)."""
|
|
161
|
+
depth = 0
|
|
162
|
+
cur = d / THREADS_SUBDIR
|
|
163
|
+
while depth < 4: # bound: depths beyond 2 don't exist, but never loop unbounded
|
|
164
|
+
try:
|
|
165
|
+
bucket = next(
|
|
166
|
+
(
|
|
167
|
+
e for e in os.scandir(cur)
|
|
168
|
+
if e.is_dir() and len(e.name) == 2
|
|
169
|
+
and all(c in "0123456789abcdef" for c in e.name)
|
|
170
|
+
),
|
|
171
|
+
None,
|
|
172
|
+
)
|
|
173
|
+
except OSError:
|
|
174
|
+
break
|
|
175
|
+
if bucket is None:
|
|
176
|
+
break
|
|
177
|
+
depth += 1
|
|
178
|
+
cur = Path(bucket.path)
|
|
179
|
+
return depth
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _read_manifest(d: Path) -> dict:
|
|
183
|
+
p = _manifest_path(d)
|
|
184
|
+
if p.exists():
|
|
185
|
+
try:
|
|
186
|
+
m = json.loads(p.read_text(encoding="utf-8"))
|
|
187
|
+
except (OSError, ValueError):
|
|
188
|
+
# Corrupt manifest: fall through to defaults, but NEVER default the
|
|
189
|
+
# shard depth on a sharded archive — infer it from the layout below.
|
|
190
|
+
logger.error("truth: manifest.json unreadable — inferring shard depth")
|
|
191
|
+
else:
|
|
192
|
+
declared = m.get("version", TRUTH_FORMAT_VERSION)
|
|
193
|
+
if isinstance(declared, int) and declared > TRUTH_FORMAT_VERSION:
|
|
194
|
+
# A newer layout may have changed sharding or record semantics;
|
|
195
|
+
# reading it with old assumptions risks writing flat twins or
|
|
196
|
+
# shadowing fresh records — refuse instead of guessing.
|
|
197
|
+
raise TruthFormatError(
|
|
198
|
+
f"truth directory {d} declares format version {declared}, but this "
|
|
199
|
+
f"code reads version {TRUTH_FORMAT_VERSION}. Upgrade thread-archive "
|
|
200
|
+
"to a release that supports it."
|
|
201
|
+
)
|
|
202
|
+
return m
|
|
203
|
+
depth = _infer_shard_depth(d)
|
|
204
|
+
if depth:
|
|
205
|
+
logger.warning(
|
|
206
|
+
"truth: no readable manifest; inferred shard_depth=%d from layout", depth
|
|
207
|
+
)
|
|
208
|
+
return {"version": TRUTH_FORMAT_VERSION, "shard_depth": depth, "last_checkpoint_at": None}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _write_manifest(d: Path, m: dict) -> None:
|
|
212
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
# Per-process tmp name: manifest writers (watcher maintenance, a manual
|
|
214
|
+
# checkpoint, a backup) aren't otherwise serialized, and two processes
|
|
215
|
+
# sharing one tmp path would interleave writes and publish a torn manifest.
|
|
216
|
+
tmp = _manifest_path(d).with_name(f"manifest.json.tmp.{os.getpid()}")
|
|
217
|
+
try:
|
|
218
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
219
|
+
fh.write(json.dumps(m, indent=2))
|
|
220
|
+
fh.flush()
|
|
221
|
+
os.fsync(fh.fileno()) # durable before the rename makes it visible
|
|
222
|
+
os.replace(tmp, _manifest_path(d))
|
|
223
|
+
finally:
|
|
224
|
+
tmp.unlink(missing_ok=True) # no-op after the rename; clears a failed write
|
|
225
|
+
_fsync_dir(d) # the rename itself must survive power loss
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# Serializes manifest read-modify-write across processes. The manifest carries
|
|
229
|
+
# keys owned by different writers (shard_depth: checkpoint/rebalance; hashes
|
|
230
|
+
# baseline: verify), and concurrent whole-file replaces would lose whichever
|
|
231
|
+
# writer published first. Leaf lock: mutators run pure, nothing else is
|
|
232
|
+
# acquired while it is held, so it can nest inside any other lock safely.
|
|
233
|
+
MANIFEST_LOCK_FILE = ".manifest.lock"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _manifest_lock_path() -> Path:
|
|
237
|
+
from .._config import resolve_paths
|
|
238
|
+
|
|
239
|
+
return resolve_paths().home / MANIFEST_LOCK_FILE
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def update_manifest(d: Path, mutate) -> dict:
|
|
243
|
+
"""Atomically read-modify-write the manifest: ``mutate(m)`` edits the dict in
|
|
244
|
+
place under an exclusive flock, so no concurrent writer's keys are lost to a
|
|
245
|
+
stale read. Returns the manifest as written."""
|
|
246
|
+
path = _manifest_lock_path()
|
|
247
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
249
|
+
try:
|
|
250
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
251
|
+
m = _read_manifest(d)
|
|
252
|
+
mutate(m)
|
|
253
|
+
_write_manifest(d, m)
|
|
254
|
+
return m
|
|
255
|
+
finally:
|
|
256
|
+
os.close(fd) # closing the fd releases the flock
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _shard_depth(d: Path) -> int:
|
|
260
|
+
return int(_read_manifest(d).get("shard_depth", 0))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── per-thread file paths (sharded by recorded depth) ────────────────────────
|
|
264
|
+
def _thread_relpath(thread_id: int, depth: int) -> Path:
|
|
265
|
+
"""Relative path of a thread's file at ``depth``: flat (depth 0), else nested
|
|
266
|
+
id-bucket dirs (``<id%256>/...``). Deterministic — readers and writers compute
|
|
267
|
+
the same location from the manifest's depth."""
|
|
268
|
+
parts: list[str] = []
|
|
269
|
+
x = int(thread_id)
|
|
270
|
+
for _ in range(depth):
|
|
271
|
+
parts.append(f"{x % _BUCKET:02x}")
|
|
272
|
+
x //= _BUCKET
|
|
273
|
+
return Path(THREADS_SUBDIR, *parts, f"{int(thread_id)}.jsonl")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _thread_file(d: Path, thread_id: int, depth: int) -> Path:
|
|
277
|
+
return d / _thread_relpath(thread_id, depth)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _max_dir_occupancy(n_threads: int, depth: int) -> int:
|
|
281
|
+
"""Worst-case files in a single directory for ``n_threads`` spread over ``depth``
|
|
282
|
+
bucket levels (256 buckets/level, roughly uniform by id)."""
|
|
283
|
+
return -(-n_threads // (_BUCKET ** depth)) # ceil div
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _depth_for(n_threads: int) -> int:
|
|
287
|
+
depth = 0
|
|
288
|
+
while _max_dir_occupancy(n_threads, depth) > _FLAT_MAX:
|
|
289
|
+
depth += 1
|
|
290
|
+
return depth
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ── serialization ───────────────────────────────────────────────────────────
|
|
294
|
+
def _json_default(o: object) -> object:
|
|
295
|
+
if isinstance(o, datetime):
|
|
296
|
+
return o.isoformat()
|
|
297
|
+
if isinstance(o, (bytes, bytearray)):
|
|
298
|
+
return bytes(o).decode("utf-8", "replace")
|
|
299
|
+
return str(o)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _row_dict(obj: object) -> dict:
|
|
303
|
+
"""Every mapped column of an ORM row, by key — captures server-defaulted
|
|
304
|
+
columns (``recorded_at``) populated after flush, so the JSONL is faithful."""
|
|
305
|
+
return {c.key: getattr(obj, c.key) for c in obj.__table__.columns} # type: ignore[attr-defined]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _datetime_cols(model: type) -> set[str]:
|
|
309
|
+
return {c.key for c in model.__table__.columns if isinstance(c.type, DateTime)} # type: ignore[attr-defined]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _coerce(model: type, row: dict) -> dict:
|
|
313
|
+
"""Reverse :func:`_json_default` for reload: ISO strings → datetime so the
|
|
314
|
+
SQLite DateTime columns round-trip as the live writer wrote them. Keys not
|
|
315
|
+
mapped on the model are dropped, so a truth log written under an older schema
|
|
316
|
+
(columns since removed) still reloads cleanly into the current projection."""
|
|
317
|
+
valid = {c.key for c in model.__table__.columns}
|
|
318
|
+
row = {k: v for k, v in row.items() if k in valid}
|
|
319
|
+
for key in _datetime_cols(model):
|
|
320
|
+
val = row.get(key)
|
|
321
|
+
if isinstance(val, str):
|
|
322
|
+
row[key] = datetime.fromisoformat(val)
|
|
323
|
+
return row
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ── append handles (LRU, one per open thread file) ───────────────────────────
|
|
327
|
+
def _repair_torn_tail(path: Path) -> None:
|
|
328
|
+
"""Newline-terminate a file whose last append was torn (a crash mid-write).
|
|
329
|
+
|
|
330
|
+
Without this, the next append glues its record onto the torn fragment and the
|
|
331
|
+
combined line is unparseable — the torn fragment *consumes a valid event*, and
|
|
332
|
+
reindex skips both. Adding the newline first isolates the fragment as its own
|
|
333
|
+
(already unrecoverable) line so every later record stays intact. Same guard
|
|
334
|
+
:func:`_merge_file_into` applies at its boundary."""
|
|
335
|
+
try:
|
|
336
|
+
if path.stat().st_size == 0:
|
|
337
|
+
return
|
|
338
|
+
except FileNotFoundError:
|
|
339
|
+
return
|
|
340
|
+
with open(path, "rb+") as fh:
|
|
341
|
+
fh.seek(-1, os.SEEK_END)
|
|
342
|
+
if fh.read(1) != b"\n":
|
|
343
|
+
fh.write(b"\n")
|
|
344
|
+
fh.flush()
|
|
345
|
+
os.fsync(fh.fileno())
|
|
346
|
+
logger.warning("truth: repaired torn tail (newline-terminated) %s", path)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _same_inode(fh: TextIO, path: Path) -> bool:
|
|
350
|
+
"""True when the cached handle still writes the file at ``path``. A truth
|
|
351
|
+
re-emit (``rebuild_truth_from_store``) atomically replaces thread files, so a
|
|
352
|
+
handle cached across it points at the unlinked old inode — appends through it
|
|
353
|
+
vanish while their SQLite commits survive, the one direction the invariant
|
|
354
|
+
forbids. Two stats per append; the price of never writing to a dead file."""
|
|
355
|
+
try:
|
|
356
|
+
a = os.fstat(fh.fileno())
|
|
357
|
+
b = os.stat(path)
|
|
358
|
+
except (OSError, ValueError):
|
|
359
|
+
return False
|
|
360
|
+
return (a.st_dev, a.st_ino) == (b.st_dev, b.st_ino)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _handle(path: Path) -> TextIO:
|
|
364
|
+
key = str(path)
|
|
365
|
+
fh = _handles.get(key)
|
|
366
|
+
if fh is not None and not fh.closed and _same_inode(fh, path):
|
|
367
|
+
_handles.move_to_end(key)
|
|
368
|
+
return fh
|
|
369
|
+
if fh is not None and not fh.closed:
|
|
370
|
+
try:
|
|
371
|
+
fh.close() # stale inode — the file was replaced under us
|
|
372
|
+
except (OSError, ValueError): # pragma: no cover
|
|
373
|
+
pass
|
|
374
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
_repair_torn_tail(path) # never append onto a torn fragment
|
|
376
|
+
is_new = not path.exists()
|
|
377
|
+
fh = open(path, "a", encoding="utf-8") # noqa: SIM115 — long-lived, closed in reset()
|
|
378
|
+
if is_new:
|
|
379
|
+
_fsync_dir(path.parent) # the new file's dirent must be as durable as its rows
|
|
380
|
+
_handles[key] = fh
|
|
381
|
+
_handles.move_to_end(key)
|
|
382
|
+
while len(_handles) > _MAX_OPEN_HANDLES:
|
|
383
|
+
_, old = _handles.popitem(last=False)
|
|
384
|
+
try:
|
|
385
|
+
old.close()
|
|
386
|
+
except (OSError, ValueError): # pragma: no cover
|
|
387
|
+
pass
|
|
388
|
+
return fh
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _append_line(path: Path, rec: dict) -> None:
|
|
392
|
+
fh = _handle(path)
|
|
393
|
+
fh.write(json.dumps(rec, default=_json_default, ensure_ascii=False))
|
|
394
|
+
fh.write("\n")
|
|
395
|
+
fh.flush()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _fsync_handle(path: Path) -> None:
|
|
399
|
+
"""fsync a cached append handle so its flushed lines are durable on disk.
|
|
400
|
+
|
|
401
|
+
Callers batch: write every line of a logical unit via :func:`_append_line`
|
|
402
|
+
(flush-only), then fsync each touched file once — one fsync per file per
|
|
403
|
+
commit, not per line, keeps bulk imports fast while closing the power-loss
|
|
404
|
+
window between the OS page cache and the platter.
|
|
405
|
+
|
|
406
|
+
A batch touching more than ``_MAX_OPEN_HANDLES`` files LRU-evicts its early
|
|
407
|
+
handles before this runs; eviction close() flushes to the OS but does not
|
|
408
|
+
fsync, so an evicted file is reopened here and fsynced by fd — the durability
|
|
409
|
+
bar must not quietly drop for bulk batches. An OSError propagates (fail fast,
|
|
410
|
+
same as an fsync failure on a live handle)."""
|
|
411
|
+
fh = _handles.get(str(path))
|
|
412
|
+
if fh is not None and not fh.closed:
|
|
413
|
+
os.fsync(fh.fileno())
|
|
414
|
+
return
|
|
415
|
+
fd = os.open(path, os.O_RDONLY)
|
|
416
|
+
try:
|
|
417
|
+
os.fsync(fd)
|
|
418
|
+
finally:
|
|
419
|
+
os.close(fd)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def reset_handles() -> None:
|
|
423
|
+
"""Close cached append handles (tests / shutdown)."""
|
|
424
|
+
for fh in _handles.values():
|
|
425
|
+
try:
|
|
426
|
+
fh.close()
|
|
427
|
+
except (OSError, ValueError): # pragma: no cover — already closed / broken handle
|
|
428
|
+
pass
|
|
429
|
+
_handles.clear()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ── drain intent (crash framing for append batches) ──────────────────────────
|
|
433
|
+
# The all-or-nothing drain rollback handles *process* failure, but a power loss
|
|
434
|
+
# mid-batch leaves a partial batch in the truth with nothing to distinguish it
|
|
435
|
+
# from committed records. The intent file frames each batch as an undo journal:
|
|
436
|
+
# the drain durably records ``{txn, files: [{path, baseline, ids}]}`` BEFORE its
|
|
437
|
+
# first data write and empties the file after its last data fsync — both inside
|
|
438
|
+
# the truth-write mutex, so a non-empty intent is only ever observable after a
|
|
439
|
+
# crash. Recovery runs on every acquisition of the mutex (and at reindex start):
|
|
440
|
+
# it rolls the framed files back to their baselines, guarded two ways —
|
|
441
|
+
# * the committed-ids check: any of the batch's freshly-inserted ids present in
|
|
442
|
+
# the live index means the COMMIT landed (it was atomic), so a crash *after*
|
|
443
|
+
# COMMIT can never truncate committed truth (index ⊃ truth is the forbidden
|
|
444
|
+
# direction);
|
|
445
|
+
# * the tail check: everything past a file's baseline must be the framed
|
|
446
|
+
# batch's own records (a torn final fragment — the crash itself — is
|
|
447
|
+
# allowed). Anything else means another writer appended after the crash, and
|
|
448
|
+
# the file is left in place rather than risk cutting foreign records.
|
|
449
|
+
DRAIN_INTENT_FILE = ".drain.intent"
|
|
450
|
+
|
|
451
|
+
# Intent record kinds whose ids are fresh inserts in an index table — the basis
|
|
452
|
+
# of the committed-ids check. ``thread`` is deliberately absent: a metadata
|
|
453
|
+
# re-stage carries a thread id that pre-exists in the index whether or not the
|
|
454
|
+
# batch committed, so it can't witness the commit.
|
|
455
|
+
_INTENT_TABLES = {"event": "events", "kg_event": "kg_events"}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _intent_path() -> Path:
|
|
459
|
+
from .._config import resolve_paths
|
|
460
|
+
|
|
461
|
+
return resolve_paths().home / DRAIN_INTENT_FILE
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _write_intent(files: list[dict]) -> None:
|
|
465
|
+
"""Durably frame the batch about to be appended: fsynced BEFORE the first
|
|
466
|
+
data write, so a crash mid-batch always leaves the frame recovery needs to
|
|
467
|
+
identify — and remove — the partial batch."""
|
|
468
|
+
p = _intent_path()
|
|
469
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
existed = p.exists()
|
|
471
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
472
|
+
json.dump(
|
|
473
|
+
{"txn": uuid.uuid4().hex, "at": _now_iso(), "files": files},
|
|
474
|
+
fh, default=_json_default,
|
|
475
|
+
)
|
|
476
|
+
fh.flush()
|
|
477
|
+
os.fsync(fh.fileno())
|
|
478
|
+
if not existed:
|
|
479
|
+
_fsync_dir(p.parent) # the frame's dirent must be as durable as the frame
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _clear_intent() -> None:
|
|
483
|
+
"""Empty the intent after the batch's last data fsync, still under the write
|
|
484
|
+
mutex — an intent must never be visible outside its batch's lock hold. Not
|
|
485
|
+
fsynced: if a crash resurrects a cleared-but-complete intent, the
|
|
486
|
+
committed-ids check in recovery resolves it correctly."""
|
|
487
|
+
try:
|
|
488
|
+
with open(_intent_path(), "w", encoding="utf-8"):
|
|
489
|
+
pass
|
|
490
|
+
except OSError: # pragma: no cover — best-effort; recovery re-resolves it
|
|
491
|
+
logger.exception("truth: could not clear drain intent")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _intent_committed(files: list[dict]) -> bool | None:
|
|
495
|
+
"""Whether the framed batch's COMMIT landed. Any of its freshly-inserted ids
|
|
496
|
+
present in the live index means yes (the COMMIT was atomic — all or none).
|
|
497
|
+
``None`` = cannot tell (no insert ids in the batch, or no readable index),
|
|
498
|
+
which callers treat as committed: keeping possibly-uncommitted records is the
|
|
499
|
+
safe direction (dedup collapse bounds the damage), truncating possibly-
|
|
500
|
+
committed ones is the forbidden one."""
|
|
501
|
+
by_table: dict[str, list[int]] = {}
|
|
502
|
+
for f in files:
|
|
503
|
+
for kind, rid in f.get("ids") or []:
|
|
504
|
+
table = _INTENT_TABLES.get(kind)
|
|
505
|
+
if table is not None and rid is not None:
|
|
506
|
+
by_table.setdefault(table, []).append(int(rid))
|
|
507
|
+
if not by_table:
|
|
508
|
+
return None
|
|
509
|
+
db = get_engine().url.database
|
|
510
|
+
if not db or db == ":memory:":
|
|
511
|
+
return None
|
|
512
|
+
try:
|
|
513
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
514
|
+
except sqlite3.Error:
|
|
515
|
+
return None
|
|
516
|
+
try:
|
|
517
|
+
for table, ids in by_table.items():
|
|
518
|
+
probe = ids[:32] # one committed id proves the whole batch
|
|
519
|
+
try:
|
|
520
|
+
row = conn.execute(
|
|
521
|
+
f"SELECT 1 FROM {table} WHERE id IN ({','.join('?' * len(probe))}) LIMIT 1", # noqa: S608 — table from _INTENT_TABLES
|
|
522
|
+
probe,
|
|
523
|
+
).fetchone()
|
|
524
|
+
except sqlite3.Error:
|
|
525
|
+
return None
|
|
526
|
+
if row is not None:
|
|
527
|
+
return True
|
|
528
|
+
return False
|
|
529
|
+
finally:
|
|
530
|
+
conn.close()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _tail_is_intent_only(path: Path, baseline: int, allowed: set) -> bool:
|
|
534
|
+
"""True when everything past ``baseline`` is the framed batch's own records.
|
|
535
|
+
An unparseable FINAL fragment — the torn write of the crash itself — is
|
|
536
|
+
allowed; any other content means a writer appended after the crash and a
|
|
537
|
+
truncate would destroy records that aren't the batch's."""
|
|
538
|
+
try:
|
|
539
|
+
with open(path, "rb") as fh:
|
|
540
|
+
fh.seek(baseline)
|
|
541
|
+
chunks = fh.read().split(b"\n")
|
|
542
|
+
except OSError:
|
|
543
|
+
return False
|
|
544
|
+
for i, raw in enumerate(chunks):
|
|
545
|
+
if not raw.strip():
|
|
546
|
+
continue
|
|
547
|
+
try:
|
|
548
|
+
rec = json.loads(raw)
|
|
549
|
+
except ValueError:
|
|
550
|
+
return i == len(chunks) - 1 # torn final fragment only
|
|
551
|
+
if (rec.get("type", "event"), rec.get("id")) not in allowed:
|
|
552
|
+
return False
|
|
553
|
+
return True
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _recover_crashed_drain() -> None:
|
|
557
|
+
"""Resolve a leftover drain intent — the mark of a process that died (power
|
|
558
|
+
loss, SIGKILL) mid-batch. Runs under the truth-write mutex on every
|
|
559
|
+
acquisition: a live drain's intent never outlives its lock hold, so anything
|
|
560
|
+
found here is a dead process's. Uncommitted framed files are rolled back to
|
|
561
|
+
their baselines (the crash-durable twin of the drain's in-process rollback);
|
|
562
|
+
a committed or undecidable batch keeps its records. Never raises — recovery
|
|
563
|
+
is a repair pass, and a bug in it must not take writes down."""
|
|
564
|
+
p = _intent_path()
|
|
565
|
+
try:
|
|
566
|
+
raw = p.read_text(encoding="utf-8")
|
|
567
|
+
except OSError:
|
|
568
|
+
return
|
|
569
|
+
if not raw.strip():
|
|
570
|
+
return
|
|
571
|
+
try:
|
|
572
|
+
intent = json.loads(raw)
|
|
573
|
+
files = list(intent["files"])
|
|
574
|
+
except (ValueError, KeyError, TypeError):
|
|
575
|
+
# A torn intent write: the crash hit before any data append (the intent
|
|
576
|
+
# is durable before the first write), so the truth is untouched.
|
|
577
|
+
logger.warning("truth: discarding torn drain intent (no data was written)")
|
|
578
|
+
_clear_intent()
|
|
579
|
+
return
|
|
580
|
+
try:
|
|
581
|
+
committed = _intent_committed(files)
|
|
582
|
+
if committed is not False:
|
|
583
|
+
logger.warning(
|
|
584
|
+
"truth: drain intent txn=%s survived a crash but its commit %s — keeping its records",
|
|
585
|
+
intent.get("txn"), "landed" if committed else "cannot be determined",
|
|
586
|
+
)
|
|
587
|
+
_clear_intent()
|
|
588
|
+
return
|
|
589
|
+
d = log_dir()
|
|
590
|
+
rolled_back = kept = 0
|
|
591
|
+
for f in files:
|
|
592
|
+
path = d / f["path"]
|
|
593
|
+
baseline = f.get("baseline")
|
|
594
|
+
allowed = {(k, i) for k, i in f.get("ids") or []}
|
|
595
|
+
try:
|
|
596
|
+
size = path.stat().st_size
|
|
597
|
+
except OSError:
|
|
598
|
+
continue # never created (crash before its first append) or already gone
|
|
599
|
+
if baseline is not None and size <= baseline:
|
|
600
|
+
continue # nothing of the batch landed here
|
|
601
|
+
if not _tail_is_intent_only(path, baseline or 0, allowed):
|
|
602
|
+
kept += 1
|
|
603
|
+
logger.error(
|
|
604
|
+
"truth: crashed drain tail of %s holds records outside its intent — left in place",
|
|
605
|
+
path,
|
|
606
|
+
)
|
|
607
|
+
continue
|
|
608
|
+
fh = _handles.pop(str(path), None)
|
|
609
|
+
if fh is not None and not fh.closed:
|
|
610
|
+
fh.close()
|
|
611
|
+
if baseline is None:
|
|
612
|
+
path.unlink(missing_ok=True) # the batch created it — no ghost thread file
|
|
613
|
+
_fsync_dir(path.parent)
|
|
614
|
+
else:
|
|
615
|
+
with open(path, "rb+") as rb:
|
|
616
|
+
rb.truncate(baseline)
|
|
617
|
+
os.fsync(rb.fileno())
|
|
618
|
+
rolled_back += 1
|
|
619
|
+
logger.warning(
|
|
620
|
+
"truth: recovered crashed drain txn=%s — %d file(s) rolled back to baseline%s",
|
|
621
|
+
intent.get("txn"), rolled_back,
|
|
622
|
+
f", {kept} left for inspection" if kept else "",
|
|
623
|
+
)
|
|
624
|
+
except Exception: # pragma: no cover — recovery must never block writes
|
|
625
|
+
logger.exception("truth: crashed-drain recovery failed; intent cleared")
|
|
626
|
+
_clear_intent()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# ── truth-write mutex (append batches are mutually exclusive across processes) ─
|
|
630
|
+
# The reindex lock is *shared* among writers, so two processes can append to the
|
|
631
|
+
# same truth file concurrently — a JSON line larger than one write(2) can interleave
|
|
632
|
+
# with another writer's line (corrupting both), and the drain-failure rollback
|
|
633
|
+
# truncates to a baseline that would chop records another process appended in
|
|
634
|
+
# between. This exclusive flock makes each append batch (a drain, a checkpoint
|
|
635
|
+
# backstop pass) atomic with respect to other writers. Held for milliseconds;
|
|
636
|
+
# writers are few. flock auto-releases on process death.
|
|
637
|
+
TRUTH_WRITE_LOCK_FILE = ".truthwrite.lock"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _truth_write_lock_path() -> Path:
|
|
641
|
+
from .._config import resolve_paths
|
|
642
|
+
|
|
643
|
+
return resolve_paths().home / TRUTH_WRITE_LOCK_FILE
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@contextmanager
|
|
647
|
+
def _truth_write_lock() -> Generator[None, None, None]:
|
|
648
|
+
path = _truth_write_lock_path()
|
|
649
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
650
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
651
|
+
try:
|
|
652
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
653
|
+
# A leftover intent is a dead process's crashed batch — resolve it before
|
|
654
|
+
# this holder appends (or moves files) on top of the partial tail.
|
|
655
|
+
_recover_crashed_drain()
|
|
656
|
+
yield
|
|
657
|
+
finally:
|
|
658
|
+
os.close(fd) # closing the fd releases the flock
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ── staged writes (durable per-thread append BEFORE the SQLite commit) ───────
|
|
662
|
+
# "JSONL is truth" requires the invariant **JSONL ⊇ SQLite**: anything the
|
|
663
|
+
# projection has must already be in the truth, never the reverse. A write doesn't
|
|
664
|
+
# touch the file directly — it stages (kind, thread_id, row) on the session, and a
|
|
665
|
+
# global ``before_commit`` listener flushes each staged record to its thread file
|
|
666
|
+
# *before* the COMMIT. A raise there aborts the commit (fail fast — never commit a
|
|
667
|
+
# projection we couldn't make durable as truth). If the COMMIT then fails after the
|
|
668
|
+
# flush, the truth holds a row SQLite lacks — the safe direction: reindex rebuilds
|
|
669
|
+
# it, and dedup_key collapses any re-import. ``after_rollback`` discards the buffer.
|
|
670
|
+
_PENDING = "_jsonl_pending"
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _stage(session: Session, kind: str, thread_id: int, row: dict) -> None:
|
|
674
|
+
session.info.setdefault(_PENDING, []).append((kind, int(thread_id), row))
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def append_event_row(session: Session, event_row: object) -> None:
|
|
678
|
+
"""Stage an event for its thread's truth file. Written only if the session commits."""
|
|
679
|
+
_stage(session, "event", event_row.thread_id, _row_dict(event_row)) # type: ignore[attr-defined]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def record_thread(session: Session, thread: object) -> None:
|
|
683
|
+
"""Stage a thread's metadata record for its truth file (written on commit).
|
|
684
|
+
|
|
685
|
+
The metadata-write seam: called at thread creation so each thread file opens
|
|
686
|
+
with its ``{"type": "thread", ...}`` record. A later metadata change can re-stage
|
|
687
|
+
(latest wins on reindex); :func:`checkpoint` is the backstop for updates."""
|
|
688
|
+
_stage(session, "thread", thread.id, _row_dict(thread)) # type: ignore[attr-defined]
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def unstage_thread(session: Session, thread_id: int) -> None:
|
|
692
|
+
"""Discard any staged truth rows for ``thread_id`` (its metadata record and any
|
|
693
|
+
events) from the session's pending buffer — the complement of the staging seam
|
|
694
|
+
for the discard-an-empty-thread path. A thread row deleted before its commit
|
|
695
|
+
must ALSO drop its staged record: otherwise the drain still writes a
|
|
696
|
+
``threads/<id>.jsonl`` the projection no longer holds, ``verify`` drifts, and
|
|
697
|
+
the next reindex resurrects the thread as an empty ghost. Cross-thread rows
|
|
698
|
+
(kind ``kg_event``) are untouched."""
|
|
699
|
+
pending = session.info.get(_PENDING)
|
|
700
|
+
if not pending:
|
|
701
|
+
return
|
|
702
|
+
tid = int(thread_id)
|
|
703
|
+
session.info[_PENDING] = [
|
|
704
|
+
(kind, t, row) for kind, t, row in pending
|
|
705
|
+
if not (t == tid and kind in ("thread", "event"))
|
|
706
|
+
]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def append_kg_event(session: Session, kg_event: object) -> None:
|
|
710
|
+
"""Stage a curatorial event for the append-only ``kg_events.jsonl`` truth log.
|
|
711
|
+
|
|
712
|
+
The knowledge-layer write seam (mirrors :func:`append_event_row` for the
|
|
713
|
+
conversation log): a librarian write flushes the ``KgEvent`` (so ``id`` /
|
|
714
|
+
``recorded_at`` are populated), stages it here, and the row is appended to the
|
|
715
|
+
single ``kg_events.jsonl`` file before the COMMIT it belongs to — keeping the
|
|
716
|
+
JSONL ⊇ SQLite invariant for curation. ``thread_id`` is irrelevant for the
|
|
717
|
+
cross-thread log (it routes to one file, not a per-thread file), so pass 0."""
|
|
718
|
+
_stage(session, "kg_event", 0, _row_dict(kg_event))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def write_events(session: Session, events: list[Event]) -> list[Event]:
|
|
722
|
+
"""Insert events into SQLite *and* stage them for their threads' truth files.
|
|
723
|
+
|
|
724
|
+
The single seam so the projection and the truth never diverge: each row is
|
|
725
|
+
flushed to its thread file before the COMMIT it belongs to. ``flush`` populates
|
|
726
|
+
the autoincrement id and the server-default ``recorded_at`` before the row dict
|
|
727
|
+
is snapshotted, so the truth line is faithful. The caller owns the commit."""
|
|
728
|
+
session.add_all(events)
|
|
729
|
+
session.flush()
|
|
730
|
+
for ev in events:
|
|
731
|
+
append_event_row(session, ev)
|
|
732
|
+
return events
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@event.listens_for(Session, "before_commit")
|
|
736
|
+
def _drain_before_commit(session: Session) -> None:
|
|
737
|
+
# Flush staged truth rows to their thread files *before* the COMMIT, so a row can
|
|
738
|
+
# never land in SQLite without already being in the truth (a raise here aborts
|
|
739
|
+
# the commit). pop() so a second commit on the same session doesn't re-append.
|
|
740
|
+
# Each touched file is fsynced once after its lines are written: the COMMIT that
|
|
741
|
+
# follows is itself fsynced by SQLite, so without this the projection could
|
|
742
|
+
# survive a power loss that the truth doesn't — the one direction the invariant
|
|
743
|
+
# forbids.
|
|
744
|
+
#
|
|
745
|
+
# The drain is all-or-nothing per commit: each file's pre-drain size is recorded
|
|
746
|
+
# and a failure partway (write error, unserializable row, failed fsync) truncates
|
|
747
|
+
# every touched file back before re-raising. Without the rollback, records 1…N−1
|
|
748
|
+
# of a failed batch would stay in the truth while SQLite rolls back — a later
|
|
749
|
+
# reindex would resurrect the partial batch, and a retried import would re-append
|
|
750
|
+
# the same content under fresh ids (the dedup check reads the rolled-back SQLite),
|
|
751
|
+
# doubling events. The same guarantee against a *power loss* mid-drain comes from
|
|
752
|
+
# the intent frame (see the drain-intent section): the batch's files, baselines
|
|
753
|
+
# and ids are durable before its first data write, so recovery can roll a partial
|
|
754
|
+
# batch back to the same baselines the in-process rollback uses.
|
|
755
|
+
pending = session.info.pop(_PENDING, None)
|
|
756
|
+
if not pending:
|
|
757
|
+
return
|
|
758
|
+
d = log_dir()
|
|
759
|
+
with _truth_write_lock():
|
|
760
|
+
# Depth read under the lock, so a rebalance that bumped it between our
|
|
761
|
+
# staging and this drain can't leave us appending at a stale layout.
|
|
762
|
+
depth = _shard_depth(d)
|
|
763
|
+
staged: list[tuple[Path, dict]] = []
|
|
764
|
+
for kind, thread_id, row in pending:
|
|
765
|
+
path = d / KG_EVENTS_FILE if kind == "kg_event" else _thread_file(d, thread_id, depth)
|
|
766
|
+
staged.append((path, {"type": kind, **row}))
|
|
767
|
+
# Baselines (and open handles) for every touched file BEFORE any append,
|
|
768
|
+
# so the intent frame describes the whole batch up front.
|
|
769
|
+
baselines: dict[Path, int | None] = {} # pre-drain size; None = file didn't exist
|
|
770
|
+
ids_by_path: dict[Path, list] = {}
|
|
771
|
+
for path, rec in staged:
|
|
772
|
+
if path not in baselines:
|
|
773
|
+
existed = path.exists()
|
|
774
|
+
fh = _handle(path) # may create the file, and may newline-repair a torn tail
|
|
775
|
+
fh.flush()
|
|
776
|
+
# Post-repair size, so a rollback keeps the repair in place.
|
|
777
|
+
baselines[path] = path.stat().st_size if existed else None
|
|
778
|
+
ids_by_path.setdefault(path, []).append([rec["type"], rec.get("id")])
|
|
779
|
+
_write_intent([
|
|
780
|
+
{"path": str(path.relative_to(d)), "baseline": size, "ids": ids_by_path[path]}
|
|
781
|
+
for path, size in baselines.items()
|
|
782
|
+
]) # durable BEFORE the first data write
|
|
783
|
+
try:
|
|
784
|
+
for path, rec in staged:
|
|
785
|
+
_append_line(path, rec)
|
|
786
|
+
for path in baselines:
|
|
787
|
+
_fsync_handle(path)
|
|
788
|
+
except BaseException:
|
|
789
|
+
# Truncate-to-baseline is safe under the write lock: no other writer
|
|
790
|
+
# can have appended to these files since the baseline was taken.
|
|
791
|
+
# Each undo is fsynced: the intent clear below is not, so an
|
|
792
|
+
# un-durable truncate surviving a power loss alongside a durably
|
|
793
|
+
# cleared intent would resurrect the partial batch with no frame
|
|
794
|
+
# left to roll it back.
|
|
795
|
+
for path, size in baselines.items():
|
|
796
|
+
try:
|
|
797
|
+
fh = _handles.pop(str(path), None)
|
|
798
|
+
if fh is not None and not fh.closed:
|
|
799
|
+
fh.close() # drop any buffered partial write with the handle
|
|
800
|
+
if size is None:
|
|
801
|
+
path.unlink(missing_ok=True) # we created it — no ghost thread file
|
|
802
|
+
_fsync_dir(path.parent)
|
|
803
|
+
else:
|
|
804
|
+
with open(path, "rb+") as rb:
|
|
805
|
+
rb.truncate(size)
|
|
806
|
+
os.fsync(rb.fileno())
|
|
807
|
+
except OSError: # pragma: no cover — rollback is best-effort
|
|
808
|
+
logger.exception("truth: could not roll back partial append to %s", path)
|
|
809
|
+
_clear_intent() # the batch is undone; the frame must not outlive it
|
|
810
|
+
raise
|
|
811
|
+
_clear_intent() # inside the lock: an intent is never visible outside its batch
|
|
812
|
+
# Remember what this drain appended (per file: baseline → post-drain size)
|
|
813
|
+
# so a COMMIT failure *after* the drain can compensate (see
|
|
814
|
+
# _undo_drain_on_rollback). Cleared on successful commit.
|
|
815
|
+
session.info[_DRAINED] = {
|
|
816
|
+
path: (size, path.stat().st_size) for path, size in baselines.items()
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
# A successful drain's footprint, kept on the session until its COMMIT lands:
|
|
821
|
+
# {path: (baseline_size_or_None, post_drain_size)}. The compensation seam for
|
|
822
|
+
# the one atomicity gap the drain leaves open — truth is written and fsynced
|
|
823
|
+
# before the SQLite COMMIT, so a COMMIT that then fails (disk full, a
|
|
824
|
+
# constraint enforced at the final flush, busy timeout) leaves the batch in the
|
|
825
|
+
# truth with no projection row. That is the *safe* direction (JSONL ⊇ SQLite),
|
|
826
|
+
# but it isn't atomic: a later reindex materializes the uncommitted batch.
|
|
827
|
+
_DRAINED = "_jsonl_drained"
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@event.listens_for(Session, "after_commit")
|
|
831
|
+
def _forget_drain_on_commit(session: Session) -> None:
|
|
832
|
+
session.info.pop(_DRAINED, None)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@event.listens_for(Session, "after_rollback")
|
|
836
|
+
def _discard_on_rollback(session: Session) -> None:
|
|
837
|
+
session.info.pop(_PENDING, None)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
@event.listens_for(Session, "after_transaction_end")
|
|
841
|
+
def _compensate_failed_commit(session: Session, transaction) -> None:
|
|
842
|
+
# The root transaction ending with the drain footprint still present means
|
|
843
|
+
# the COMMIT never landed (a successful one pops it in after_commit, which
|
|
844
|
+
# fires first). after_transaction_end — not after_rollback — is the hook
|
|
845
|
+
# that actually fires on every such path: a commit that raises mid-flight
|
|
846
|
+
# ends via the session context manager's close(), which never emits
|
|
847
|
+
# after_rollback.
|
|
848
|
+
if transaction.parent is None and _DRAINED in session.info:
|
|
849
|
+
_undo_drain(session)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _undo_drain(session: Session) -> None:
|
|
853
|
+
"""Best-effort compensation when a COMMIT failed after its drain succeeded:
|
|
854
|
+
truncate each touched truth file back to its pre-drain baseline, so the
|
|
855
|
+
truth doesn't keep a transaction SQLite rejected (which reindex would
|
|
856
|
+
otherwise resurrect, and which — for metadata records — carries no fresh
|
|
857
|
+
event id for recovery to reconcile against).
|
|
858
|
+
|
|
859
|
+
Only provably-safe undos run: under the exclusive truth-write lock, a file
|
|
860
|
+
is rolled back only if its current size still equals the drain's post-drain
|
|
861
|
+
size — any other writer's append since (sizes differ) leaves the file
|
|
862
|
+
alone, degrading to the documented JSONL ⊇ SQLite behavior. Failure here is
|
|
863
|
+
logged, never raised: the transaction is already over and the leftover
|
|
864
|
+
truth rows are the benign direction (dedup collapses a re-import)."""
|
|
865
|
+
drained: dict[Path, tuple[int | None, int]] | None = session.info.pop(_DRAINED, None)
|
|
866
|
+
if not drained:
|
|
867
|
+
return
|
|
868
|
+
try:
|
|
869
|
+
with _truth_write_lock():
|
|
870
|
+
for path, (baseline, post_size) in drained.items():
|
|
871
|
+
try:
|
|
872
|
+
if not path.exists() or path.stat().st_size != post_size:
|
|
873
|
+
continue # someone else appended (or repaired) — leave it
|
|
874
|
+
fh = _handles.pop(str(path), None)
|
|
875
|
+
if fh is not None and not fh.closed:
|
|
876
|
+
fh.close()
|
|
877
|
+
if baseline is None:
|
|
878
|
+
path.unlink(missing_ok=True) # the drain created it
|
|
879
|
+
_fsync_dir(path.parent)
|
|
880
|
+
else:
|
|
881
|
+
with open(path, "rb+") as rb:
|
|
882
|
+
rb.truncate(baseline)
|
|
883
|
+
os.fsync(rb.fileno())
|
|
884
|
+
except OSError: # pragma: no cover — compensation is best-effort
|
|
885
|
+
logger.exception(
|
|
886
|
+
"truth: could not undo drained batch in %s after rollback", path
|
|
887
|
+
)
|
|
888
|
+
except OSError: # pragma: no cover — lock unavailable; leave the safe direction
|
|
889
|
+
logger.exception("truth: post-rollback drain compensation skipped")
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
# ── checkpoint (cross-thread snapshots + metadata-update backstop) ───────────
|
|
893
|
+
def _write_snapshot(d: Path, name: str, model: type) -> int:
|
|
894
|
+
"""Atomically rewrite ``<name>.jsonl`` from the live table (tmp + rename)."""
|
|
895
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
896
|
+
path = d / f"{name}.jsonl"
|
|
897
|
+
tmp = path.with_suffix(".jsonl.tmp")
|
|
898
|
+
with get_session() as s, open(tmp, "w", encoding="utf-8") as fh:
|
|
899
|
+
n = 0
|
|
900
|
+
for obj in s.execute(select(model)).scalars():
|
|
901
|
+
fh.write(json.dumps(_row_dict(obj), default=_json_default, ensure_ascii=False))
|
|
902
|
+
fh.write("\n")
|
|
903
|
+
n += 1
|
|
904
|
+
fh.flush()
|
|
905
|
+
os.fsync(fh.fileno()) # durable before the rename makes it visible
|
|
906
|
+
os.replace(tmp, path)
|
|
907
|
+
_fsync_dir(d) # the rename itself must survive power loss
|
|
908
|
+
return n
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _checkpoint_changed_threads(d: Path, depth: int, last_iso: str | None) -> int:
|
|
912
|
+
"""Backstop for thread *metadata updates*: append a fresh thread record for any
|
|
913
|
+
thread whose ``updated_at`` advanced since the last checkpoint. New threads are
|
|
914
|
+
already recorded at creation (:func:`record_thread`), so the first checkpoint
|
|
915
|
+
(no watermark) writes nothing — it just sets the watermark."""
|
|
916
|
+
if last_iso is None:
|
|
917
|
+
return 0
|
|
918
|
+
last_dt = datetime.fromisoformat(last_iso)
|
|
919
|
+
n = 0
|
|
920
|
+
touched: set[Path] = set()
|
|
921
|
+
with get_session() as s:
|
|
922
|
+
changed = s.execute(select(Thread).where(Thread.updated_at > last_dt)).scalars().all()
|
|
923
|
+
with _truth_write_lock(): # append batches are mutually exclusive across writers
|
|
924
|
+
for t in changed:
|
|
925
|
+
path = _thread_file(d, t.id, depth)
|
|
926
|
+
_append_line(path, {"type": "thread", **_row_dict(t)})
|
|
927
|
+
touched.add(path)
|
|
928
|
+
n += 1
|
|
929
|
+
for path in touched:
|
|
930
|
+
_fsync_handle(path)
|
|
931
|
+
return n
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def checkpoint(*, snapshots: bool = True) -> dict:
|
|
935
|
+
"""Persist the cross-thread overlays + any thread-metadata updates, and keep the
|
|
936
|
+
shard layout balanced. Per-thread event/metadata records are written inline on
|
|
937
|
+
commit; this is the cadence call (and pre-backup call) that makes the directory
|
|
938
|
+
a complete restore set.
|
|
939
|
+
|
|
940
|
+
``snapshots=False`` runs **maintenance only** — the manifest watermark, the
|
|
941
|
+
thread-metadata-update backstop, the (small) ``import_state`` snapshot, and
|
|
942
|
+
shard rebalance — and skips the full
|
|
943
|
+
rewrite of the cross-thread overlay files (``thread_links`` / ``topic_messages``).
|
|
944
|
+
That rewrite is O(graph size) and the live-ingest path never changes those tables
|
|
945
|
+
(conversation import only appends events; the overlays are the migration seed plus
|
|
946
|
+
the append-only ``kg_events`` log), so the watcher uses the cheap maintenance form
|
|
947
|
+
on its cadence instead of rewriting tens of MB every few seconds. The full form
|
|
948
|
+
(overlays included) is for explicit pre-backup / pre-reindex / post-curation use."""
|
|
949
|
+
d = log_dir()
|
|
950
|
+
(d / THREADS_SUBDIR).mkdir(parents=True, exist_ok=True)
|
|
951
|
+
m = _read_manifest(d)
|
|
952
|
+
counts: dict = (
|
|
953
|
+
{name: _write_snapshot(d, name, model) for name, model in _CROSS_THREAD.items()}
|
|
954
|
+
if snapshots
|
|
955
|
+
else {}
|
|
956
|
+
)
|
|
957
|
+
# Source-import watermarks: operational state, snapshotted so a reindex of a
|
|
958
|
+
# lost/deleted index (where there is no previous index to carry them from)
|
|
959
|
+
# still restores cursors instead of adopting active sources at EOF. A stale
|
|
960
|
+
# snapshot is safe (import resumes from the older watermark and dedup_key
|
|
961
|
+
# collapses the overlap), but a *fresh* one keeps the regression window at
|
|
962
|
+
# minutes instead of a backup cycle — and the table is small, so unlike the
|
|
963
|
+
# cross-thread overlays it is snapshotted on the maintenance cadence too.
|
|
964
|
+
counts["import_state"] = _write_snapshot(d, "import_state", ImportState)
|
|
965
|
+
# Rebalance BEFORE the thread-metadata backstop, so the backstop appends at the
|
|
966
|
+
# post-rebalance depth and can never manufacture a flat twin of a just-moved file.
|
|
967
|
+
depth = _maybe_rebalance(d, int(m.get("shard_depth", 0)))
|
|
968
|
+
# Re-read the manifest: a concurrent sweep (ours skips when the rebalance lock is
|
|
969
|
+
# held) may have advanced shard_depth — never write a stale depth back over it.
|
|
970
|
+
m = _read_manifest(d)
|
|
971
|
+
depth = max(depth, int(m.get("shard_depth", 0)))
|
|
972
|
+
# The new watermark is captured BEFORE the changed-threads query: a thread
|
|
973
|
+
# updated between the query and a later stamp would fall below the stamp and
|
|
974
|
+
# be missed by every later backstop pass. Captured-first, such an update is
|
|
975
|
+
# re-scanned next pass; the worst case is a harmless re-appended thread record
|
|
976
|
+
# (latest-wins). Updates whose transaction is still uncommitted when the query
|
|
977
|
+
# runs are the primary seam's job — every in-tree metadata writer also stages
|
|
978
|
+
# its record inline (``record_thread``); this pass is only the backstop.
|
|
979
|
+
checkpoint_started_at = _now_iso()
|
|
980
|
+
counts["threads_updated"] = _checkpoint_changed_threads(d, depth, m.get("last_checkpoint_at"))
|
|
981
|
+
# Locked read-modify-write: the backstop pass takes time, and the manifest
|
|
982
|
+
# is shared state (shard depth from a concurrent sweep, a verify run's
|
|
983
|
+
# hashes baseline). Mutating only this checkpoint's own keys under the
|
|
984
|
+
# manifest lock means a foreign key written mid-pass survives.
|
|
985
|
+
|
|
986
|
+
def _stamp(m: dict) -> None:
|
|
987
|
+
m["shard_depth"] = max(depth, int(m.get("shard_depth", 0)))
|
|
988
|
+
m["last_checkpoint_at"] = checkpoint_started_at
|
|
989
|
+
|
|
990
|
+
update_manifest(d, _stamp)
|
|
991
|
+
logger.info("jsonl_log checkpoint(snapshots=%s): %s", snapshots, counts)
|
|
992
|
+
return counts
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
# ── adaptive rebalance (flat → sharded when a directory gets large) ──────────
|
|
996
|
+
# Serializes the move/merge sweep across processes (the watcher's maintenance pass
|
|
997
|
+
# vs. the daily backup's checkpoint): flock, non-blocking — the loser skips, and
|
|
998
|
+
# whichever checkpoint runs next finishes the job. Deliberately distinct from the
|
|
999
|
+
# reindex lock: the watcher holds THAT one shared around its whole ingest pass, so
|
|
1000
|
+
# an exclusive acquire on it here would self-deadlock.
|
|
1001
|
+
REBALANCE_LOCK_FILE = ".rebalance.lock"
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _rebalance_lock_path() -> Path:
|
|
1005
|
+
from .._config import resolve_paths
|
|
1006
|
+
|
|
1007
|
+
return resolve_paths().home / REBALANCE_LOCK_FILE
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
@contextmanager
|
|
1011
|
+
def _try_rebalance_lock() -> Generator[bool, None, None]:
|
|
1012
|
+
"""Hold the rebalance lock exclusive, non-blocking. Yields False (not holding)
|
|
1013
|
+
when another process is mid-sweep. flock auto-releases on process death — a
|
|
1014
|
+
killed sweep can never wedge the next one."""
|
|
1015
|
+
path = _rebalance_lock_path()
|
|
1016
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1017
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
1018
|
+
try:
|
|
1019
|
+
try:
|
|
1020
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1021
|
+
except OSError:
|
|
1022
|
+
yield False
|
|
1023
|
+
return
|
|
1024
|
+
yield True
|
|
1025
|
+
finally:
|
|
1026
|
+
os.close(fd) # closing the fd releases the flock
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _merge_file_into(src: Path, dest: Path) -> None:
|
|
1030
|
+
"""Append ``src``'s lines onto ``dest`` (fsynced), then remove ``src`` — the
|
|
1031
|
+
no-clobber move for a file whose home already exists.
|
|
1032
|
+
|
|
1033
|
+
Safe at every boundary: if ``dest`` ends in a torn line (a crash mid-append),
|
|
1034
|
+
a newline is inserted first so the fragment stays its own unparseable line
|
|
1035
|
+
instead of gluing onto ``src``'s first record. Duplicate lines from a crash
|
|
1036
|
+
between copy and unlink are harmless — event lines are id-keyed (reindex's
|
|
1037
|
+
INSERT OR REPLACE collapses them) and thread records are latest-wins. The
|
|
1038
|
+
copy re-checks ``src``'s size before unlinking so lines a racing pre-bump
|
|
1039
|
+
commit appended mid-copy are drained, not dropped."""
|
|
1040
|
+
with open(dest, "ab") as df:
|
|
1041
|
+
if os.stat(dest).st_size > 0:
|
|
1042
|
+
with open(dest, "rb") as ck:
|
|
1043
|
+
ck.seek(-1, os.SEEK_END)
|
|
1044
|
+
if ck.read(1) != b"\n":
|
|
1045
|
+
df.write(b"\n")
|
|
1046
|
+
with open(src, "rb") as sf:
|
|
1047
|
+
while True:
|
|
1048
|
+
shutil.copyfileobj(sf, df)
|
|
1049
|
+
df.flush()
|
|
1050
|
+
if sf.tell() >= os.stat(src).st_size:
|
|
1051
|
+
break # nothing landed on src during the copy
|
|
1052
|
+
os.fsync(df.fileno())
|
|
1053
|
+
os.unlink(src)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _maybe_rebalance(d: Path, depth: int) -> int:
|
|
1057
|
+
"""Keep the shard layout balanced — crash-safe, merge-only, serialized.
|
|
1058
|
+
|
|
1059
|
+
Three properties make a killed or concurrent sweep unable to lose truth:
|
|
1060
|
+
|
|
1061
|
+
* **Manifest first.** Crossing a threshold persists the new ``shard_depth``
|
|
1062
|
+
*before* any file moves. Writers re-read the manifest on every commit, so
|
|
1063
|
+
they immediately compute paths in the final layout — a crash mid-sweep
|
|
1064
|
+
leaves only not-yet-moved files, never a growing flat twin of an
|
|
1065
|
+
already-moved one.
|
|
1066
|
+
* **Merge, never clobber.** A file whose destination already exists (a twin
|
|
1067
|
+
left by a killed sweep, or by a commit that raced the manifest bump) is
|
|
1068
|
+
appended onto it via :func:`_merge_file_into` — recombined, not replaced.
|
|
1069
|
+
* **Straggler sweep.** Once sharded (``depth > 0``), every call re-homes any
|
|
1070
|
+
misplaced file, so an interrupted migration is finished by the next
|
|
1071
|
+
checkpoint rather than waiting on a threshold that will never re-fire.
|
|
1072
|
+
|
|
1073
|
+
The sweep skips (without moving anything) while a reindex build holds the
|
|
1074
|
+
ingest lock exclusive, and runs under its own non-blocking exclusive flock so
|
|
1075
|
+
two checkpointing processes can't interleave moves — the loser returns the
|
|
1076
|
+
depth it was given and its caller re-reads the manifest rather than writing a
|
|
1077
|
+
stale depth back. Returns the effective shard depth."""
|
|
1078
|
+
threads_dir = d / THREADS_SUBDIR
|
|
1079
|
+
if not threads_dir.exists():
|
|
1080
|
+
return depth
|
|
1081
|
+
n = sum(1 for _ in threads_dir.rglob("*.jsonl"))
|
|
1082
|
+
if max(depth, _depth_for(n)) == 0:
|
|
1083
|
+
return 0 # flat and staying flat — nothing can be misplaced
|
|
1084
|
+
with try_shared_ingest_lock() as ingest_ok:
|
|
1085
|
+
if not ingest_ok:
|
|
1086
|
+
return depth # a reindex is reading the truth — don't move it underneath
|
|
1087
|
+
with _try_rebalance_lock() as held:
|
|
1088
|
+
if not held:
|
|
1089
|
+
return depth # another sweep is running; it owns the manifest depth
|
|
1090
|
+
m = _read_manifest(d)
|
|
1091
|
+
depth = max(depth, int(m.get("shard_depth", 0))) # fresh under the lock
|
|
1092
|
+
target = max(depth, _depth_for(n))
|
|
1093
|
+
if target > depth:
|
|
1094
|
+
# Locked RMW, durable BEFORE any file moves (see docstring).
|
|
1095
|
+
update_manifest(d, lambda m: m.__setitem__(
|
|
1096
|
+
"shard_depth", max(target, int(m.get("shard_depth", 0)))
|
|
1097
|
+
))
|
|
1098
|
+
# List under the lock — a sweep that completed between the count above
|
|
1099
|
+
# and our acquisition has already re-homed what we would move again.
|
|
1100
|
+
misplaced: list[tuple[Path, Path]] = []
|
|
1101
|
+
for path in threads_dir.rglob("*.jsonl"):
|
|
1102
|
+
try:
|
|
1103
|
+
tid = int(path.stem)
|
|
1104
|
+
except ValueError: # pragma: no cover — stray file
|
|
1105
|
+
continue
|
|
1106
|
+
dest = _thread_file(d, tid, target)
|
|
1107
|
+
if dest != path:
|
|
1108
|
+
misplaced.append((path, dest))
|
|
1109
|
+
if not misplaced:
|
|
1110
|
+
return target
|
|
1111
|
+
reset_handles() # our own cached appenders must not span the sweep
|
|
1112
|
+
# The truth-write mutex excludes every drain for the move loop, so no
|
|
1113
|
+
# append can land on a file between its merge-copy and its unlink.
|
|
1114
|
+
with _truth_write_lock():
|
|
1115
|
+
for path, dest in misplaced:
|
|
1116
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1117
|
+
if dest.exists():
|
|
1118
|
+
_merge_file_into(path, dest)
|
|
1119
|
+
else:
|
|
1120
|
+
os.replace(path, dest)
|
|
1121
|
+
logger.info(
|
|
1122
|
+
"jsonl_log rebalance: shard_depth %d → %d (%d files re-homed, %d threads)",
|
|
1123
|
+
depth, target, len(misplaced), n,
|
|
1124
|
+
)
|
|
1125
|
+
return target
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
# ── integrity scan (the primitive behind `archive verify`) ───────────────────
|
|
1129
|
+
def scan_truth_counts(
|
|
1130
|
+
*, event_id_max: int | None = None, thread_id_max: int | None = None,
|
|
1131
|
+
kg_event_id_max: int | None = None, truth_dir: Path | None = None,
|
|
1132
|
+
) -> dict:
|
|
1133
|
+
"""Count threads + event lines across the truth directory — the per-thread
|
|
1134
|
+
files *and* the curatorial log (``kg_events.jsonl``) — tallying any JSON
|
|
1135
|
+
parse errors. The integrity primitive behind ``archive verify``: a clean
|
|
1136
|
+
archive has these match the SQLite projection's thread/event/kg-event counts
|
|
1137
|
+
(the JSONL ⊇ SQLite invariant) with zero parse errors.
|
|
1138
|
+
|
|
1139
|
+
The truth is append-only, so it legitimately accumulates superseded lines the
|
|
1140
|
+
projection collapses: a re-appended line for an id it already holds (a crash
|
|
1141
|
+
between a rebalance merge-copy and its unlink), and a same-content twin under
|
|
1142
|
+
a fresh id (a re-import after the original's commit was lost — same
|
|
1143
|
+
``dedup_key``). ``events`` counts raw lines; ``events_effective`` counts what
|
|
1144
|
+
the projection materializes — distinct on id, then distinct on ``dedup_key``
|
|
1145
|
+
(falling back to id when NULL). The index is compared against
|
|
1146
|
+
``events_effective``; the superseded remainder is reported, not drift.
|
|
1147
|
+
|
|
1148
|
+
A thread is counted once per distinct file *stem*, and the collapse runs
|
|
1149
|
+
across every file sharing that stem — so a thread present at two shard
|
|
1150
|
+
depths (a both-layouts backup mirror, a mid-migration crash) counts as one
|
|
1151
|
+
thread and its duplicated lines as superseded, matching what a reindex of
|
|
1152
|
+
that directory materializes.
|
|
1153
|
+
|
|
1154
|
+
``kg_events`` counts the curatorial log's distinct ids (the file is
|
|
1155
|
+
append-only, so a crash-merge can legitimately duplicate a line; the
|
|
1156
|
+
projection materializes one row per id). Its unparseable lines fold into the
|
|
1157
|
+
same ``parse_errors`` tally and torn-tail/interior split as the per-thread
|
|
1158
|
+
files — the curation truth deserves the same daily scan the conversation
|
|
1159
|
+
truth gets, not a weekly one.
|
|
1160
|
+
|
|
1161
|
+
``event_id_max`` / ``thread_id_max`` / ``kg_event_id_max`` bound the scan to
|
|
1162
|
+
ids at or below a stable watermark, so a verify racing live ingest (truth
|
|
1163
|
+
lines land *before* their commit; the index keeps growing while the scan
|
|
1164
|
+
reads files) compares the same committed prefix on both sides instead of
|
|
1165
|
+
false-alarming.
|
|
1166
|
+
|
|
1167
|
+
``truth_dir`` scans an explicit directory instead of the live archive's —
|
|
1168
|
+
the primitive behind the backup-side check (``archive verify --backup``)."""
|
|
1169
|
+
d = truth_dir if truth_dir is not None else log_dir()
|
|
1170
|
+
threads_dir = d / THREADS_SUBDIR
|
|
1171
|
+
n_events = n_effective = parse_errors = 0
|
|
1172
|
+
dup_id_lines = dup_content_lines = 0
|
|
1173
|
+
parse_error_sample: list[str] = []
|
|
1174
|
+
parse_error_locs: list[tuple[str, int]] = []
|
|
1175
|
+
files_by_stem: dict[str, list[Path]] = {}
|
|
1176
|
+
if threads_dir.exists():
|
|
1177
|
+
for path in threads_dir.rglob("*.jsonl"):
|
|
1178
|
+
if thread_id_max is not None:
|
|
1179
|
+
try:
|
|
1180
|
+
if int(path.stem) > thread_id_max:
|
|
1181
|
+
continue
|
|
1182
|
+
except ValueError: # pragma: no cover — stray file
|
|
1183
|
+
pass
|
|
1184
|
+
files_by_stem.setdefault(path.stem, []).append(path)
|
|
1185
|
+
for paths in files_by_stem.values():
|
|
1186
|
+
seen_ids: set = set()
|
|
1187
|
+
seen_keys: set = set()
|
|
1188
|
+
for path in paths:
|
|
1189
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
1190
|
+
for lineno, line in enumerate(fh, 1):
|
|
1191
|
+
line = line.strip()
|
|
1192
|
+
if not line:
|
|
1193
|
+
continue
|
|
1194
|
+
try:
|
|
1195
|
+
rec = json.loads(line)
|
|
1196
|
+
except ValueError:
|
|
1197
|
+
parse_errors += 1
|
|
1198
|
+
parse_error_locs.append((str(path), lineno))
|
|
1199
|
+
if len(parse_error_sample) < 10:
|
|
1200
|
+
parse_error_sample.append(f"{path}:{lineno}")
|
|
1201
|
+
continue
|
|
1202
|
+
if rec.get("type", "event") != "event":
|
|
1203
|
+
continue
|
|
1204
|
+
ev_id = rec.get("id")
|
|
1205
|
+
if event_id_max is not None and ev_id is not None and ev_id > event_id_max:
|
|
1206
|
+
continue
|
|
1207
|
+
n_events += 1
|
|
1208
|
+
if ev_id in seen_ids:
|
|
1209
|
+
dup_id_lines += 1
|
|
1210
|
+
continue
|
|
1211
|
+
seen_ids.add(ev_id)
|
|
1212
|
+
key = rec.get("dedup_key") or ("id", ev_id)
|
|
1213
|
+
if key in seen_keys:
|
|
1214
|
+
dup_content_lines += 1
|
|
1215
|
+
continue
|
|
1216
|
+
seen_keys.add(key)
|
|
1217
|
+
n_effective += 1
|
|
1218
|
+
# The curatorial log: distinct kg-event ids at or below the watermark, its
|
|
1219
|
+
# parse errors folded into the same tally (and torn/interior split) so a
|
|
1220
|
+
# damaged curation line fails the daily verify, not just the weekly deep one.
|
|
1221
|
+
kg_ids: set = set()
|
|
1222
|
+
kg_path = d / KG_EVENTS_FILE
|
|
1223
|
+
if kg_path.exists():
|
|
1224
|
+
with open(kg_path, encoding="utf-8", errors="replace") as fh:
|
|
1225
|
+
for lineno, line in enumerate(fh, 1):
|
|
1226
|
+
line = line.strip()
|
|
1227
|
+
if not line:
|
|
1228
|
+
continue
|
|
1229
|
+
try:
|
|
1230
|
+
rec = json.loads(line)
|
|
1231
|
+
except ValueError:
|
|
1232
|
+
parse_errors += 1
|
|
1233
|
+
parse_error_locs.append((str(kg_path), lineno))
|
|
1234
|
+
if len(parse_error_sample) < 10:
|
|
1235
|
+
parse_error_sample.append(f"{kg_path}:{lineno}")
|
|
1236
|
+
continue
|
|
1237
|
+
kg_id = rec.get("id")
|
|
1238
|
+
if kg_id is None or (kg_event_id_max is not None and kg_id > kg_event_id_max):
|
|
1239
|
+
continue
|
|
1240
|
+
kg_ids.add(int(kg_id))
|
|
1241
|
+
# Same split reindex reports: a torn tail (the file's final non-empty line —
|
|
1242
|
+
# the residue of a crash mid-append, waiting for `archive repair`) vs. interior
|
|
1243
|
+
# damage (a fragment later appends isolated, or corruption of a formerly-good
|
|
1244
|
+
# line — repair quarantines it and restores any committed event it shadowed).
|
|
1245
|
+
torn, interior = _classify_parse_errors(parse_error_locs)
|
|
1246
|
+
return {
|
|
1247
|
+
"threads": len(files_by_stem),
|
|
1248
|
+
"events": n_events,
|
|
1249
|
+
"events_effective": n_effective,
|
|
1250
|
+
"kg_events": len(kg_ids),
|
|
1251
|
+
"duplicate_id_lines": dup_id_lines,
|
|
1252
|
+
"duplicate_content_lines": dup_content_lines,
|
|
1253
|
+
"parse_errors": parse_errors,
|
|
1254
|
+
"parse_errors_torn_tail": len(torn),
|
|
1255
|
+
"parse_errors_interior": len(interior),
|
|
1256
|
+
"parse_error_sample": parse_error_sample,
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
# ── reindex (rebuild the SQLite projection from the JSONL truth) ─────────────
|
|
1261
|
+
def _final_nonempty_lineno(path: Path) -> int | None:
|
|
1262
|
+
"""Line number of the file's last non-empty line (None when unreadable/empty).
|
|
1263
|
+
The classifier for parse errors: an unparseable FINAL line is the accepted
|
|
1264
|
+
crash artifact (a torn last append — the same fragment
|
|
1265
|
+
:func:`_repair_torn_tail` newline-isolates); an unparseable line anywhere
|
|
1266
|
+
else is interior corruption."""
|
|
1267
|
+
last = None
|
|
1268
|
+
try:
|
|
1269
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
1270
|
+
for lineno, line in enumerate(fh, 1):
|
|
1271
|
+
if line.strip():
|
|
1272
|
+
last = lineno
|
|
1273
|
+
except OSError:
|
|
1274
|
+
return None
|
|
1275
|
+
return last
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _classify_parse_errors(
|
|
1279
|
+
errors: list[tuple[str, int]],
|
|
1280
|
+
) -> tuple[list[tuple[str, int]], list[tuple[str, int]]]:
|
|
1281
|
+
"""Split recorded parse errors into ``(torn_tails, interior)``. A torn tail —
|
|
1282
|
+
the file's final non-empty line — is the expected residue of a crash
|
|
1283
|
+
mid-append and never blocks recovery; anything interior means the truth was
|
|
1284
|
+
corrupted some other way and must be looked at, not silently dropped."""
|
|
1285
|
+
torn: list[tuple[str, int]] = []
|
|
1286
|
+
interior: list[tuple[str, int]] = []
|
|
1287
|
+
last_by_path: dict[str, int | None] = {}
|
|
1288
|
+
for path, lineno in errors:
|
|
1289
|
+
if path not in last_by_path:
|
|
1290
|
+
last_by_path[path] = _final_nonempty_lineno(Path(path))
|
|
1291
|
+
(torn if lineno == last_by_path[path] else interior).append((path, lineno))
|
|
1292
|
+
return torn, interior
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _iter_jsonl(path: Path, *, errors: list[tuple[str, int]] | None = None):
|
|
1296
|
+
"""Yield parsed records from a JSONL file, skipping unparseable lines.
|
|
1297
|
+
|
|
1298
|
+
A torn line (a crash mid-append) must not kill :func:`reindex` — the recovery
|
|
1299
|
+
primitive has to recover everything parseable, with the same tolerance
|
|
1300
|
+
``archive verify`` (:func:`scan_truth_counts`) already has. Every skipped line
|
|
1301
|
+
is logged, and recorded on ``errors`` as ``(path, lineno)`` when given, so
|
|
1302
|
+
reindex can report the count instead of silently dropping."""
|
|
1303
|
+
if not path.exists():
|
|
1304
|
+
return
|
|
1305
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
1306
|
+
for lineno, line in enumerate(fh, 1):
|
|
1307
|
+
line = line.strip()
|
|
1308
|
+
if not line:
|
|
1309
|
+
continue
|
|
1310
|
+
try:
|
|
1311
|
+
yield json.loads(line)
|
|
1312
|
+
except ValueError:
|
|
1313
|
+
logger.warning("truth: skipping unparseable line %s:%d", path, lineno)
|
|
1314
|
+
if errors is not None:
|
|
1315
|
+
errors.append((str(path), lineno))
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _load_table(
|
|
1319
|
+
model: type, path: Path, engine, batch: int = 5000,
|
|
1320
|
+
*, errors: list[tuple[str, int]] | None = None,
|
|
1321
|
+
) -> int:
|
|
1322
|
+
"""Bulk-load a snapshot file (one row per line) into its table."""
|
|
1323
|
+
table = model.__table__ # type: ignore[attr-defined]
|
|
1324
|
+
total = 0
|
|
1325
|
+
buf: list[dict] = []
|
|
1326
|
+
|
|
1327
|
+
def _flush() -> None:
|
|
1328
|
+
nonlocal total
|
|
1329
|
+
if not buf:
|
|
1330
|
+
return
|
|
1331
|
+
with engine.begin() as conn:
|
|
1332
|
+
conn.execute(insert(table).prefix_with("OR REPLACE"), buf)
|
|
1333
|
+
total += len(buf)
|
|
1334
|
+
buf.clear()
|
|
1335
|
+
|
|
1336
|
+
for row in _iter_jsonl(path, errors=errors):
|
|
1337
|
+
buf.append(_coerce(model, row))
|
|
1338
|
+
if len(buf) >= batch:
|
|
1339
|
+
_flush()
|
|
1340
|
+
_flush()
|
|
1341
|
+
return total
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def thread_file_load_order(d: Path) -> list[Path]:
|
|
1345
|
+
"""Every ``threads/**/*.jsonl`` in the order reindex loads them: path-sorted,
|
|
1346
|
+
then any file at its thread's *canonical* (manifest-depth) location moved
|
|
1347
|
+
last. Loads are last-wins (OR REPLACE by id; latest thread record), so when a
|
|
1348
|
+
thread has files at more than one shard depth — a both-layouts backup mirror,
|
|
1349
|
+
a mid-migration crash — the canonical file's records must win: it is the one
|
|
1350
|
+
live writers append to, so it is at least as fresh as any stale twin. Plain
|
|
1351
|
+
path order would let a stale flat twin load *after* its sharded home
|
|
1352
|
+
(``threads/00/…`` sorts before ``threads/12345.jsonl``) and shadow it."""
|
|
1353
|
+
threads_dir = d / THREADS_SUBDIR
|
|
1354
|
+
if not threads_dir.exists():
|
|
1355
|
+
return []
|
|
1356
|
+
depth = _shard_depth(d)
|
|
1357
|
+
|
|
1358
|
+
def _canonical(path: Path) -> bool:
|
|
1359
|
+
try:
|
|
1360
|
+
return path == _thread_file(d, int(path.stem), depth)
|
|
1361
|
+
except ValueError:
|
|
1362
|
+
return False
|
|
1363
|
+
|
|
1364
|
+
paths = sorted(threads_dir.rglob("*.jsonl"))
|
|
1365
|
+
paths.sort(key=_canonical) # stable: non-canonical first, canonical last
|
|
1366
|
+
return paths
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _load_thread_files(
|
|
1370
|
+
d: Path, engine, batch: int = 5000,
|
|
1371
|
+
*, errors: list[tuple[str, int]] | None = None,
|
|
1372
|
+
) -> tuple[int, int]:
|
|
1373
|
+
"""Load every ``threads/**/<id>.jsonl`` into the threads + events tables.
|
|
1374
|
+
|
|
1375
|
+
Per file: the last ``type:thread`` record is the metadata (latest wins), every
|
|
1376
|
+
``type:event`` record is an event. Files load in :func:`thread_file_load_order`
|
|
1377
|
+
(canonical-depth file last), so a thread with a stale twin at another shard
|
|
1378
|
+
depth materializes the canonical file's records. A file with events but no
|
|
1379
|
+
thread record (a crash between an import commit and its checkpoint) gets a
|
|
1380
|
+
synthesized minimal thread so its events aren't dropped. FK enforcement is off
|
|
1381
|
+
on the loader, so the interleaved thread/event inserts need no ordering."""
|
|
1382
|
+
thread_buf: list[dict] = []
|
|
1383
|
+
event_buf: list[dict] = []
|
|
1384
|
+
nt = ne = 0
|
|
1385
|
+
|
|
1386
|
+
def _flush() -> None:
|
|
1387
|
+
nonlocal nt, ne
|
|
1388
|
+
if thread_buf:
|
|
1389
|
+
with engine.begin() as conn:
|
|
1390
|
+
conn.execute(insert(Thread.__table__).prefix_with("OR REPLACE"), thread_buf)
|
|
1391
|
+
nt += len(thread_buf)
|
|
1392
|
+
thread_buf.clear()
|
|
1393
|
+
if event_buf:
|
|
1394
|
+
with engine.begin() as conn:
|
|
1395
|
+
conn.execute(insert(Event.__table__).prefix_with("OR REPLACE"), event_buf)
|
|
1396
|
+
ne += len(event_buf)
|
|
1397
|
+
event_buf.clear()
|
|
1398
|
+
|
|
1399
|
+
have_meta: set[int] = set() # tids with a real thread record already loaded
|
|
1400
|
+
for path in thread_file_load_order(d):
|
|
1401
|
+
last_thread: dict | None = None
|
|
1402
|
+
events: list[dict] = []
|
|
1403
|
+
for rec in _iter_jsonl(path, errors=errors):
|
|
1404
|
+
kind = rec.pop("type", "event")
|
|
1405
|
+
if kind == "thread":
|
|
1406
|
+
last_thread = rec
|
|
1407
|
+
else:
|
|
1408
|
+
events.append(rec)
|
|
1409
|
+
if last_thread is None:
|
|
1410
|
+
try:
|
|
1411
|
+
tid = int(path.stem)
|
|
1412
|
+
except ValueError: # pragma: no cover
|
|
1413
|
+
continue
|
|
1414
|
+
if tid in have_meta:
|
|
1415
|
+
# A twin of this thread already supplied its real record; a
|
|
1416
|
+
# synthesized stub loading after it would clobber real metadata.
|
|
1417
|
+
last_thread = None
|
|
1418
|
+
else:
|
|
1419
|
+
last_thread = {"id": tid, "name": f"thread:{tid}"}
|
|
1420
|
+
logger.warning("reindex: %s had no thread record — synthesized minimal", path.name)
|
|
1421
|
+
else:
|
|
1422
|
+
try:
|
|
1423
|
+
have_meta.add(int(last_thread.get("id")))
|
|
1424
|
+
except (TypeError, ValueError): # pragma: no cover — malformed record
|
|
1425
|
+
pass
|
|
1426
|
+
if last_thread is not None:
|
|
1427
|
+
thread_buf.append(_coerce(Thread, last_thread))
|
|
1428
|
+
for ev in events:
|
|
1429
|
+
event_buf.append(_coerce(Event, ev))
|
|
1430
|
+
if len(thread_buf) >= batch or len(event_buf) >= batch:
|
|
1431
|
+
_flush()
|
|
1432
|
+
_flush()
|
|
1433
|
+
return nt, ne
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _carry_import_state(index_path: Path, engine) -> int:
|
|
1437
|
+
"""Carry the source-import watermarks from the previous index into the rebuild.
|
|
1438
|
+
|
|
1439
|
+
``import_state`` is operational state, not truth — the JSONL doesn't contain it,
|
|
1440
|
+
so a plain rebuild would wipe every source cursor. The next poll would then
|
|
1441
|
+
re-adopt each event-bearing source at its current EOF (``adopt_if_unwatermarked``),
|
|
1442
|
+
permanently skipping any source lines appended since its last import — reindexing
|
|
1443
|
+
with live sessions writing would silently lose their tails. The previous live
|
|
1444
|
+
index is the freshest copy of the cursors, so it overlays the (possibly stale)
|
|
1445
|
+
``import_state.jsonl`` snapshot seed loaded before this. Rows pointing at threads
|
|
1446
|
+
the truth no longer holds are pruned. Returns rows carried."""
|
|
1447
|
+
if not index_path.exists():
|
|
1448
|
+
return 0
|
|
1449
|
+
build_cols = {c.key for c in ImportState.__table__.columns}
|
|
1450
|
+
try:
|
|
1451
|
+
src = sqlite3.connect(f"file:{index_path}?mode=ro", uri=True)
|
|
1452
|
+
except sqlite3.OperationalError: # pragma: no cover — unreadable old index
|
|
1453
|
+
return 0
|
|
1454
|
+
try:
|
|
1455
|
+
try:
|
|
1456
|
+
cur = src.execute("SELECT * FROM import_state")
|
|
1457
|
+
except sqlite3.OperationalError as e:
|
|
1458
|
+
if "no such table" not in str(e): # pragma: no cover — unreadable old index
|
|
1459
|
+
logger.warning("reindex: could not read import_state from old index: %s", e)
|
|
1460
|
+
return 0
|
|
1461
|
+
src_cols = [d[0] for d in cur.description]
|
|
1462
|
+
keep = [i for i, c in enumerate(src_cols) if c in build_cols]
|
|
1463
|
+
cols = [src_cols[i] for i in keep]
|
|
1464
|
+
rows = [tuple(r[i] for i in keep) for r in cur.fetchall()]
|
|
1465
|
+
finally:
|
|
1466
|
+
src.close()
|
|
1467
|
+
if rows:
|
|
1468
|
+
stmt = (
|
|
1469
|
+
f"INSERT OR REPLACE INTO import_state ({', '.join(cols)}) "
|
|
1470
|
+
f"VALUES ({', '.join('?' * len(cols))})"
|
|
1471
|
+
)
|
|
1472
|
+
with engine.begin() as conn:
|
|
1473
|
+
conn.exec_driver_sql(stmt, rows)
|
|
1474
|
+
with engine.begin() as conn:
|
|
1475
|
+
conn.exec_driver_sql(
|
|
1476
|
+
"DELETE FROM import_state WHERE thread_id IS NOT NULL "
|
|
1477
|
+
"AND thread_id NOT IN (SELECT id FROM threads)"
|
|
1478
|
+
)
|
|
1479
|
+
return len(rows)
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _replay_kg_events(
|
|
1483
|
+
d: Path, engine, *, errors: list[tuple[str, int]] | None = None,
|
|
1484
|
+
) -> int:
|
|
1485
|
+
"""Fold the curatorial event log (``kg_events.jsonl``) onto the knowledge projection.
|
|
1486
|
+
|
|
1487
|
+
Loads the log into the ``kg_events`` table and replays each event in ``id`` order
|
|
1488
|
+
through the materializer, mutating ``thread_links`` / ``topic_messages`` on top of
|
|
1489
|
+
whatever legacy snapshot seed was already loaded. The materializer is upsert +
|
|
1490
|
+
tombstone, so a delta that re-touches a seeded row (or deletes one) reconciles
|
|
1491
|
+
cleanly and the replay is idempotent and order-stable. A no-op when the log is
|
|
1492
|
+
absent — a pre-librarian (or purely lexical) archive simply has no curation to fold.
|
|
1493
|
+
Runs through an ORM ``Session`` so the fold can use the materializer, but it never
|
|
1494
|
+
*stages* truth (only :func:`append_kg_event` does), so the before-commit drain is a
|
|
1495
|
+
no-op here and the rebuild can't re-write the log it is reading."""
|
|
1496
|
+
from .._knowledge.materialize import apply_event
|
|
1497
|
+
|
|
1498
|
+
rows = list(_iter_jsonl(d / KG_EVENTS_FILE, errors=errors))
|
|
1499
|
+
for r in rows:
|
|
1500
|
+
r.pop("type", None)
|
|
1501
|
+
rows.sort(key=lambda r: r.get("id") or 0)
|
|
1502
|
+
if not rows:
|
|
1503
|
+
return 0
|
|
1504
|
+
coerced = [_coerce(KgEvent, r) for r in rows]
|
|
1505
|
+
with engine.begin() as conn:
|
|
1506
|
+
conn.execute(insert(KgEvent.__table__).prefix_with("OR REPLACE"), coerced)
|
|
1507
|
+
with Session(engine) as s:
|
|
1508
|
+
for r in coerced:
|
|
1509
|
+
apply_event(s, KgEvent(**r))
|
|
1510
|
+
s.commit()
|
|
1511
|
+
return len(rows)
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
# ── reindex quiesce lock (shared with the watcher's ingest pass) ─────────────
|
|
1515
|
+
# flock on <home>/.reindex.lock: the watcher holds it SHARED for the duration of
|
|
1516
|
+
# each ingest pass; reindex holds it EXCLUSIVE across build+swap. So a reindex
|
|
1517
|
+
# waits out an in-flight pass, and no event can land in the truth mid-rebuild and
|
|
1518
|
+
# silently miss the new index. flock auto-releases on process death — a killed
|
|
1519
|
+
# holder can never wedge the other side.
|
|
1520
|
+
REINDEX_LOCK_FILE = ".reindex.lock"
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _reindex_lock_path() -> Path:
|
|
1524
|
+
from .._config import resolve_paths
|
|
1525
|
+
|
|
1526
|
+
return resolve_paths().home / REINDEX_LOCK_FILE
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
@contextmanager
|
|
1530
|
+
def try_shared_ingest_lock() -> Generator[bool, None, None]:
|
|
1531
|
+
"""Hold the reindex lock *shared* for one ingest pass, non-blocking.
|
|
1532
|
+
|
|
1533
|
+
Yields True holding the lock, or False (not holding) when a reindex holds it
|
|
1534
|
+
exclusive — the caller skips the pass and ingest resumes on the next one, after
|
|
1535
|
+
the swap. Sources replay from their own import state, so a skipped pass loses
|
|
1536
|
+
nothing."""
|
|
1537
|
+
path = _reindex_lock_path()
|
|
1538
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1539
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
1540
|
+
try:
|
|
1541
|
+
try:
|
|
1542
|
+
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
1543
|
+
except OSError:
|
|
1544
|
+
yield False
|
|
1545
|
+
return
|
|
1546
|
+
yield True
|
|
1547
|
+
finally:
|
|
1548
|
+
os.close(fd) # closing the fd releases the flock
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
@contextmanager
|
|
1552
|
+
def shared_ingest_lock() -> Generator[None, None, None]:
|
|
1553
|
+
"""Hold the reindex lock *shared*, blocking — for one-shot writers.
|
|
1554
|
+
|
|
1555
|
+
The watcher uses the non-blocking :func:`try_shared_ingest_lock` (it can just
|
|
1556
|
+
skip a pass); a one-shot writer — a CLI import, a librarian mutation — has no
|
|
1557
|
+
later pass to retry on, so it waits out an in-flight reindex instead. Every
|
|
1558
|
+
cross-process writer must hold this (or the try- variant) around its truth
|
|
1559
|
+
append **and** the SQLite commit: an unlocked write can append truth after the
|
|
1560
|
+
rebuild's read point and commit into the database inode the swap replaces —
|
|
1561
|
+
present in the truth, silently absent from the new index."""
|
|
1562
|
+
path = _reindex_lock_path()
|
|
1563
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1564
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
1565
|
+
try:
|
|
1566
|
+
fcntl.flock(fd, fcntl.LOCK_SH)
|
|
1567
|
+
yield
|
|
1568
|
+
finally:
|
|
1569
|
+
os.close(fd) # closing the fd releases the flock
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
@contextmanager
|
|
1573
|
+
def _hold_reindex_lock() -> Generator[None, None, None]:
|
|
1574
|
+
"""Hold the reindex lock *exclusive* for build+swap (blocks until any
|
|
1575
|
+
in-flight ingest pass — a shared holder — finishes, bounded by one pass)."""
|
|
1576
|
+
path = _reindex_lock_path()
|
|
1577
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1578
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
1579
|
+
try:
|
|
1580
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
1581
|
+
yield
|
|
1582
|
+
finally:
|
|
1583
|
+
os.close(fd)
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def _committed_regression(index_path: Path, tmp_path: Path) -> dict | None:
|
|
1587
|
+
"""Committed records the rebuild would lose, diffed against the current index.
|
|
1588
|
+
|
|
1589
|
+
The gate behind fail-closed publication: every event and kg-event id the
|
|
1590
|
+
live index holds must survive into the build — an event may instead survive
|
|
1591
|
+
as a same-content twin (same ``(thread_id, dedup_key)`` under another id;
|
|
1592
|
+
dedup collapse legitimately re-keys those). Threads are checked by id too:
|
|
1593
|
+
an event-less thread lost to a name-conflict ``OR REPLACE`` (or a deleted
|
|
1594
|
+
truth file) has no event row to trip the event diff. Anything else missing
|
|
1595
|
+
means the truth lost committed content (a damaged line, a deleted file) and
|
|
1596
|
+
the swap would make the loss permanent-by-default. Returns ``None`` when
|
|
1597
|
+
there is no readable previous index to diff against (a fresh restore has no
|
|
1598
|
+
baseline)."""
|
|
1599
|
+
if not index_path.exists():
|
|
1600
|
+
return None
|
|
1601
|
+
conn = sqlite3.connect(tmp_path)
|
|
1602
|
+
try:
|
|
1603
|
+
try:
|
|
1604
|
+
conn.execute("ATTACH DATABASE ? AS old", (str(index_path),))
|
|
1605
|
+
lost_threads = conn.execute(
|
|
1606
|
+
"SELECT count(*) FROM old.threads o "
|
|
1607
|
+
"WHERE NOT EXISTS(SELECT 1 FROM main.threads n WHERE n.id = o.id)"
|
|
1608
|
+
).fetchone()[0]
|
|
1609
|
+
thread_sample = [r[0] for r in conn.execute(
|
|
1610
|
+
"SELECT o.id FROM old.threads o "
|
|
1611
|
+
"WHERE NOT EXISTS(SELECT 1 FROM main.threads n WHERE n.id = o.id) "
|
|
1612
|
+
"ORDER BY o.id LIMIT 10"
|
|
1613
|
+
).fetchall()] if lost_threads else []
|
|
1614
|
+
lost_events = conn.execute(
|
|
1615
|
+
"SELECT count(*) FROM old.events o "
|
|
1616
|
+
"WHERE NOT EXISTS(SELECT 1 FROM main.events n WHERE n.id = o.id) "
|
|
1617
|
+
"AND (o.dedup_key IS NULL OR NOT EXISTS("
|
|
1618
|
+
" SELECT 1 FROM main.events n "
|
|
1619
|
+
" WHERE n.thread_id = o.thread_id AND n.dedup_key = o.dedup_key))"
|
|
1620
|
+
).fetchone()[0]
|
|
1621
|
+
sample = [r[0] for r in conn.execute(
|
|
1622
|
+
"SELECT o.id FROM old.events o "
|
|
1623
|
+
"WHERE NOT EXISTS(SELECT 1 FROM main.events n WHERE n.id = o.id) "
|
|
1624
|
+
"AND (o.dedup_key IS NULL OR NOT EXISTS("
|
|
1625
|
+
" SELECT 1 FROM main.events n "
|
|
1626
|
+
" WHERE n.thread_id = o.thread_id AND n.dedup_key = o.dedup_key)) "
|
|
1627
|
+
"ORDER BY o.id LIMIT 10"
|
|
1628
|
+
).fetchall()] if lost_events else []
|
|
1629
|
+
lost_kg = conn.execute(
|
|
1630
|
+
"SELECT count(*) FROM old.kg_events o "
|
|
1631
|
+
"WHERE NOT EXISTS(SELECT 1 FROM main.kg_events n WHERE n.id = o.id)"
|
|
1632
|
+
).fetchone()[0]
|
|
1633
|
+
except sqlite3.Error as e:
|
|
1634
|
+
# An unreadable / pre-schema old index is no baseline — the rebuild
|
|
1635
|
+
# IS the recovery. Never let the gate itself block it.
|
|
1636
|
+
logger.warning("reindex: cannot diff against the previous index (%s)", e)
|
|
1637
|
+
return None
|
|
1638
|
+
finally:
|
|
1639
|
+
conn.close()
|
|
1640
|
+
return {
|
|
1641
|
+
"events": int(lost_events), "event_sample": sample,
|
|
1642
|
+
"kg_events": int(lost_kg),
|
|
1643
|
+
"threads": int(lost_threads), "thread_sample": thread_sample,
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def _parsed_equal(a: object, b: object) -> bool:
|
|
1648
|
+
"""True when two stored payload texts encode the same object. Raw-text
|
|
1649
|
+
inequality alone must not count — the two stores may serialize one object
|
|
1650
|
+
differently across code versions — so candidates are confirmed by parsed
|
|
1651
|
+
comparison. An unparseable side is a real disagreement (that copy is
|
|
1652
|
+
damaged)."""
|
|
1653
|
+
if a == b:
|
|
1654
|
+
return True
|
|
1655
|
+
try:
|
|
1656
|
+
pa = json.loads(a) if isinstance(a, str) else a
|
|
1657
|
+
pb = json.loads(b) if isinstance(b, str) else b
|
|
1658
|
+
except ValueError:
|
|
1659
|
+
return False
|
|
1660
|
+
return pa == pb
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def _content_divergence(index_path: Path, tmp_path: Path) -> dict | None:
|
|
1664
|
+
"""Same-id events whose content disagrees between the current index and the
|
|
1665
|
+
rebuild — publication *visibility*, deliberately not a gate.
|
|
1666
|
+
|
|
1667
|
+
The truth is authoritative: on publish the rebuild's content wins, and the
|
|
1668
|
+
projection disagreeing must not block the swap. But nothing mutates a
|
|
1669
|
+
payload after commit, so a same-id disagreement means one copy is damaged —
|
|
1670
|
+
and for an event with no hash-tailed ``dedup_key`` the live index row can be
|
|
1671
|
+
the last good copy of a truth line rotted in place. Cross-store parity
|
|
1672
|
+
(``verify --hashes``) can only see the disagreement while both copies still
|
|
1673
|
+
exist; the moment the swap lands they agree and the rot is laundered.
|
|
1674
|
+
Publication is therefore the last observable moment of the overwrite: count
|
|
1675
|
+
it and name the ids, so an operator (or the agent that just ran ``archive
|
|
1676
|
+
reindex`` as a routine fix) can adjudicate against a backup generation
|
|
1677
|
+
before the next backup run propagates the new content. Returns ``None``
|
|
1678
|
+
with no readable previous index, else ``{"events": n, "sample": [...]}``."""
|
|
1679
|
+
if not index_path.exists():
|
|
1680
|
+
return None
|
|
1681
|
+
conn = sqlite3.connect(tmp_path)
|
|
1682
|
+
try:
|
|
1683
|
+
try:
|
|
1684
|
+
conn.execute("ATTACH DATABASE ? AS old", (str(index_path),))
|
|
1685
|
+
# Raw-text inequality is the cheap SQL prefilter; each candidate is
|
|
1686
|
+
# confirmed in Python (see _parsed_equal). Streamed, not materialized.
|
|
1687
|
+
cur = conn.execute(
|
|
1688
|
+
"SELECT o.id, o.event_type, o.payload, n.event_type, n.payload "
|
|
1689
|
+
"FROM old.events o JOIN main.events n ON n.id = o.id "
|
|
1690
|
+
"WHERE o.payload IS NOT n.payload OR o.event_type IS NOT n.event_type"
|
|
1691
|
+
)
|
|
1692
|
+
diverged = 0
|
|
1693
|
+
sample: list[int] = []
|
|
1694
|
+
for ev_id, o_type, o_payload, n_type, n_payload in cur:
|
|
1695
|
+
if o_type == n_type and _parsed_equal(o_payload, n_payload):
|
|
1696
|
+
continue
|
|
1697
|
+
diverged += 1
|
|
1698
|
+
if len(sample) < 10:
|
|
1699
|
+
sample.append(int(ev_id))
|
|
1700
|
+
except sqlite3.Error as e:
|
|
1701
|
+
# No readable old index — nothing to diverge from; the rebuild IS
|
|
1702
|
+
# the recovery.
|
|
1703
|
+
logger.warning("reindex: cannot content-diff against the previous index (%s)", e)
|
|
1704
|
+
return None
|
|
1705
|
+
finally:
|
|
1706
|
+
conn.close()
|
|
1707
|
+
return {"events": diverged, "sample": sample}
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def _build_fk_violations(tmp_path: Path) -> list[tuple]:
|
|
1711
|
+
"""``PRAGMA foreign_key_check`` over the whole build — the relational gate
|
|
1712
|
+
behind fail-closed publication. The loader runs FK-OFF with blanket
|
|
1713
|
+
``INSERT OR REPLACE``, so a load-order accident can delete a parent row
|
|
1714
|
+
while its children survive (``threads.name`` is UNIQUE: two thread records
|
|
1715
|
+
sharing a name make OR REPLACE silently drop one thread and orphan its
|
|
1716
|
+
events). ``quick_check`` is page-level and cannot see that. Every declared
|
|
1717
|
+
FK targets ``threads`` — deliberately-soft references (citations to
|
|
1718
|
+
events) carry no FK, so this gate can never refuse a rebuild over the
|
|
1719
|
+
dangling citations ``verify --deep`` tolerates by design. Returns up to 20
|
|
1720
|
+
``(table, rowid, parent, fkid)`` rows."""
|
|
1721
|
+
conn = sqlite3.connect(tmp_path)
|
|
1722
|
+
try:
|
|
1723
|
+
return conn.execute("PRAGMA foreign_key_check").fetchmany(20)
|
|
1724
|
+
finally:
|
|
1725
|
+
conn.close()
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _files_for_thread(d: Path, thread_id: int) -> list[Path]:
|
|
1729
|
+
"""Every truth file for ``thread_id`` — its canonical-depth file plus any
|
|
1730
|
+
stale twin at another shard depth."""
|
|
1731
|
+
threads_dir = d / THREADS_SUBDIR
|
|
1732
|
+
if not threads_dir.exists():
|
|
1733
|
+
return []
|
|
1734
|
+
return list(threads_dir.rglob(f"{int(thread_id)}.jsonl"))
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def _reconcile_collapsed_citations(d: Path, engine) -> dict:
|
|
1738
|
+
"""Post-load reconciliation of citation → event references in the build.
|
|
1739
|
+
|
|
1740
|
+
Dedup collapse (OR REPLACE + the ``(thread_id, dedup_key)`` unique index)
|
|
1741
|
+
keeps one row per content identity; when the *discarded* twin's id was cited
|
|
1742
|
+
(``topic_messages.event_id``), the citation would dangle even though the
|
|
1743
|
+
content it cites survived under the other id. The truth still holds the
|
|
1744
|
+
discarded id's line, so its ``dedup_key`` recovers the surviving row: the
|
|
1745
|
+
citation is repointed to it — or dropped when the topic already cites the
|
|
1746
|
+
survivor (same content, same topic, one citation). A citation whose event
|
|
1747
|
+
has no surviving twin is left dangling for ``verify --deep`` to report.
|
|
1748
|
+
|
|
1749
|
+
Citations whose recorded ``thread_id`` disagrees with the cited event's
|
|
1750
|
+
actual thread are aligned to the event — the event row is authoritative and
|
|
1751
|
+
the column is derived (a wrong value came from an unvalidated write or a
|
|
1752
|
+
stale snapshot seed).
|
|
1753
|
+
|
|
1754
|
+
Both repairs are deterministic functions of the truth directory, so
|
|
1755
|
+
re-running them on every reindex lands on the same projection; the truth
|
|
1756
|
+
log itself is never rewritten here. Returns the (nonzero) counts."""
|
|
1757
|
+
with engine.begin() as conn:
|
|
1758
|
+
dangling = conn.exec_driver_sql(
|
|
1759
|
+
"SELECT m.id, m.topic_id, m.event_id, m.thread_id FROM topic_messages m "
|
|
1760
|
+
"WHERE m.archived_at IS NULL "
|
|
1761
|
+
"AND NOT EXISTS(SELECT 1 FROM events e WHERE e.id = m.event_id)"
|
|
1762
|
+
).fetchall()
|
|
1763
|
+
repointed = dropped = 0
|
|
1764
|
+
if dangling:
|
|
1765
|
+
# dedup_key of each discarded id, recovered from its thread's truth
|
|
1766
|
+
# file(s). dedup_key is thread-scoped (same content in two threads
|
|
1767
|
+
# shares a key), so the survivor lookup stays scoped to the thread.
|
|
1768
|
+
wanted_by_thread: dict[int, set[int]] = {}
|
|
1769
|
+
for _, _, ev_id, tid in dangling:
|
|
1770
|
+
wanted_by_thread.setdefault(int(tid), set()).add(int(ev_id))
|
|
1771
|
+
keys: dict[int, str] = {}
|
|
1772
|
+
for tid, wanted in wanted_by_thread.items():
|
|
1773
|
+
for path in _files_for_thread(d, tid):
|
|
1774
|
+
for rec in _iter_jsonl(path):
|
|
1775
|
+
if (
|
|
1776
|
+
rec.get("type", "event") == "event"
|
|
1777
|
+
and rec.get("id") in wanted and rec.get("dedup_key")
|
|
1778
|
+
):
|
|
1779
|
+
keys[int(rec["id"])] = rec["dedup_key"]
|
|
1780
|
+
with engine.begin() as conn:
|
|
1781
|
+
for row_id, topic_id, ev_id, tid in dangling:
|
|
1782
|
+
key = keys.get(int(ev_id))
|
|
1783
|
+
if not key:
|
|
1784
|
+
continue
|
|
1785
|
+
survivor = conn.exec_driver_sql(
|
|
1786
|
+
"SELECT id FROM events WHERE thread_id = ? AND dedup_key = ?",
|
|
1787
|
+
(int(tid), key),
|
|
1788
|
+
).fetchone()
|
|
1789
|
+
if survivor is None:
|
|
1790
|
+
continue
|
|
1791
|
+
already = conn.exec_driver_sql(
|
|
1792
|
+
"SELECT 1 FROM topic_messages WHERE topic_id = ? AND event_id = ?",
|
|
1793
|
+
(int(topic_id), int(survivor[0])),
|
|
1794
|
+
).fetchone()
|
|
1795
|
+
if already is not None:
|
|
1796
|
+
conn.exec_driver_sql(
|
|
1797
|
+
"DELETE FROM topic_messages WHERE id = ?", (int(row_id),)
|
|
1798
|
+
)
|
|
1799
|
+
dropped += 1
|
|
1800
|
+
else:
|
|
1801
|
+
conn.exec_driver_sql(
|
|
1802
|
+
"UPDATE topic_messages SET event_id = ? WHERE id = ?",
|
|
1803
|
+
(int(survivor[0]), int(row_id)),
|
|
1804
|
+
)
|
|
1805
|
+
repointed += 1
|
|
1806
|
+
with engine.begin() as conn:
|
|
1807
|
+
aligned = conn.exec_driver_sql(
|
|
1808
|
+
"UPDATE topic_messages SET thread_id = "
|
|
1809
|
+
"(SELECT e.thread_id FROM events e WHERE e.id = topic_messages.event_id) "
|
|
1810
|
+
"WHERE EXISTS(SELECT 1 FROM events e WHERE e.id = topic_messages.event_id "
|
|
1811
|
+
"AND e.thread_id != topic_messages.thread_id)"
|
|
1812
|
+
).rowcount
|
|
1813
|
+
out: dict = {}
|
|
1814
|
+
if repointed:
|
|
1815
|
+
out["citations_repointed"] = repointed
|
|
1816
|
+
if dropped:
|
|
1817
|
+
out["citations_dropped"] = dropped
|
|
1818
|
+
if aligned:
|
|
1819
|
+
out["citations_thread_aligned"] = aligned
|
|
1820
|
+
if out:
|
|
1821
|
+
logger.warning("reindex: citation reconciliation %s", out)
|
|
1822
|
+
return out
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _fold_wal(path: Path) -> None:
|
|
1826
|
+
"""Fold any leftover ``-wal`` into the main file and drop the sidecars, so a
|
|
1827
|
+
rename moves one complete, self-contained database."""
|
|
1828
|
+
if Path(f"{path}-wal").exists():
|
|
1829
|
+
conn = sqlite3.connect(path)
|
|
1830
|
+
try:
|
|
1831
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
1832
|
+
finally:
|
|
1833
|
+
conn.close()
|
|
1834
|
+
for suffix in ("-wal", "-shm"):
|
|
1835
|
+
Path(f"{path}{suffix}").unlink(missing_ok=True)
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
def _unlink_build(tmp_path: Path) -> None:
|
|
1839
|
+
for p in (tmp_path, Path(f"{tmp_path}-wal"), Path(f"{tmp_path}-shm")):
|
|
1840
|
+
p.unlink(missing_ok=True)
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
def reindex(*, vectors: bool = False, salvage: bool = False) -> dict:
|
|
1844
|
+
"""Rebuild the SQLite store from the JSONL truth directory — build-and-swap.
|
|
1845
|
+
|
|
1846
|
+
The recovery primitive: build a complete new index (schema, per-thread files,
|
|
1847
|
+
cross-thread snapshots, kg-event replay, FTS, optionally vectors) in
|
|
1848
|
+
``index.db.rebuild`` next to the live file, then ``os.replace`` it over
|
|
1849
|
+
``index.db``. Atomic against a crash: a reindex killed at any point leaves the
|
|
1850
|
+
old index fully intact and the build file is discarded; only the rename — atomic
|
|
1851
|
+
on the same filesystem — publishes the new one. Ingest is quiesced for the
|
|
1852
|
+
duration via the reindex flock (the watcher skips passes while it's held), so no
|
|
1853
|
+
event lands in the truth mid-build and silently misses the new index. In-process
|
|
1854
|
+
readers reconnect on the next ``get_engine()`` call (the live engine's pools are
|
|
1855
|
+
disposed before the swap); *cross-process* readers keep serving the old inode
|
|
1856
|
+
until they reconnect or restart — the accepted stale-read window (see
|
|
1857
|
+
docs/plans/reindex-atomic-swap.md for the follow-up).
|
|
1858
|
+
|
|
1859
|
+
Bulk loading targets the build file through a **FK-OFF Core** loader so
|
|
1860
|
+
dependency-agnostic inserts need no ordering and the conversation truth-log
|
|
1861
|
+
listeners never fire; the kg-event replay runs through a Session on that same
|
|
1862
|
+
engine but stages nothing, so it likewise can't re-write the truth it reads.
|
|
1863
|
+
Loads are **INSERT OR REPLACE** (last-wins). The JSONL is authoritative and
|
|
1864
|
+
replayed as-is — including any dangling reference; integrity was the writer's
|
|
1865
|
+
job.
|
|
1866
|
+
|
|
1867
|
+
**Publication fails closed on committed-content loss.** Unparseable truth
|
|
1868
|
+
lines are skipped and counted (``parse_errors``, split into
|
|
1869
|
+
``parse_errors_torn_tail`` — the file's final line, a torn last append — and
|
|
1870
|
+
``parse_errors_interior``): crash artifacts are expected residue (an
|
|
1871
|
+
isolated torn fragment stays an unparseable interior line forever) and must
|
|
1872
|
+
never block the recovery primitive. What must never happen instead is a
|
|
1873
|
+
swap that silently *loses committed records*, so before publishing the
|
|
1874
|
+
build is diffed against the current index (:func:`_committed_regression`):
|
|
1875
|
+
any event, kg-event, or thread the old index holds that the rebuild lacks —
|
|
1876
|
+
by id, and for events with no same-content twin surviving under another id —
|
|
1877
|
+
aborts the swap (the old index stays live) and the error names the loss.
|
|
1878
|
+
The build must also pass ``PRAGMA foreign_key_check``
|
|
1879
|
+
(:func:`_build_fk_violations`) — the FK-OFF OR REPLACE load can orphan
|
|
1880
|
+
children when conflicting parent records collide, and a structurally
|
|
1881
|
+
inconsistent build must not be published. Deliberately-soft references
|
|
1882
|
+
(citations to events) carry no FK, so the gate never blocks the tolerated
|
|
1883
|
+
dangling-citation case; ``salvage=True`` overrides it like the loss gate. A
|
|
1884
|
+
crash fragment was never committed (truth is fsynced before its COMMIT), so
|
|
1885
|
+
it can't trip the gate; a damaged committed line always does. ``salvage=True``
|
|
1886
|
+
is the deliberate override: publish the lossy rebuild anyway. With no
|
|
1887
|
+
readable previous index (the ``rm index.db`` recovery flow) there is no
|
|
1888
|
+
baseline and the gate is skipped — parse errors are still reported. The
|
|
1889
|
+
rebuilt file must also pass ``PRAGMA quick_check`` before the swap (never
|
|
1890
|
+
overridable) — a build corrupted at the page level must not replace a
|
|
1891
|
+
healthy index.
|
|
1892
|
+
|
|
1893
|
+
**Content divergence is reported, never blocked.** A same-id event whose
|
|
1894
|
+
content disagrees between the old index and the build is counted into the
|
|
1895
|
+
result (``content_overwrites`` + sample ids) and logged at publication
|
|
1896
|
+
(:func:`_content_divergence`): the truth is authoritative and its content
|
|
1897
|
+
wins, but nothing mutates a payload after commit, so a disagreement means
|
|
1898
|
+
one copy is damaged — and once the swap lands the two stores agree and
|
|
1899
|
+
cross-store parity can no longer see it. The report is the last observable
|
|
1900
|
+
moment of the overwrite; adjudicate an unexpected one against a backup
|
|
1901
|
+
generation before the next backup run propagates the published content."""
|
|
1902
|
+
d = log_dir()
|
|
1903
|
+
|
|
1904
|
+
engine = get_engine()
|
|
1905
|
+
db_path = engine.url.database
|
|
1906
|
+
if not db_path or db_path == ":memory:":
|
|
1907
|
+
raise RuntimeError("reindex needs a file-backed index (build-and-swap)")
|
|
1908
|
+
index_path = Path(db_path)
|
|
1909
|
+
|
|
1910
|
+
# Pre-flight: the build needs room for a second copy of the index.
|
|
1911
|
+
if index_path.exists():
|
|
1912
|
+
free = shutil.disk_usage(index_path.parent).free
|
|
1913
|
+
need = int(index_path.stat().st_size * 1.2)
|
|
1914
|
+
if free < need:
|
|
1915
|
+
raise RuntimeError(
|
|
1916
|
+
f"reindex: {free / 1e9:.1f} GB free < {need / 1e9:.1f} GB needed "
|
|
1917
|
+
"for the temp build — free disk space first"
|
|
1918
|
+
)
|
|
1919
|
+
|
|
1920
|
+
tmp_path = index_path.with_name(index_path.name + ".rebuild")
|
|
1921
|
+
counts: dict = {}
|
|
1922
|
+
parse_errors: list[tuple[str, int]] = []
|
|
1923
|
+
|
|
1924
|
+
with _hold_reindex_lock():
|
|
1925
|
+
# Entering the truth-write mutex resolves any crashed drain's leftover
|
|
1926
|
+
# intent (a partial batch) before the rebuild reads the files.
|
|
1927
|
+
with _truth_write_lock():
|
|
1928
|
+
pass
|
|
1929
|
+
_unlink_build(tmp_path) # a dead prior build is stale — start clean
|
|
1930
|
+
loader = build_engine(f"sqlite:///{tmp_path}", enforce_fk=False)
|
|
1931
|
+
try:
|
|
1932
|
+
init_db(loader)
|
|
1933
|
+
counts["threads"], counts["events"] = _load_thread_files(d, loader, errors=parse_errors)
|
|
1934
|
+
# The loader counts rows loaded; OR REPLACE + the (thread_id, dedup_key)
|
|
1935
|
+
# unique index collapse superseded lines (re-appended ids, same-content
|
|
1936
|
+
# twins from a lost-commit re-import, a thread's stale twin at another
|
|
1937
|
+
# shard depth), so report what actually survived.
|
|
1938
|
+
with loader.begin() as conn:
|
|
1939
|
+
actual_events = conn.exec_driver_sql("SELECT count(*) FROM events").scalar() or 0
|
|
1940
|
+
actual_threads = conn.exec_driver_sql("SELECT count(*) FROM threads").scalar() or 0
|
|
1941
|
+
if actual_events != counts["events"]:
|
|
1942
|
+
counts["events_collapsed"] = counts["events"] - actual_events
|
|
1943
|
+
counts["events"] = int(actual_events)
|
|
1944
|
+
if actual_threads != counts["threads"]:
|
|
1945
|
+
counts["threads"] = int(actual_threads)
|
|
1946
|
+
for name, model in _CROSS_THREAD.items():
|
|
1947
|
+
counts[name] = _load_table(model, d / f"{name}.jsonl", loader, errors=parse_errors)
|
|
1948
|
+
# Source-import watermarks: seed from the checkpoint snapshot, then
|
|
1949
|
+
# overlay the previous live index's fresher rows (see _carry_import_state).
|
|
1950
|
+
_load_table(ImportState, d / "import_state.jsonl", loader, errors=parse_errors)
|
|
1951
|
+
counts["import_state"] = _carry_import_state(index_path, loader)
|
|
1952
|
+
counts["kg_events"] = _replay_kg_events(d, loader, errors=parse_errors)
|
|
1953
|
+
|
|
1954
|
+
# Fail closed on committed-content loss (see docstring): crash
|
|
1955
|
+
# fragments never block recovery, but a rebuild missing records the
|
|
1956
|
+
# current index holds must not be published over it.
|
|
1957
|
+
torn_tails, interior = _classify_parse_errors(parse_errors)
|
|
1958
|
+
counts["parse_errors_torn_tail"] = len(torn_tails)
|
|
1959
|
+
counts["parse_errors_interior"] = len(interior)
|
|
1960
|
+
if not salvage:
|
|
1961
|
+
lost = _committed_regression(index_path, tmp_path)
|
|
1962
|
+
if lost is not None and (lost["events"] or lost["kg_events"] or lost["threads"]):
|
|
1963
|
+
err_sample = ", ".join(f"{p}:{ln}" for p, ln in interior[:5])
|
|
1964
|
+
raise RuntimeError(
|
|
1965
|
+
f"reindex: the rebuild would lose {lost['events']} committed "
|
|
1966
|
+
f"event(s), {lost['kg_events']} curation event(s) and "
|
|
1967
|
+
f"{lost['threads']} thread(s) the current index holds "
|
|
1968
|
+
f"(event sample: {lost['event_sample']}; thread sample: "
|
|
1969
|
+
f"{lost['thread_sample']}) "
|
|
1970
|
+
"— refusing to publish; the old index was left in place. "
|
|
1971
|
+
+ (f"Likely cause: {len(interior)} damaged truth line(s) "
|
|
1972
|
+
f"({err_sample}). " if interior else "")
|
|
1973
|
+
+ "Repair the truth (or restore it from backup), or rerun "
|
|
1974
|
+
"with --salvage to publish the lossy rebuild anyway."
|
|
1975
|
+
)
|
|
1976
|
+
# Report-only, salvage or not: same-id content the publish will
|
|
1977
|
+
# overwrite in the index. The truth wins by design — this is the
|
|
1978
|
+
# last observable moment of the overwrite, not a gate (see
|
|
1979
|
+
# _content_divergence).
|
|
1980
|
+
diverged = _content_divergence(index_path, tmp_path)
|
|
1981
|
+
if diverged and diverged["events"]:
|
|
1982
|
+
counts["content_overwrites"] = diverged["events"]
|
|
1983
|
+
counts["content_overwrite_sample"] = diverged["sample"]
|
|
1984
|
+
logger.warning(
|
|
1985
|
+
"reindex: publishing content for %d event id(s) that disagrees "
|
|
1986
|
+
"with the live index (sample: %s) — the truth wins by design; "
|
|
1987
|
+
"if this is unexpected, adjudicate against a backup generation "
|
|
1988
|
+
"before the next backup run propagates it",
|
|
1989
|
+
diverged["events"], diverged["sample"],
|
|
1990
|
+
)
|
|
1991
|
+
counts.update(_reconcile_collapsed_citations(d, loader))
|
|
1992
|
+
|
|
1993
|
+
# FTS + vectors resolve their engine via get_engine(); point them at
|
|
1994
|
+
# the build for the block.
|
|
1995
|
+
with use_engine(loader):
|
|
1996
|
+
from .._retrieval.fts import rebuild_fts
|
|
1997
|
+
|
|
1998
|
+
counts["fts"] = rebuild_fts()
|
|
1999
|
+
# Vectors always survive the rebuild: restore the durable sidecar
|
|
2000
|
+
# cache (space-key-guarded; the hours-long embed runs once, ever)
|
|
2001
|
+
# into the build regardless of the ``vectors`` flag — a plain
|
|
2002
|
+
# reindex must not silently swap away the semantic index.
|
|
2003
|
+
# ``vectors=True`` additionally embeds whatever the cache lacks and
|
|
2004
|
+
# refreshes the sidecar. Degrades to 0 without a sidecar or the
|
|
2005
|
+
# [embeddings] extra — the store stays lexical-only.
|
|
2006
|
+
from .._retrieval import vectors as _vec
|
|
2007
|
+
|
|
2008
|
+
counts["vectors_restored"] = _vec.load_vectors_sidecar(d)
|
|
2009
|
+
# The sidecar may carry vectors for event ids the rebuild
|
|
2010
|
+
# collapsed away (superseded same-content twins) — prune them
|
|
2011
|
+
# so the vector arm never scores rows that can't hydrate.
|
|
2012
|
+
with loader.begin() as conn:
|
|
2013
|
+
has_vec = conn.exec_driver_sql(
|
|
2014
|
+
"SELECT 1 FROM sqlite_master WHERE name='event_vectors'"
|
|
2015
|
+
).scalar()
|
|
2016
|
+
pruned = conn.exec_driver_sql(
|
|
2017
|
+
"DELETE FROM event_vectors "
|
|
2018
|
+
"WHERE event_id NOT IN (SELECT id FROM events)"
|
|
2019
|
+
).rowcount if has_vec else 0
|
|
2020
|
+
if pruned:
|
|
2021
|
+
counts["vectors_pruned"] = pruned
|
|
2022
|
+
if vectors:
|
|
2023
|
+
counts["vectors_embedded"] = _vec.index_events_local(rebuild=False)
|
|
2024
|
+
counts["vectors_cached"] = _vec.save_vectors_sidecar(d)
|
|
2025
|
+
except BaseException:
|
|
2026
|
+
loader.dispose()
|
|
2027
|
+
_unlink_build(tmp_path) # the old index was never touched
|
|
2028
|
+
raise
|
|
2029
|
+
loader.dispose()
|
|
2030
|
+
|
|
2031
|
+
# Relational gate on the FINISHED build (after the reconciliation pass
|
|
2032
|
+
# has run its deterministic repairs): the FK-OFF OR REPLACE load can
|
|
2033
|
+
# orphan children when conflicting parent records collide, and a
|
|
2034
|
+
# structurally inconsistent build must not be published. Salvage
|
|
2035
|
+
# overrides, like the loss gate.
|
|
2036
|
+
if not salvage:
|
|
2037
|
+
fk_violations = _build_fk_violations(tmp_path)
|
|
2038
|
+
if fk_violations:
|
|
2039
|
+
_unlink_build(tmp_path)
|
|
2040
|
+
raise RuntimeError(
|
|
2041
|
+
"reindex: the rebuild is relationally inconsistent — "
|
|
2042
|
+
f"foreign_key_check reported {len(fk_violations)} violation(s) "
|
|
2043
|
+
f"(sample: {fk_violations[:5]}) — refusing to publish; the old "
|
|
2044
|
+
"index was left in place. Likely cause: conflicting thread "
|
|
2045
|
+
"records in the truth (OR REPLACE dropped a parent row). "
|
|
2046
|
+
"Repair the truth, or rerun with --salvage to publish anyway."
|
|
2047
|
+
)
|
|
2048
|
+
|
|
2049
|
+
# Page-level gate: a build file corrupted on disk (a bad write during the
|
|
2050
|
+
# hours-long rebuild) must not replace a healthy index. Before the WAL
|
|
2051
|
+
# fold, and read-write: a WAL-mode database refuses a read-only open
|
|
2052
|
+
# once its sidecars are gone.
|
|
2053
|
+
qconn = sqlite3.connect(tmp_path)
|
|
2054
|
+
try:
|
|
2055
|
+
qc = [r[0] for r in qconn.execute("PRAGMA quick_check(10)").fetchall()]
|
|
2056
|
+
finally:
|
|
2057
|
+
qconn.close()
|
|
2058
|
+
if qc != ["ok"]:
|
|
2059
|
+
_unlink_build(tmp_path)
|
|
2060
|
+
raise RuntimeError(
|
|
2061
|
+
f"reindex: rebuilt index failed quick_check ({'; '.join(map(str, qc))}) "
|
|
2062
|
+
"— the old index was left in place"
|
|
2063
|
+
)
|
|
2064
|
+
_fold_wal(tmp_path)
|
|
2065
|
+
|
|
2066
|
+
# Publish: dispose the live engine's pools first (its connections point at
|
|
2067
|
+
# the file being replaced), atomically rename the build over index.db, then
|
|
2068
|
+
# drop the old sidecars — a stale -wal must never be replayed into the new
|
|
2069
|
+
# database. The next get_engine() connection opens the new file.
|
|
2070
|
+
engine.dispose()
|
|
2071
|
+
os.replace(tmp_path, index_path)
|
|
2072
|
+
for suffix in ("-wal", "-shm"):
|
|
2073
|
+
Path(f"{index_path}{suffix}").unlink(missing_ok=True)
|
|
2074
|
+
|
|
2075
|
+
counts["parse_errors"] = len(parse_errors)
|
|
2076
|
+
|
|
2077
|
+
# The topic graph caches a projection per engine; drop it so the next read
|
|
2078
|
+
# rebuilds over the freshly-loaded thread_links.
|
|
2079
|
+
from .._knowledge import reset_cache as _reset_kg
|
|
2080
|
+
|
|
2081
|
+
_reset_kg()
|
|
2082
|
+
|
|
2083
|
+
logger.info("jsonl_log reindex: %s", counts)
|
|
2084
|
+
return counts
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
# ── truth emit (the single per-thread file writer; no-drift seam) ────────────
|
|
2088
|
+
def emit_thread_file(d: Path, thread_id: int, depth: int, thread_record, event_records) -> int:
|
|
2089
|
+
"""Atomically (re)write one ``threads/<id>.jsonl``: an optional ``type:thread``
|
|
2090
|
+
record then the ``type:event`` records, in order. The one place the on-disk
|
|
2091
|
+
per-thread format is produced (used by the store re-emit), so the layout can't
|
|
2092
|
+
drift. Returns the event count written."""
|
|
2093
|
+
path = _thread_file(d, thread_id, depth)
|
|
2094
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2095
|
+
tmp = path.with_suffix(".jsonl.tmp")
|
|
2096
|
+
n_ev = 0
|
|
2097
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
2098
|
+
if thread_record is not None:
|
|
2099
|
+
fh.write(json.dumps({"type": "thread", **thread_record}, default=_json_default, ensure_ascii=False))
|
|
2100
|
+
fh.write("\n")
|
|
2101
|
+
for ev in event_records:
|
|
2102
|
+
fh.write(json.dumps({"type": "event", **ev}, default=_json_default, ensure_ascii=False))
|
|
2103
|
+
fh.write("\n")
|
|
2104
|
+
n_ev += 1
|
|
2105
|
+
fh.flush()
|
|
2106
|
+
os.fsync(fh.fileno()) # durable before the rename makes it visible
|
|
2107
|
+
os.replace(tmp, path)
|
|
2108
|
+
_fsync_dir(path.parent) # the rename itself must survive power loss
|
|
2109
|
+
return n_ev
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def _hash_key_check(payload: object, dedup_key: str) -> bool | None:
|
|
2113
|
+
"""True = the payload re-hashes to the content hash embedded in its own
|
|
2114
|
+
``dedup_key`` (the last ``:``-segment; see
|
|
2115
|
+
``thread_archive._thread_import.event_builder.compute_dedup_key``); False = mismatch;
|
|
2116
|
+
None = the key carries no hash tail (nothing to validate against)."""
|
|
2117
|
+
import re as _re
|
|
2118
|
+
|
|
2119
|
+
from thread_archive._thread_import.event_builder import compute_content_hash
|
|
2120
|
+
|
|
2121
|
+
if not _re.match(r"^[0-9a-f]{16}$", dedup_key.rsplit(":", 1)[-1]):
|
|
2122
|
+
return None
|
|
2123
|
+
if not isinstance(payload, dict):
|
|
2124
|
+
return False
|
|
2125
|
+
return compute_content_hash(payload) == dedup_key.rsplit(":", 1)[-1]
|
|
2126
|
+
|
|
2127
|
+
|
|
2128
|
+
def _store_rows_failing_key_hash() -> tuple[int, list[str]]:
|
|
2129
|
+
"""Store event rows whose payload no longer re-hashes to the content hash
|
|
2130
|
+
embedded in their own ``dedup_key`` — the content half of the pre-flight
|
|
2131
|
+
behind :func:`rebuild_truth_from_store`. The containment check proves the
|
|
2132
|
+
store holds every truth *unit*; this proves the payloads behind those units
|
|
2133
|
+
are self-consistent, so a corrupted index row (rot, a bad in-place write)
|
|
2134
|
+
that kept its id and key can't be promoted over the good truth line by a
|
|
2135
|
+
re-emit. Returns ``(failing, sample)``."""
|
|
2136
|
+
failing = 0
|
|
2137
|
+
sample: list[str] = []
|
|
2138
|
+
with get_session() as s:
|
|
2139
|
+
conn = s.connection().connection # raw sqlite3 — stream, don't materialize
|
|
2140
|
+
for ev_id, tid, key, payload_text in conn.execute(
|
|
2141
|
+
"SELECT id, thread_id, dedup_key, payload FROM events "
|
|
2142
|
+
"WHERE dedup_key IS NOT NULL"
|
|
2143
|
+
):
|
|
2144
|
+
try:
|
|
2145
|
+
payload = (
|
|
2146
|
+
json.loads(payload_text)
|
|
2147
|
+
if isinstance(payload_text, str) else payload_text
|
|
2148
|
+
)
|
|
2149
|
+
except ValueError:
|
|
2150
|
+
payload = None
|
|
2151
|
+
if _hash_key_check(payload, key) is False:
|
|
2152
|
+
failing += 1
|
|
2153
|
+
if len(sample) < 10:
|
|
2154
|
+
sample.append(f"thread {tid}: event {ev_id}")
|
|
2155
|
+
return failing, sample
|
|
2156
|
+
|
|
2157
|
+
|
|
2158
|
+
def _truth_units_missing_from_store(d: Path) -> tuple[int, list[str], dict[str, int]]:
|
|
2159
|
+
"""Content units present in the truth but absent from the store — the
|
|
2160
|
+
pre-flight behind :func:`rebuild_truth_from_store`. A *unit* is an event's
|
|
2161
|
+
content identity: its ``dedup_key`` (thread-scoped), falling back to the
|
|
2162
|
+
event id when the key is NULL — the same collapse ``scan_truth_counts`` and
|
|
2163
|
+
the reindex loader apply. Count parity can hide compensating errors (an
|
|
2164
|
+
index-only event masking a missing one, swapped payloads behind equal
|
|
2165
|
+
totals); containment can't: every effective truth unit must exist in the
|
|
2166
|
+
store, or a re-emit would destroy content the index never had. Walked one
|
|
2167
|
+
thread at a time so memory stays bounded.
|
|
2168
|
+
|
|
2169
|
+
The same walk collects ``dropped_keys``: fields on truth records (event,
|
|
2170
|
+
thread, kg_event) that the running code's models don't map. ``_coerce``
|
|
2171
|
+
drops them on reload — harmless for the projection — but a re-emit rewrites
|
|
2172
|
+
the truth *without* them, which turns that tolerance into permanent loss;
|
|
2173
|
+
the caller refuses on any. Returns ``(missing, sample, dropped_keys)``
|
|
2174
|
+
where ``dropped_keys`` maps ``"<kind>.<field>"`` to its occurrence count."""
|
|
2175
|
+
threads_dir = d / THREADS_SUBDIR
|
|
2176
|
+
files_by_stem: dict[str, list[Path]] = {}
|
|
2177
|
+
if threads_dir.exists():
|
|
2178
|
+
for path in threads_dir.rglob("*.jsonl"):
|
|
2179
|
+
files_by_stem.setdefault(path.stem, []).append(path)
|
|
2180
|
+
valid_keys = {
|
|
2181
|
+
"event": {c.key for c in Event.__table__.columns},
|
|
2182
|
+
"thread": {c.key for c in Thread.__table__.columns},
|
|
2183
|
+
"kg_event": {c.key for c in KgEvent.__table__.columns},
|
|
2184
|
+
}
|
|
2185
|
+
dropped: dict[str, int] = {}
|
|
2186
|
+
|
|
2187
|
+
def _note_unknown(kind: str, rec: dict) -> None:
|
|
2188
|
+
for k in rec.keys() - valid_keys[kind] - {"type"}:
|
|
2189
|
+
dropped[f"{kind}.{k}"] = dropped.get(f"{kind}.{k}", 0) + 1
|
|
2190
|
+
|
|
2191
|
+
missing = 0
|
|
2192
|
+
sample: list[str] = []
|
|
2193
|
+
with get_session() as s:
|
|
2194
|
+
conn = s.connection().connection # raw sqlite3 — stream, don't materialize
|
|
2195
|
+
for stem, paths in files_by_stem.items():
|
|
2196
|
+
try:
|
|
2197
|
+
tid = int(stem)
|
|
2198
|
+
except ValueError: # pragma: no cover — stray file
|
|
2199
|
+
continue
|
|
2200
|
+
units: set = set()
|
|
2201
|
+
for path in paths:
|
|
2202
|
+
for rec in _iter_jsonl(path):
|
|
2203
|
+
kind = rec.get("type", "event")
|
|
2204
|
+
if kind in valid_keys:
|
|
2205
|
+
_note_unknown(kind, rec)
|
|
2206
|
+
if kind != "event" or rec.get("id") is None:
|
|
2207
|
+
continue
|
|
2208
|
+
units.add(rec.get("dedup_key") or ("id", int(rec["id"])))
|
|
2209
|
+
if not units:
|
|
2210
|
+
continue
|
|
2211
|
+
for ev_id, key in conn.execute(
|
|
2212
|
+
"SELECT id, dedup_key FROM events WHERE thread_id = ?", (tid,)
|
|
2213
|
+
):
|
|
2214
|
+
units.discard(key or ("id", int(ev_id)))
|
|
2215
|
+
missing += len(units)
|
|
2216
|
+
for unit in sorted(map(str, units))[: max(0, 10 - len(sample))]:
|
|
2217
|
+
sample.append(f"thread {tid}: {unit}")
|
|
2218
|
+
if (d / KG_EVENTS_FILE).exists():
|
|
2219
|
+
for rec in _iter_jsonl(d / KG_EVENTS_FILE):
|
|
2220
|
+
_note_unknown("kg_event", rec)
|
|
2221
|
+
return missing, sample, dropped
|
|
2222
|
+
|
|
2223
|
+
|
|
2224
|
+
def rebuild_truth_from_store(*, force: bool = False) -> dict:
|
|
2225
|
+
"""Re-emit the entire per-thread truth from the current SQLite store.
|
|
2226
|
+
|
|
2227
|
+
The inverse of :func:`reindex`: for every thread, (re)write ``threads/<id>.jsonl``
|
|
2228
|
+
as its metadata record + ordered events; rewrite the cross-thread snapshots; pick
|
|
2229
|
+
the shard depth for the thread count; set the manifest. Used to migrate an
|
|
2230
|
+
older monolithic ``events.jsonl`` into per-thread files (the old monolith files,
|
|
2231
|
+
if present, are removed), and to finish an index-only repair pass (e.g. the
|
|
2232
|
+
dedup-key backfill) by making the truth match. Idempotent — safe to re-run.
|
|
2233
|
+
|
|
2234
|
+
This is the ONE operation that overwrites truth from the projection — the
|
|
2235
|
+
reverse of the normal flow — so it protects itself: it holds the reindex lock
|
|
2236
|
+
**exclusive** for the duration (no writer can append truth or commit to the
|
|
2237
|
+
index mid-emission; in-tree writers all hold it shared), and it refuses to run
|
|
2238
|
+
unless the store *contains* every effective content unit the truth holds
|
|
2239
|
+
(:func:`_truth_units_missing_from_store` — per-unit containment, not count
|
|
2240
|
+
parity, so a missing event can't hide behind an index-only one). A repair
|
|
2241
|
+
pass that rewrites payloads in place keeps its units (the ``dedup_key``
|
|
2242
|
+
column carries the identity), so the intended use survives the gate.
|
|
2243
|
+
|
|
2244
|
+
Two further pre-flights guard the *content* of what gets written: the truth
|
|
2245
|
+
must carry no fields the running code's models don't map (``_coerce``
|
|
2246
|
+
tolerates them on reload, but a re-emit would drop them from the truth
|
|
2247
|
+
forever — an older binary must not lossily rewrite newer truth), and every
|
|
2248
|
+
store payload with a hash-tailed ``dedup_key`` must still re-hash to it
|
|
2249
|
+
(:func:`_store_rows_failing_key_hash` — a corrupted index row that kept its
|
|
2250
|
+
id and key must not replace the good truth line).
|
|
2251
|
+
|
|
2252
|
+
``force=True`` overrides the pre-flights only (for a deliberate, understood
|
|
2253
|
+
shrink or drop — e.g. a duplicate-collapse repair, or an in-place payload
|
|
2254
|
+
repair that didn't recompute its keys); it never skips the lock."""
|
|
2255
|
+
d = log_dir()
|
|
2256
|
+
(d / THREADS_SUBDIR).mkdir(parents=True, exist_ok=True)
|
|
2257
|
+
|
|
2258
|
+
with _hold_reindex_lock():
|
|
2259
|
+
# Resolve any crashed drain's leftover intent first: a stale intent
|
|
2260
|
+
# surviving past the re-emit would frame baselines that no longer
|
|
2261
|
+
# describe the (replaced) files. The tail check would refuse to cut
|
|
2262
|
+
# them anyway; resolving here keeps that guard a backstop, not a path.
|
|
2263
|
+
with _truth_write_lock():
|
|
2264
|
+
pass
|
|
2265
|
+
if not force:
|
|
2266
|
+
missing, sample, dropped = _truth_units_missing_from_store(d)
|
|
2267
|
+
if missing:
|
|
2268
|
+
raise RuntimeError(
|
|
2269
|
+
f"rebuild_truth_from_store: the store lacks {missing} event(s) "
|
|
2270
|
+
f"the truth holds (sample: {sample}) — re-emitting would destroy "
|
|
2271
|
+
"truth content the index lacks. Run `archive reindex` first "
|
|
2272
|
+
"(or pass force=True if the shrink is intended)."
|
|
2273
|
+
)
|
|
2274
|
+
if dropped:
|
|
2275
|
+
fields = ", ".join(f"{k} ×{v}" for k, v in sorted(dropped.items()))
|
|
2276
|
+
raise RuntimeError(
|
|
2277
|
+
f"rebuild_truth_from_store: the truth carries field(s) the running "
|
|
2278
|
+
f"code's models don't map ({fields}) — re-emitting would silently "
|
|
2279
|
+
"drop them from the truth forever. Run the code version that wrote "
|
|
2280
|
+
"them (or pass force=True if the drop is intended)."
|
|
2281
|
+
)
|
|
2282
|
+
failing, bad_sample = _store_rows_failing_key_hash()
|
|
2283
|
+
if failing:
|
|
2284
|
+
raise RuntimeError(
|
|
2285
|
+
f"rebuild_truth_from_store: {failing} store payload(s) fail their "
|
|
2286
|
+
f"own dedup-key content hash (sample: {bad_sample}) — re-emitting "
|
|
2287
|
+
"would promote suspect index content over the existing truth. "
|
|
2288
|
+
"Investigate with `archive verify --hashes` and repair the index "
|
|
2289
|
+
"(`archive reindex`) first, or pass force=True if the payloads are "
|
|
2290
|
+
"known-good (an in-place repair that didn't recompute its keys)."
|
|
2291
|
+
)
|
|
2292
|
+
return _rebuild_truth_from_store_locked(d)
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
def _rebuild_truth_from_store_locked(d: Path) -> dict:
|
|
2296
|
+
with get_session() as s:
|
|
2297
|
+
threads = s.execute(select(Thread).order_by(Thread.id)).scalars().all()
|
|
2298
|
+
depth = _depth_for(len(threads))
|
|
2299
|
+
nt = ne = 0
|
|
2300
|
+
emitted: set[int] = set()
|
|
2301
|
+
for t in threads: # outer list is materialized, so the inner event stream is the only cursor
|
|
2302
|
+
ev_rows = (_row_dict(ev) for ev in s.execute(
|
|
2303
|
+
select(Event).where(Event.thread_id == t.id).order_by(Event.id)
|
|
2304
|
+
).scalars())
|
|
2305
|
+
ne += emit_thread_file(d, t.id, depth, _row_dict(t), ev_rows)
|
|
2306
|
+
emitted.add(t.id)
|
|
2307
|
+
nt += 1
|
|
2308
|
+
|
|
2309
|
+
for name, model in _CROSS_THREAD.items():
|
|
2310
|
+
_write_snapshot(d, name, model)
|
|
2311
|
+
_write_snapshot(d, "import_state", ImportState) # cursors survive the re-emit too
|
|
2312
|
+
|
|
2313
|
+
# Locked read-modify-write: the re-emit owns the layout keys, not the whole
|
|
2314
|
+
# manifest — a verify run's hashes baseline (or any future key) survives.
|
|
2315
|
+
update_manifest(d, lambda m: m.update(
|
|
2316
|
+
{"version": TRUTH_FORMAT_VERSION, "shard_depth": depth, "last_checkpoint_at": _now_iso()}
|
|
2317
|
+
))
|
|
2318
|
+
|
|
2319
|
+
# Remove stale copies of re-emitted threads left at another shard depth. Their
|
|
2320
|
+
# content was just fully re-emitted at ``depth``, so an old-layout copy is pure
|
|
2321
|
+
# duplication — and on a later reindex a stale duplicate line would shadow the
|
|
2322
|
+
# fresh (possibly repaired) row for the same event id. Files whose ids the store
|
|
2323
|
+
# does NOT hold are left untouched.
|
|
2324
|
+
for path in list((d / THREADS_SUBDIR).rglob("*.jsonl")):
|
|
2325
|
+
try:
|
|
2326
|
+
tid = int(path.stem)
|
|
2327
|
+
except ValueError: # pragma: no cover — stray file
|
|
2328
|
+
continue
|
|
2329
|
+
if tid in emitted and path != _thread_file(d, tid, depth):
|
|
2330
|
+
path.unlink()
|
|
2331
|
+
|
|
2332
|
+
# Drop the old monolithic files this format replaces.
|
|
2333
|
+
for old in ("events.jsonl", "threads.jsonl"):
|
|
2334
|
+
p = d / old
|
|
2335
|
+
if p.exists():
|
|
2336
|
+
p.unlink()
|
|
2337
|
+
|
|
2338
|
+
result = {"threads": nt, "events": ne, "shard_depth": depth}
|
|
2339
|
+
logger.info("jsonl_log rebuild_truth_from_store: %s", result)
|
|
2340
|
+
return result
|