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.
@@ -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]