@sema-agent/core 7.38.2 → 7.38.3
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.
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 7.38.3 — 2026-10-02
|
|
2
|
+
|
|
3
|
+
**Hard-breaking changes:** none.
|
|
4
|
+
|
|
5
|
+
**Closed sets:** none. **Package exports:** +0 / −0.
|
|
6
|
+
|
|
7
|
+
### The JSONL tail repair reads the file's last segment, not the whole file (#1286; behavior (widen: a store file of 2 GiB or more opens and takes appends; an unterminated last segment past the engine's string bound no longer throws at open); +0 exports) (@server @test)
|
|
8
|
+
|
|
9
|
+
- **Before (v7.38.2).** `AppendLog` repaired a JSONL file's tail at three stations (open, the lazy reopen after a compaction swap, the append door when the file had moved off a record boundary) by reading the whole file to look at its last line. A file of 2 GiB or more could not be opened at all (`ERR_FS_FILE_TOO_LARGE`), so an append-only dataset that grows without bound (an outcomes log) refused every write once it crossed that size; an unterminated last segment whose text was longer than V8's maximum string (`buffer.constants.MAX_STRING_LENGTH`, 536,870,888 characters; for ASCII that is 512 MiB) threw `ERR_STRING_TOO_LONG` at open.
|
|
10
|
+
- **Now.** One function, `readTailSegment(fd, size)` (`src/stores/file/fs-atomic.ts`), walks back from the end in 64 KiB blocks to the last newline and returns the segment after it; the three stations read their tail through it, so a repair costs the length of the last line, not of the file. What the segment IS stays `jsonlTextFormOf`'s call, unchanged: a whole record is ended with its newline, a half record or a non-object is cut back to the last newline, a file that ends on a newline is not touched. A segment longer than that bound, MEASURED IN BYTES (536,870,888 bytes), is not judged: it is ended as a line (one `\n` appended, its bytes untouched, never cut) and one `console.warn` line names the file; the next append stands on its own line behind it, where leaving it open would have glued the next acknowledged record onto it.
|
|
11
|
+
- **Who is hurt.** Nobody who worked before: every file that opened still gets the same repair, byte for byte, with one exception: an unterminated last segment of more than 536,870,888 BYTES of multi-byte text (up to about three times that) could still be decoded before, and a torn one was cut back; it is now ended as a line unjudged like an ASCII one, so a torn one stays as an unparseable line (a reader skips it) instead of being cut. A file that used to refuse to open (2 GiB or more, or an ASCII last segment above the string bound) now opens and appends. The repair is only half of what such a file needs: a store that replays its whole file when it boots (`readJsonlRecords`, still one whole-file read) still cannot read a file of 2 GiB or more, nor a line above the string bound; only a consumer that appends without replaying (a sink) benefits end to end. That read is tracked as its own backlog row (1287), not changed here.
|
|
12
|
+
- **Compensation.** None needed; there is no switch for the old whole read.
|
|
13
|
+
- **Known limit (KL-1286-a).** A last segment longer than the string bound is ended as a line and not judged: if it was a real record, it stays on disk and is not read by the repair (a reader meets it as an ordinary line; what the whole-file read does with it is backlog row 1287's). The bound is the runtime's `buffer.constants.MAX_STRING_LENGTH` applied to the segment's length in BYTES: a segment within it always decodes (a byte is at most one UTF-16 unit), one past it might still decode when it is multi-byte text, and is left unjudged either way (judging by decoded length would mean reading and decoding up to three times the bound to find out). A record that long is half a gigabyte of text: this store's writers do not produce one in practice, so the shape is a foreign writer's or damage.
|
|
14
|
+
- **Known limit (KL-1286-b).** The repair is not serialized with an appender that is writing the same file: it samples the last segment, judges it, then cuts by offset. A peer that completes the half record after the sample and before the cut has that record cut with the remnant. The window is the same read-to-cut one v7.38.2 had (it read the whole file first); it is now the length of the last line, not of the file. The directory fence (one writer per ledger directory) is what keeps a peer from being there; closing the window itself needs the repair to run under that fence.
|
|
15
|
+
- Pins: `test/backlog1286-jsonl-tail-large-file.test.ts` — a 2.2 GB sparse file (nothing is written; `truncateSync` extends it) with ① a whole unterminated last line (ended, one byte added, the next append is its own line), ② a half last line / a non-object tail (cut back), ③ a clean file (not one byte moved), and the other two stations on it (lazy reopen after a swap; append door met by a peer's whole / half tail); ④ tails around read-block edges (100 KiB; every length one byte either side of one and two blocks), ⑤ a line longer than three blocks, ⑥ a file with no newline at all (small, and larger than a block), whole and torn; ⑦ a segment one byte past the string bound (ended, never cut, one warning, the next append not glued); ⑨ the repair reads at most a couple of blocks and never goes through `readFileSync` on the log; ⑪ the present form of KL-1286-b (a peer completing the half record between the read and the cut loses it); `readTailSegment` itself (segment starts, newline at a block's first / last byte, the bound judged at its exact edge). `test/backlog1278-jsonl-tail-newline.test.ts` PIN 14 (a multi-byte character straddling a read-block edge does not tear a whole record) and PINs 1–13 unchanged and green.
|
|
16
|
+
|
|
1
17
|
## 7.38.2 — 2026-10-02
|
|
2
18
|
|
|
3
19
|
**Hard-breaking changes:** none.
|
|
@@ -133,10 +133,11 @@ export declare class AppendLog {
|
|
|
133
133
|
* onto the remnant, so replay dropped BOTH — after that second append had returned and fsync'd. Measured:
|
|
134
134
|
* records [1,2,3] + a torn remnant + appends 5,6 replayed as [1,2,3,6].
|
|
135
135
|
*
|
|
136
|
-
* Cost on the healthy path is ONE `fstat` per append (the sizes match and we return); the
|
|
137
|
-
*
|
|
138
|
-
* process under the boot-lock handoff) leaves the file
|
|
139
|
-
*
|
|
136
|
+
* Cost on the healthy path is ONE `fstat` per append (the sizes match and we return); the tail read only
|
|
137
|
+
* happens when the file actually moved off a boundary, and it reads the last segment, not the file. A
|
|
138
|
+
* legitimate concurrent appender (another process under the boot-lock handoff) leaves the file
|
|
139
|
+
* newline-terminated, so it is adopted, not truncated; a whole record it left without its newline is kept
|
|
140
|
+
* and ended, and only a partial one is cut.
|
|
140
141
|
*/
|
|
141
142
|
private repairTail;
|
|
142
143
|
/**
|
|
@@ -171,6 +172,30 @@ export declare class AppendLog {
|
|
|
171
172
|
closeForSwap(): void;
|
|
172
173
|
private releaseFd;
|
|
173
174
|
}
|
|
175
|
+
/** Bytes read per positioned read while a file's tail is walked back to its last newline. */
|
|
176
|
+
export declare const JSONL_TAIL_READ_BLOCK: number;
|
|
177
|
+
/**
|
|
178
|
+
* The longest last segment (in BYTES) a repair station will judge: V8's maximum string length in UTF-16 code units
|
|
179
|
+
* (Node's `buffer.constants.MAX_STRING_LENGTH`). A segment of N bytes decodes to at most N code units, so one within
|
|
180
|
+
* the bound can never make `toString` throw. One past it MAY still decode (multi-byte text has fewer units than
|
|
181
|
+
* bytes) but is left unjudged either way: telling would mean reading and decoding up to three times the bound.
|
|
182
|
+
*/
|
|
183
|
+
export declare const JSONL_TAIL_SEGMENT_LIMIT: number;
|
|
184
|
+
/** A file's last segment (the bytes after its last `\n`, or the whole file when it holds none) — or the fact that it is
|
|
185
|
+
* too long to judge. `start` is the segment's offset; a file that ends on a newline has an empty segment at `size`. */
|
|
186
|
+
export type JsonlTailSegment = {
|
|
187
|
+
unjudgeable: false;
|
|
188
|
+
start: number;
|
|
189
|
+
bytes: Buffer;
|
|
190
|
+
} | {
|
|
191
|
+
unjudgeable: true;
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* The last segment of an open file of `size` bytes, found by walking back from the end one block at a time to the
|
|
195
|
+
* last newline: a read is bounded by the segment's length, never by the file's. The one reader of "what is the tail"
|
|
196
|
+
* for every repair station; whether the segment is a record is {@link jsonlTextFormOf}'s to say.
|
|
197
|
+
*/
|
|
198
|
+
export declare function readTailSegment(fd: number, size: number, limit?: number): JsonlTailSegment;
|
|
174
199
|
/** Why a {@link BootLock}/{@link acquireStoreDirLock} acquisition was refused, as a branchable code. */
|
|
175
200
|
export type FileStoreLockErrorCode =
|
|
176
201
|
/** A LIVE process (this one or another) already holds the directory. Steady state: it ends when that
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { appendFileSync, closeSync, constants as FS, existsSync, fstatSync, ftruncateSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
|
|
2
|
+
import { constants as bufferConstants } from "node:buffer";
|
|
2
3
|
import { homedir, hostname, uptime } from "node:os";
|
|
3
4
|
import { hrtime } from "node:process";
|
|
4
5
|
import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
@@ -285,23 +286,71 @@ export class AppendLog {
|
|
|
285
286
|
}
|
|
286
287
|
}
|
|
287
288
|
}
|
|
289
|
+
export const JSONL_TAIL_READ_BLOCK = 64 * 1024;
|
|
290
|
+
export const JSONL_TAIL_SEGMENT_LIMIT = bufferConstants.MAX_STRING_LENGTH;
|
|
291
|
+
function readExactly(fd, into, length, position) {
|
|
292
|
+
let got = 0;
|
|
293
|
+
while (got < length) {
|
|
294
|
+
const n = readSync(fd, into, got, length - got, position + got);
|
|
295
|
+
if (n <= 0)
|
|
296
|
+
throw new Error(`jsonl tail: the file ended ${length - got} bytes early while its last line was read — it shrank under the reader`);
|
|
297
|
+
got += n;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
export function readTailSegment(fd, size, limit = JSONL_TAIL_SEGMENT_LIMIT) {
|
|
301
|
+
const block = Buffer.allocUnsafe(Math.min(JSONL_TAIL_READ_BLOCK, size));
|
|
302
|
+
let start = 0;
|
|
303
|
+
for (let end = size; end > 0;) {
|
|
304
|
+
const from = Math.max(0, end - JSONL_TAIL_READ_BLOCK);
|
|
305
|
+
readExactly(fd, block, end - from, from);
|
|
306
|
+
const newline = block.subarray(0, end - from).lastIndexOf(0x0a);
|
|
307
|
+
if (newline >= 0) {
|
|
308
|
+
start = from + newline + 1;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
end = from;
|
|
312
|
+
if (size - end > limit)
|
|
313
|
+
return { unjudgeable: true };
|
|
314
|
+
}
|
|
315
|
+
if (size - start > limit)
|
|
316
|
+
return { unjudgeable: true };
|
|
317
|
+
const bytes = Buffer.allocUnsafe(size - start);
|
|
318
|
+
readExactly(fd, bytes, bytes.length, start);
|
|
319
|
+
return { unjudgeable: false, start, bytes };
|
|
320
|
+
}
|
|
288
321
|
function repairJsonlTail(path) {
|
|
289
|
-
let
|
|
322
|
+
let fd;
|
|
290
323
|
try {
|
|
291
|
-
|
|
324
|
+
fd = openSync(path, "r");
|
|
292
325
|
}
|
|
293
326
|
catch (err) {
|
|
294
327
|
if (err.code === "ENOENT")
|
|
295
328
|
return;
|
|
296
329
|
throw err;
|
|
297
330
|
}
|
|
298
|
-
|
|
331
|
+
let segment;
|
|
332
|
+
try {
|
|
333
|
+
segment = readTailSegment(fd, fstatSync(fd).size);
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
closeSync(fd);
|
|
337
|
+
}
|
|
338
|
+
if (segment.unjudgeable) {
|
|
339
|
+
appendFileSync(path, "\n");
|
|
340
|
+
try {
|
|
341
|
+
console.warn(`[sema] file store: a tail segment larger than the judgeable bound (${JSONL_TAIL_SEGMENT_LIMIT} bytes) was ended as a line, not judged — ` +
|
|
342
|
+
`${path}. Its bytes are untouched; a reader meets it as an ordinary line.`);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (segment.bytes.length === 0)
|
|
299
349
|
return;
|
|
300
|
-
|
|
301
|
-
if (jsonlTextFormOf(bytes.toString("utf8", segmentStart)).form === "record")
|
|
350
|
+
if (jsonlTextFormOf(segment.bytes.toString("utf8")).form === "record")
|
|
302
351
|
appendFileSync(path, "\n");
|
|
303
352
|
else
|
|
304
|
-
truncateSync(path,
|
|
353
|
+
truncateSync(path, segment.start);
|
|
305
354
|
}
|
|
306
355
|
export class FileStoreLockError extends Error {
|
|
307
356
|
code;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"repo": "core",
|
|
3
3
|
"version": "7.38.2",
|
|
4
4
|
"from": "a8493dd6305fe6f8b3fea1174b56c30667f3ff1b",
|
|
5
|
-
"to": "
|
|
5
|
+
"to": "25b09b0232b290da289a6d9697f0d3b72826713c",
|
|
6
6
|
"added": [],
|
|
7
7
|
"removed": [],
|
|
8
8
|
"changed": [],
|
|
@@ -14,7 +14,25 @@
|
|
|
14
14
|
"typeKeysFlattenedCount": 220,
|
|
15
15
|
"declarationMoves": [],
|
|
16
16
|
"mintMoves": [],
|
|
17
|
-
"criteriaObserves": [
|
|
17
|
+
"criteriaObserves": [
|
|
18
|
+
{
|
|
19
|
+
"kind": "typeMember",
|
|
20
|
+
"name": "AppendLog.append",
|
|
21
|
+
"assertions": [
|
|
22
|
+
"G1.b",
|
|
23
|
+
"G1.c",
|
|
24
|
+
"G1.d"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"kind": "typeMember",
|
|
29
|
+
"name": "CheckpointStore.get",
|
|
30
|
+
"assertions": [
|
|
31
|
+
"G1.a",
|
|
32
|
+
"G1.c"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
],
|
|
18
36
|
"consumersHint": {
|
|
19
37
|
"cli": [],
|
|
20
38
|
"server": [],
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repo": "core",
|
|
3
|
+
"version": "7.38.3",
|
|
4
|
+
"from": "25b09b0232b290da289a6d9697f0d3b72826713c",
|
|
5
|
+
"to": "e0ab70decfe37f3dcdcb4aff4f8e694df14a7efe",
|
|
6
|
+
"added": [],
|
|
7
|
+
"removed": [],
|
|
8
|
+
"changed": [],
|
|
9
|
+
"narrowed": [],
|
|
10
|
+
"closedSetMembers": [],
|
|
11
|
+
"closedSetsUnreadable": [],
|
|
12
|
+
"typeKeys": [],
|
|
13
|
+
"typeKeysUnreadableArms": [],
|
|
14
|
+
"typeKeysFlattenedCount": 220,
|
|
15
|
+
"declarationMoves": [],
|
|
16
|
+
"mintMoves": [],
|
|
17
|
+
"criteriaObserves": [
|
|
18
|
+
{
|
|
19
|
+
"kind": "typeMember",
|
|
20
|
+
"name": "AppendLog.append",
|
|
21
|
+
"assertions": [
|
|
22
|
+
"G1.a",
|
|
23
|
+
"G1.b",
|
|
24
|
+
"G1.c"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"consumersHint": {
|
|
29
|
+
"cli": [],
|
|
30
|
+
"server": [],
|
|
31
|
+
"sdk": [],
|
|
32
|
+
"note": "scanned cli@64a76326 server@f4ce4085 sdk@2756723 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
|
|
33
|
+
},
|
|
34
|
+
"breakingCount": 0,
|
|
35
|
+
"breakingItems": []
|
|
36
|
+
}
|