errlore 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- errlore/__init__.py +16 -0
- errlore/errmem/__init__.py +21 -0
- errlore/errmem/classifier.py +54 -0
- errlore/errmem/injector.py +156 -0
- errlore/errmem/patterns.py +193 -0
- errlore/errmem/tracker.py +157 -0
- errlore/facade.py +602 -0
- errlore/io/__init__.py +21 -0
- errlore/io/jsonl_index.py +199 -0
- errlore/io/jsonl_writer.py +342 -0
- errlore/io/repair.py +199 -0
- errlore/lessons/__init__.py +16 -0
- errlore/lessons/models.py +129 -0
- errlore/lessons/store.py +495 -0
- errlore/py.typed +0 -0
- errlore/retrieval/__init__.py +66 -0
- errlore/retrieval/backend.py +117 -0
- errlore/retrieval/index.py +288 -0
- errlore/sanitize.py +117 -0
- errlore/trust/__init__.py +11 -0
- errlore/trust/engine.py +602 -0
- errlore-0.1.0.dist-info/METADATA +214 -0
- errlore-0.1.0.dist-info/RECORD +25 -0
- errlore-0.1.0.dist-info/WHEEL +4 -0
- errlore-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""JSONL sidecar index for O(1) access to records by byte offset.
|
|
2
|
+
|
|
3
|
+
Each JSONL file can have a companion .idx file storing 8-byte big-endian
|
|
4
|
+
unsigned offsets. This enables:
|
|
5
|
+
- get_recent() via tail-read without scanning the entire file
|
|
6
|
+
- O(1) append (add offset to end of .idx)
|
|
7
|
+
- rebuild when the index is corrupted
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import struct
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("errlore.io")
|
|
22
|
+
|
|
23
|
+
# Each entry in .idx = 8 bytes (unsigned long long, big-endian)
|
|
24
|
+
_OFFSET_FORMAT = ">Q"
|
|
25
|
+
_OFFSET_SIZE = struct.calcsize(_OFFSET_FORMAT)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class JSONLIndex:
|
|
29
|
+
"""Byte-offset index for a JSONL file."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, jsonl_path: Path) -> None:
|
|
32
|
+
self.jsonl_path = Path(jsonl_path)
|
|
33
|
+
self.idx_path = self.jsonl_path.with_suffix(".idx")
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
def append_offset(self, byte_offset: int) -> None:
|
|
37
|
+
"""Append an offset for a new record to the index."""
|
|
38
|
+
with self._lock:
|
|
39
|
+
self.idx_path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with open(self.idx_path, "ab") as f:
|
|
41
|
+
f.write(struct.pack(_OFFSET_FORMAT, byte_offset))
|
|
42
|
+
|
|
43
|
+
def _read_all_offsets(self) -> list[int]:
|
|
44
|
+
"""Read all valid offsets from .idx, discarding any truncated tail."""
|
|
45
|
+
if not self.idx_path.exists():
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
file_size = self.idx_path.stat().st_size
|
|
49
|
+
if file_size == 0:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
aligned_size = file_size - (file_size % _OFFSET_SIZE)
|
|
53
|
+
if aligned_size != file_size:
|
|
54
|
+
logger.warning(
|
|
55
|
+
"Index file %s has truncated tail (%d bytes ignored)",
|
|
56
|
+
self.idx_path.name,
|
|
57
|
+
file_size - aligned_size,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if aligned_size == 0:
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
offsets: list[int] = []
|
|
64
|
+
with open(self.idx_path, "rb") as f:
|
|
65
|
+
data = f.read(aligned_size)
|
|
66
|
+
for i in range(0, aligned_size, _OFFSET_SIZE):
|
|
67
|
+
offsets.append(struct.unpack(_OFFSET_FORMAT, data[i : i + _OFFSET_SIZE])[0])
|
|
68
|
+
return offsets
|
|
69
|
+
|
|
70
|
+
def get_recent_offsets(self, count: int) -> list[int]:
|
|
71
|
+
"""Get byte-offsets of the last N records."""
|
|
72
|
+
if count <= 0:
|
|
73
|
+
return []
|
|
74
|
+
with self._lock:
|
|
75
|
+
offsets = self._read_all_offsets()
|
|
76
|
+
total_entries = len(offsets)
|
|
77
|
+
if total_entries == 0:
|
|
78
|
+
return []
|
|
79
|
+
start = max(0, total_entries - count)
|
|
80
|
+
return offsets[start:]
|
|
81
|
+
|
|
82
|
+
def read_records_at_offsets(self, offsets: list[int]) -> list[dict[str, object]]:
|
|
83
|
+
"""Read records from the JSONL file at specified byte-offsets."""
|
|
84
|
+
if not self.jsonl_path.exists():
|
|
85
|
+
return []
|
|
86
|
+
records: list[dict[str, object]] = []
|
|
87
|
+
with open(self.jsonl_path, encoding="utf-8") as f:
|
|
88
|
+
for offset in offsets:
|
|
89
|
+
try:
|
|
90
|
+
f.seek(offset)
|
|
91
|
+
line = f.readline()
|
|
92
|
+
if line.strip():
|
|
93
|
+
records.append(json.loads(line))
|
|
94
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
95
|
+
logger.warning("Corrupted record at offset %d: %s", offset, e)
|
|
96
|
+
return records
|
|
97
|
+
|
|
98
|
+
def get_recent_records(self, count: int) -> list[dict[str, object]]:
|
|
99
|
+
"""Get the last N records via the index. O(1) per seek."""
|
|
100
|
+
offsets = self.get_recent_offsets(count)
|
|
101
|
+
if not offsets:
|
|
102
|
+
return []
|
|
103
|
+
return self.read_records_at_offsets(offsets)
|
|
104
|
+
|
|
105
|
+
def entry_count(self) -> int:
|
|
106
|
+
"""Number of entries in the index."""
|
|
107
|
+
return len(self._read_all_offsets())
|
|
108
|
+
|
|
109
|
+
def rebuild(self) -> int:
|
|
110
|
+
"""Rebuild the index from the JSONL file.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Number of indexed entries.
|
|
114
|
+
"""
|
|
115
|
+
if not self.jsonl_path.exists():
|
|
116
|
+
if self.idx_path.exists():
|
|
117
|
+
try:
|
|
118
|
+
self.idx_path.unlink()
|
|
119
|
+
except OSError:
|
|
120
|
+
logger.warning("Failed to remove stale index file %s", self.idx_path)
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
count = 0
|
|
124
|
+
tmp_fd, tmp_path_str = tempfile.mkstemp(dir=self.idx_path.parent, suffix=".idx.tmp")
|
|
125
|
+
try:
|
|
126
|
+
with self._lock, os.fdopen(tmp_fd, "wb") as idx_f:
|
|
127
|
+
with open(self.jsonl_path, encoding="utf-8") as jsonl_f:
|
|
128
|
+
while True:
|
|
129
|
+
offset = jsonl_f.tell()
|
|
130
|
+
line = jsonl_f.readline()
|
|
131
|
+
if not line:
|
|
132
|
+
break
|
|
133
|
+
line = line.strip()
|
|
134
|
+
if not line:
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
json.loads(line)
|
|
138
|
+
idx_f.write(struct.pack(_OFFSET_FORMAT, offset))
|
|
139
|
+
count += 1
|
|
140
|
+
except json.JSONDecodeError:
|
|
141
|
+
logger.warning(
|
|
142
|
+
"Skipping corrupted line at offset %d during rebuild",
|
|
143
|
+
offset,
|
|
144
|
+
)
|
|
145
|
+
idx_f.flush()
|
|
146
|
+
os.fsync(idx_f.fileno())
|
|
147
|
+
os.replace(tmp_path_str, str(self.idx_path))
|
|
148
|
+
tmp_path_str = ""
|
|
149
|
+
finally:
|
|
150
|
+
if tmp_path_str:
|
|
151
|
+
with contextlib.suppress(OSError):
|
|
152
|
+
os.unlink(tmp_path_str)
|
|
153
|
+
logger.info("Rebuilt index for %s: %d entries", self.jsonl_path.name, count)
|
|
154
|
+
return count
|
|
155
|
+
|
|
156
|
+
def verify_integrity(self) -> tuple[bool, list[str]]:
|
|
157
|
+
"""Verify index integrity against the JSONL file.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Tuple of (is_valid, list_of_error_messages).
|
|
161
|
+
"""
|
|
162
|
+
errors: list[str] = []
|
|
163
|
+
|
|
164
|
+
if not self.idx_path.exists():
|
|
165
|
+
if self.jsonl_path.exists() and self.jsonl_path.stat().st_size > 0:
|
|
166
|
+
errors.append("Index file missing but JSONL has data")
|
|
167
|
+
return len(errors) == 0, errors
|
|
168
|
+
|
|
169
|
+
idx_size = self.idx_path.stat().st_size
|
|
170
|
+
if idx_size % _OFFSET_SIZE != 0:
|
|
171
|
+
errors.append(f"Index file size {idx_size} not aligned to {_OFFSET_SIZE} bytes")
|
|
172
|
+
|
|
173
|
+
if not self.jsonl_path.exists():
|
|
174
|
+
errors.append("JSONL file missing but index exists")
|
|
175
|
+
return False, errors
|
|
176
|
+
|
|
177
|
+
jsonl_size = self.jsonl_path.stat().st_size
|
|
178
|
+
offsets = self.get_recent_offsets(self.entry_count())
|
|
179
|
+
previous_offset = -1
|
|
180
|
+
for offset in offsets:
|
|
181
|
+
if offset < previous_offset:
|
|
182
|
+
errors.append("Offsets are not monotonically increasing")
|
|
183
|
+
previous_offset = offset
|
|
184
|
+
if offset >= jsonl_size:
|
|
185
|
+
errors.append(f"Offset {offset} exceeds JSONL size {jsonl_size}")
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
with open(self.jsonl_path, encoding="utf-8") as f:
|
|
190
|
+
f.seek(offset)
|
|
191
|
+
line = f.readline()
|
|
192
|
+
if not line.strip():
|
|
193
|
+
errors.append(f"Offset {offset} points to empty record")
|
|
194
|
+
continue
|
|
195
|
+
json.loads(line)
|
|
196
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
197
|
+
errors.append(f"Offset {offset} is invalid: {exc}")
|
|
198
|
+
|
|
199
|
+
return len(errors) == 0, errors
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Cross-process durable JSONL writer with filelock.
|
|
2
|
+
|
|
3
|
+
Guarantees append atomicity via:
|
|
4
|
+
- filelock (cross-process locking)
|
|
5
|
+
- binary mode "ab" (atomic writes below PIPE_BUF)
|
|
6
|
+
- explicit fsync by default
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import struct
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from filelock import FileLock
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable, Sequence
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("errlore.io")
|
|
28
|
+
|
|
29
|
+
_LOCK_TIMEOUT: float = 10.0
|
|
30
|
+
_REPLACE_RETRIES: int = 5
|
|
31
|
+
_REPLACE_BACKOFF: float = 0.5
|
|
32
|
+
|
|
33
|
+
_DEFAULT_MAX_BYTES: int = 50_000_000 # 50 MB
|
|
34
|
+
_DEFAULT_MAX_ARCHIVES: int = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class JSONLWriter:
|
|
38
|
+
"""Cross-process JSONL writer with filelock, rotation, and atomic rewrite.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
lock_timeout: Timeout in seconds for acquiring file locks.
|
|
42
|
+
max_bytes: Maximum file size before rotation. ``None`` disables
|
|
43
|
+
rotation entirely -- useful for files with ID-based lookups
|
|
44
|
+
(errors, lessons, injections) where all records must remain
|
|
45
|
+
visible. Growth is handled by future log compaction.
|
|
46
|
+
max_archives: Maximum number of rotated archive files to keep.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
lock_timeout: float = _LOCK_TIMEOUT,
|
|
52
|
+
max_bytes: int | None = _DEFAULT_MAX_BYTES,
|
|
53
|
+
max_archives: int = _DEFAULT_MAX_ARCHIVES,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._lock_timeout = lock_timeout
|
|
56
|
+
self._max_bytes = max_bytes
|
|
57
|
+
self._max_archives = max_archives
|
|
58
|
+
self._locks: dict[str, FileLock] = {}
|
|
59
|
+
self._lock_guard = threading.Lock()
|
|
60
|
+
# C1: mtime+size read cache -- avoids O(n) re-parse on hot paths.
|
|
61
|
+
# Key: str(path), value: (mtime_ns, size, records).
|
|
62
|
+
self._read_cache: dict[str, tuple[int, int, list[dict[str, object]]]] = {}
|
|
63
|
+
self._cache_lock = threading.Lock()
|
|
64
|
+
|
|
65
|
+
def _get_lock(self, path: Path) -> FileLock:
|
|
66
|
+
"""Get or create a FileLock for the given path (thread-safe)."""
|
|
67
|
+
key = str(path)
|
|
68
|
+
if key not in self._locks:
|
|
69
|
+
with self._lock_guard:
|
|
70
|
+
# Double-checked: another thread may have created it.
|
|
71
|
+
if key not in self._locks:
|
|
72
|
+
self._locks[key] = FileLock(
|
|
73
|
+
key + ".lock", timeout=self._lock_timeout,
|
|
74
|
+
)
|
|
75
|
+
return self._locks[key]
|
|
76
|
+
|
|
77
|
+
def lock(self, path: Path) -> FileLock:
|
|
78
|
+
"""Return the FileLock for *path* (public access for callers that
|
|
79
|
+
need to wrap broader read-modify-write cycles under the same lock).
|
|
80
|
+
|
|
81
|
+
The returned ``FileLock`` is re-entrant within the same thread, so
|
|
82
|
+
nested ``append`` / ``read_all`` calls inside a ``with writer.lock(p):``
|
|
83
|
+
block will not deadlock.
|
|
84
|
+
"""
|
|
85
|
+
return self._get_lock(path)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _rotate_versioned_files(path: Path, max_archives: int) -> None:
|
|
89
|
+
"""Shift archive chain for a versioned file.
|
|
90
|
+
|
|
91
|
+
Works for both *.jsonl and *.idx: current becomes .1,
|
|
92
|
+
previous .1 becomes .2, etc.
|
|
93
|
+
"""
|
|
94
|
+
for i in range(max_archives, 0, -1):
|
|
95
|
+
src = path.with_suffix(f"{path.suffix}.{i}")
|
|
96
|
+
dst = path.with_suffix(f"{path.suffix}.{i + 1}")
|
|
97
|
+
if i == max_archives and src.exists():
|
|
98
|
+
src.unlink()
|
|
99
|
+
elif src.exists():
|
|
100
|
+
src.replace(dst)
|
|
101
|
+
|
|
102
|
+
if path.exists():
|
|
103
|
+
path.replace(path.with_suffix(f"{path.suffix}.1"))
|
|
104
|
+
|
|
105
|
+
def _rotate_if_needed(self, path: Path) -> None:
|
|
106
|
+
"""Rotate the JSONL file when it exceeds max_bytes.
|
|
107
|
+
|
|
108
|
+
When ``max_bytes`` is ``None``, rotation is disabled.
|
|
109
|
+
"""
|
|
110
|
+
if self._max_bytes is None:
|
|
111
|
+
return
|
|
112
|
+
if not path.exists() or path.stat().st_size < self._max_bytes:
|
|
113
|
+
return
|
|
114
|
+
idx_path = path.with_suffix(".idx")
|
|
115
|
+
self._rotate_versioned_files(idx_path, self._max_archives)
|
|
116
|
+
self._rotate_versioned_files(path, self._max_archives)
|
|
117
|
+
|
|
118
|
+
def append(
|
|
119
|
+
self,
|
|
120
|
+
path: Path,
|
|
121
|
+
entry: dict[str, object],
|
|
122
|
+
*,
|
|
123
|
+
fsync: bool = True,
|
|
124
|
+
) -> int:
|
|
125
|
+
"""Append a single record to a JSONL file (cross-process safe).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
path: Path to the JSONL file.
|
|
129
|
+
entry: Dictionary record to write.
|
|
130
|
+
fsync: Force fsync after write (default True).
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Byte offset of the written line's start.
|
|
134
|
+
"""
|
|
135
|
+
lock = self._get_lock(path)
|
|
136
|
+
line = (json.dumps(entry, ensure_ascii=False, default=str) + "\n").encode("utf-8")
|
|
137
|
+
|
|
138
|
+
with lock:
|
|
139
|
+
self._rotate_if_needed(path)
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
with open(path, "ab") as f:
|
|
142
|
+
offset = f.tell()
|
|
143
|
+
f.write(line)
|
|
144
|
+
if fsync:
|
|
145
|
+
f.flush()
|
|
146
|
+
os.fsync(f.fileno())
|
|
147
|
+
return offset
|
|
148
|
+
|
|
149
|
+
def append_batch(
|
|
150
|
+
self,
|
|
151
|
+
path: Path,
|
|
152
|
+
entries: Sequence[dict[str, object]],
|
|
153
|
+
*,
|
|
154
|
+
fsync: bool = True,
|
|
155
|
+
) -> int:
|
|
156
|
+
"""Append multiple records under a single lock acquisition.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
path: Path to the JSONL file.
|
|
160
|
+
entries: Sequence of dict records.
|
|
161
|
+
fsync: Force fsync after the entire batch (default True).
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Number of records written.
|
|
165
|
+
"""
|
|
166
|
+
if not entries:
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
lines = b"".join(
|
|
170
|
+
(json.dumps(e, ensure_ascii=False, default=str) + "\n").encode("utf-8")
|
|
171
|
+
for e in entries
|
|
172
|
+
)
|
|
173
|
+
lock = self._get_lock(path)
|
|
174
|
+
|
|
175
|
+
with lock:
|
|
176
|
+
self._rotate_if_needed(path)
|
|
177
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
with open(path, "ab") as f:
|
|
179
|
+
f.write(lines)
|
|
180
|
+
if fsync:
|
|
181
|
+
f.flush()
|
|
182
|
+
os.fsync(f.fileno())
|
|
183
|
+
return len(entries)
|
|
184
|
+
|
|
185
|
+
def atomic_rewrite(
|
|
186
|
+
self,
|
|
187
|
+
path: Path,
|
|
188
|
+
entries: Sequence[dict[str, object]],
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Atomically rewrite a JSONL file (filelock + tmp + os.replace).
|
|
191
|
+
|
|
192
|
+
Also rebuilds the sidecar .idx file atomically.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
path: Path to the JSONL file.
|
|
196
|
+
entries: Full list of records to write.
|
|
197
|
+
"""
|
|
198
|
+
from errlore.io.jsonl_index import JSONLIndex
|
|
199
|
+
|
|
200
|
+
lock = self._get_lock(path)
|
|
201
|
+
tmp_jsonl: str | None = None
|
|
202
|
+
tmp_idx: str | None = None
|
|
203
|
+
idx_path = path.with_suffix(".idx")
|
|
204
|
+
|
|
205
|
+
with lock:
|
|
206
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
data_fd, tmp_jsonl = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
208
|
+
idx_fd, tmp_idx = tempfile.mkstemp(dir=path.parent, suffix=".idx.tmp")
|
|
209
|
+
try:
|
|
210
|
+
with os.fdopen(data_fd, "wb") as data_f, os.fdopen(idx_fd, "wb") as idx_f:
|
|
211
|
+
offset = 0
|
|
212
|
+
for entry in entries:
|
|
213
|
+
line = (
|
|
214
|
+
json.dumps(entry, ensure_ascii=False, default=str) + "\n"
|
|
215
|
+
).encode("utf-8")
|
|
216
|
+
data_f.write(line)
|
|
217
|
+
idx_f.write(struct.pack(">Q", offset))
|
|
218
|
+
offset += len(line)
|
|
219
|
+
data_f.flush()
|
|
220
|
+
os.fsync(data_f.fileno())
|
|
221
|
+
idx_f.flush()
|
|
222
|
+
os.fsync(idx_f.fileno())
|
|
223
|
+
|
|
224
|
+
# Replace data file with retries (Windows Defender can hold handles)
|
|
225
|
+
for attempt in range(_REPLACE_RETRIES):
|
|
226
|
+
try:
|
|
227
|
+
if tmp_jsonl is not None:
|
|
228
|
+
os.replace(tmp_jsonl, str(path))
|
|
229
|
+
tmp_jsonl = None
|
|
230
|
+
break
|
|
231
|
+
except PermissionError:
|
|
232
|
+
if attempt < _REPLACE_RETRIES - 1:
|
|
233
|
+
time.sleep(_REPLACE_BACKOFF * (attempt + 1))
|
|
234
|
+
else:
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
# Replace index file
|
|
238
|
+
try:
|
|
239
|
+
if tmp_idx is not None:
|
|
240
|
+
os.replace(tmp_idx, str(idx_path))
|
|
241
|
+
tmp_idx = None
|
|
242
|
+
except PermissionError:
|
|
243
|
+
# If sidecar locked, rebuild index from fresh JSONL
|
|
244
|
+
JSONLIndex(path).rebuild()
|
|
245
|
+
if tmp_idx is not None:
|
|
246
|
+
with contextlib.suppress(OSError):
|
|
247
|
+
os.unlink(tmp_idx)
|
|
248
|
+
tmp_idx = None
|
|
249
|
+
finally:
|
|
250
|
+
if tmp_jsonl is not None:
|
|
251
|
+
with contextlib.suppress(OSError):
|
|
252
|
+
os.unlink(tmp_jsonl)
|
|
253
|
+
if tmp_idx is not None:
|
|
254
|
+
with contextlib.suppress(OSError):
|
|
255
|
+
os.unlink(tmp_idx)
|
|
256
|
+
|
|
257
|
+
def atomic_update(
|
|
258
|
+
self,
|
|
259
|
+
path: Path,
|
|
260
|
+
transform: Callable[[list[dict[str, object]]], list[dict[str, object]] | None],
|
|
261
|
+
) -> list[dict[str, object]] | None:
|
|
262
|
+
"""Read-modify-write a JSONL file under ONE file lock (no lost updates).
|
|
263
|
+
|
|
264
|
+
``atomic_rewrite`` alone is not race-safe for read-modify-write cycles:
|
|
265
|
+
if a caller reads the file, computes new content, and then rewrites,
|
|
266
|
+
any record appended by another thread/process in between is silently
|
|
267
|
+
lost. ``atomic_update`` holds the file lock across the entire
|
|
268
|
+
read -> transform -> replace sequence, so concurrent ``append`` calls
|
|
269
|
+
(which take the same lock) serialize correctly.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
path: Path to the JSONL file.
|
|
273
|
+
transform: Callback receiving the current records; returns the new
|
|
274
|
+
full record list, or None to abort without writing.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The new record list that was written, or None if aborted.
|
|
278
|
+
"""
|
|
279
|
+
lock = self._get_lock(path)
|
|
280
|
+
with lock: # FileLock is re-entrant within the same thread
|
|
281
|
+
entries = self.read_all(path)
|
|
282
|
+
new_entries = transform(entries)
|
|
283
|
+
if new_entries is None:
|
|
284
|
+
return None
|
|
285
|
+
self.atomic_rewrite(path, new_entries)
|
|
286
|
+
return new_entries
|
|
287
|
+
|
|
288
|
+
def _invalidate_cache(self, path: Path) -> None:
|
|
289
|
+
"""Drop the read cache entry for *path*."""
|
|
290
|
+
with self._cache_lock:
|
|
291
|
+
self._read_cache.pop(str(path), None)
|
|
292
|
+
|
|
293
|
+
def read_all(self, path: Path) -> list[dict[str, object]]:
|
|
294
|
+
"""Read all valid records from a JSONL file.
|
|
295
|
+
|
|
296
|
+
Results are cached by (mtime_ns, size). Subsequent calls that hit the
|
|
297
|
+
cache skip JSON parsing entirely. Callers receive shallow copies of
|
|
298
|
+
the cached records so mutations do not corrupt the cache.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
path: Path to the JSONL file.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
List of parsed dict records.
|
|
305
|
+
"""
|
|
306
|
+
if not path.exists():
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
st = path.stat()
|
|
310
|
+
key = str(path)
|
|
311
|
+
|
|
312
|
+
with self._cache_lock:
|
|
313
|
+
cached = self._read_cache.get(key)
|
|
314
|
+
if cached is not None:
|
|
315
|
+
c_mtime, c_size, c_records = cached
|
|
316
|
+
if c_mtime == st.st_mtime_ns and c_size == st.st_size:
|
|
317
|
+
return [dict(r) for r in c_records]
|
|
318
|
+
|
|
319
|
+
# Cache miss -- parse from disk.
|
|
320
|
+
records: list[dict[str, object]] = []
|
|
321
|
+
with open(path, encoding="utf-8") as f:
|
|
322
|
+
for line in f:
|
|
323
|
+
line = line.strip()
|
|
324
|
+
if not line:
|
|
325
|
+
continue
|
|
326
|
+
try:
|
|
327
|
+
obj = json.loads(line)
|
|
328
|
+
if isinstance(obj, dict):
|
|
329
|
+
records.append(obj)
|
|
330
|
+
except json.JSONDecodeError:
|
|
331
|
+
logger.warning("Skipping corrupted line in %s", path.name)
|
|
332
|
+
|
|
333
|
+
# Re-stat after parse to store a consistent snapshot.
|
|
334
|
+
try:
|
|
335
|
+
st2 = path.stat()
|
|
336
|
+
except OSError:
|
|
337
|
+
return records
|
|
338
|
+
|
|
339
|
+
with self._cache_lock:
|
|
340
|
+
self._read_cache[key] = (st2.st_mtime_ns, st2.st_size, records)
|
|
341
|
+
|
|
342
|
+
return [dict(r) for r in records]
|