pikelet-wasm 0.13.0 → 0.14.0
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 +76 -0
- package/complete/blob-cache.node.mjs +245 -19
- package/complete/index.d.ts +23 -13
- package/complete/index.mjs +27 -8
- package/complete/index.node.mjs +1 -1
- package/package.json +1 -1
- package/src/artifact/pikelet-artifact-common.js +15 -9
- package/src/artifact/pikelet-artifact-sketch.js +35 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,82 @@ Through 0.6.0 these were published as `pancake-wasm` and
|
|
|
8
8
|
packages are pre-1.0: a minor bump may carry breaking changes, and each entry lists them
|
|
9
9
|
first.
|
|
10
10
|
|
|
11
|
+
## pikelet-wasm 0.14.0 / pikelet 0.14.0 — 2026-11-04
|
|
12
|
+
|
|
13
|
+
Asking a URL pack a question an earlier process already asked is now
|
|
14
|
+
mostly local too: `pikelet mcp` keeps the verified rows, records and digest
|
|
15
|
+
pages a query reads in its disk cache, so on the Wikipedia pack over R2 a
|
|
16
|
+
repeated question transferred 178 KiB instead of 1.3 MiB and answered in
|
|
17
|
+
0.65 s instead of 1.45 s. The cache is now private to its owner (0.13.0
|
|
18
|
+
left it readable by other local users under the usual umask), and two
|
|
19
|
+
processes sharing it no longer mis-index each other's writes. Upgrade if
|
|
20
|
+
you mount packs by URL, and especially if other people have accounts on
|
|
21
|
+
the machine. Packs compiled before this release still verify and serve,
|
|
22
|
+
and compiling the same inputs gives the same identities as 0.13.0. Both
|
|
23
|
+
packages move to 0.14.0 together, and `pikelet` requires `pikelet-wasm`
|
|
24
|
+
^0.14.0.
|
|
25
|
+
|
|
26
|
+
### Compatibility
|
|
27
|
+
|
|
28
|
+
- **The cache also holds query reads.** On top of the mount entries (up
|
|
29
|
+
to 2 GiB), URL packs now store what queries read in append-only logs
|
|
30
|
+
under `~/.cache/pikelet/blobs/small`, up to 64 MiB. `--no-cache` still
|
|
31
|
+
turns all of it off, and local packs still never use it.
|
|
32
|
+
- **The cache root becomes 0700.** The first write tightens the store's
|
|
33
|
+
directory to owner-only, including a `$PIKELET_CACHE_DIR/blobs` or a
|
|
34
|
+
directory passed to `diskBlobCache(dir)` that was opened up on purpose.
|
|
35
|
+
Nothing above that directory is touched.
|
|
36
|
+
- **Library callers: close a `diskBlobCache()` you are done with.** A store
|
|
37
|
+
that has served a query entry holds read handles on its log until
|
|
38
|
+
`close()`. Node 26 treats a file handle left to garbage collection as a
|
|
39
|
+
fatal error, so a store dropped without `close()` can end the process
|
|
40
|
+
there. The CLI keeps its store for the life of the process and is not
|
|
41
|
+
affected.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **Queries use the disk cache too.** With `blobCache`, the verified pieces
|
|
46
|
+
a query reads are stored and served by digest: rerank vector rows (by
|
|
47
|
+
their row digests; format 2), corpus records (by their record digests),
|
|
48
|
+
and the digest pages that verify both (by their page-table hashes). Every
|
|
49
|
+
hit is hash-checked against its key, a truncated row digest of at least
|
|
50
|
+
128 bits included, and only reads verified on their own use the cache:
|
|
51
|
+
the lazy lexical index never does. `diskBlobCache()` keeps entries under
|
|
52
|
+
64 KiB in append-only log generations (64 MiB in total by default,
|
|
53
|
+
`smallMaxBytes`; the oldest generation dropped whole, hits rewritten to
|
|
54
|
+
the newest) instead of one file each, and gains `close()`. On the
|
|
55
|
+
Wikipedia pack over R2, asking a question an earlier process already
|
|
56
|
+
asked transferred 178 KiB in 11 requests instead of 1.3 MiB in 29, and
|
|
57
|
+
answered in 0.65 s instead of 1.45 s; a new question costs what it did.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **Generated configs point at a schema that exists.** The configs the
|
|
62
|
+
CLI generates carried a `$schema` URL from before the repository moved
|
|
63
|
+
to `packages/`, which returned 404; it and the schema's own `$id` now name
|
|
64
|
+
`packages/pikelet/schemas/v1/pikelet.config.schema.json`. Configs
|
|
65
|
+
generated earlier keep the old URL until it is edited.
|
|
66
|
+
- **Two processes sharing a disk cache no longer mis-index each other's
|
|
67
|
+
appends.** A store computed a log batch's offset as the file size after
|
|
68
|
+
its append, so another process appending in between left its index
|
|
69
|
+
pointing at the wrong record: about a third of each writer's own
|
|
70
|
+
lookups across three processes. Every hit is hash-checked, so this cost
|
|
71
|
+
refetches, never a wrong byte, and a fresh process re-indexed the log
|
|
72
|
+
correctly. A batch is now read back at its offset before it is indexed,
|
|
73
|
+
and if the end moved, the generation is rescanned to find where it
|
|
74
|
+
landed.
|
|
75
|
+
|
|
76
|
+
### Security
|
|
77
|
+
|
|
78
|
+
- **The disk cache is private to its owner.** `diskBlobCache()` stored
|
|
79
|
+
entries with the process umask, so under the usual 022 its directories
|
|
80
|
+
were 0755 and its files 0644: readable by other local users wherever
|
|
81
|
+
the parent directories allowed it. Entries hold record text, which for a
|
|
82
|
+
pack of a private repository, a journal, or conversation history is the
|
|
83
|
+
source itself. Directories are now created 0700 and files 0600, and the
|
|
84
|
+
first write tightens the store's root to 0700, which also closes off
|
|
85
|
+
entries a 0.13.0 cache already wrote.
|
|
86
|
+
|
|
11
87
|
## pikelet-wasm 0.13.0 / pikelet 0.13.0 — 2026-11-01
|
|
12
88
|
|
|
13
89
|
Mounting a pack over HTTP is much cheaper the second time: `pikelet mcp`
|
|
@@ -1,23 +1,46 @@
|
|
|
1
|
-
// A disk store for openPikeletFile's options.blobCache:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// A disk store for openPikeletFile's options.blobCache: bytes a pack commits
|
|
2
|
+
// to by sha256, filed under that digest. Two kinds of entry reach it:
|
|
3
|
+
//
|
|
4
|
+
// - whole regions read at mount (the sketch's resident prefix, the
|
|
5
|
+
// query-interp segment holding the encoder): most of what a URL mount
|
|
6
|
+
// downloads, about 71 of 77 MiB on the 649 MiB Simple English Wikipedia
|
|
7
|
+
// pack. One file each.
|
|
8
|
+
// - pieces read per query (rerank vector rows, corpus records, and the
|
|
9
|
+
// digest pages that verify them): a few hundred bytes to a few KiB, and
|
|
10
|
+
// hundreds per query. One file each would be a poor fit, so entries under
|
|
11
|
+
// SMALL_ENTRY_BYTES go into append-only log files (generations) with an
|
|
12
|
+
// in-memory index, and whole generations rotate out under their own
|
|
13
|
+
// budget.
|
|
7
14
|
//
|
|
8
15
|
// The store is untrusted by design: the reader hashes every hit against the
|
|
9
16
|
// digest it asked for and treats a mismatch as a miss (blobCacheGet in
|
|
10
|
-
// pikelet-artifact-common.js), so a torn write
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// entries (by mtime, touched on every hit) after each write.
|
|
17
|
+
// pikelet-artifact-common.js), so a torn write, a corrupted file, or two
|
|
18
|
+
// processes appending to one log at once costs a refetch, never a wrong byte.
|
|
19
|
+
// Entries are immutable and never go stale; the only policy is size.
|
|
14
20
|
|
|
15
21
|
import fs from 'node:fs/promises';
|
|
16
22
|
import os from 'node:os';
|
|
17
23
|
import path from 'node:path';
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
// A full sha256, or a truncated one of at least 128 bits (sketch row digests
|
|
26
|
+
// may be 16-32 bytes).
|
|
27
|
+
const KEY = /^(?:[0-9a-f]{2}){16,32}$/;
|
|
20
28
|
export const DEFAULT_BLOB_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
29
|
+
export const DEFAULT_SMALL_CACHE_BYTES = 64 * 1024 * 1024;
|
|
30
|
+
// Entries below this go to the log; at or above it, a file of their own.
|
|
31
|
+
export const SMALL_ENTRY_BYTES = 64 * 1024;
|
|
32
|
+
// The log keeps this many generations; the oldest is deleted whole when the
|
|
33
|
+
// newest fills (smallMaxBytes / GENERATIONS each).
|
|
34
|
+
const GENERATIONS = 4;
|
|
35
|
+
// Log record: magic 'PKB1' (u32 LE), key length (u8), key bytes, data length
|
|
36
|
+
// (u32 LE), data. A scan stops at the first record that does not parse.
|
|
37
|
+
const RECORD_MAGIC = 0x3142_4b50;
|
|
38
|
+
const GEN_FILE = /^gen-(\d{8})\.log$/;
|
|
39
|
+
// Entries are pack content, record text included, and a pack can be a
|
|
40
|
+
// private repository or a conversation history: the store is private to its
|
|
41
|
+
// owner whatever the umask.
|
|
42
|
+
const DIR_MODE = 0o700;
|
|
43
|
+
const FILE_MODE = 0o600;
|
|
21
44
|
|
|
22
45
|
// $PIKELET_CACHE_DIR, else $XDG_CACHE_HOME/pikelet/blobs, else
|
|
23
46
|
// ~/.cache/pikelet/blobs: the same root the CLI already caches encoder
|
|
@@ -28,15 +51,28 @@ export function defaultBlobCacheDir(env = process.env) {
|
|
|
28
51
|
return path.join(base, 'pikelet', 'blobs');
|
|
29
52
|
}
|
|
30
53
|
|
|
31
|
-
export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_BLOB_CACHE_BYTES } = {}) {
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_BLOB_CACHE_BYTES, smallMaxBytes = DEFAULT_SMALL_CACHE_BYTES } = {}) {
|
|
55
|
+
for (const [label, value] of [['maxBytes', maxBytes], ['smallMaxBytes', smallMaxBytes]]) {
|
|
56
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
57
|
+
throw new Error(`diskBlobCache ${label} must be a non-negative integer`);
|
|
58
|
+
}
|
|
34
59
|
}
|
|
35
60
|
const root = path.resolve(dir);
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
|
|
61
|
+
const checkKey = (key) => {
|
|
62
|
+
if (typeof key !== 'string' || !KEY.test(key)) {
|
|
63
|
+
throw new Error(`blob cache key must be a lowercase sha256 hex digest (32-64 hex digits), got ${JSON.stringify(key)}`);
|
|
64
|
+
}
|
|
65
|
+
return key;
|
|
39
66
|
};
|
|
67
|
+
const fileOf = (key) => path.join(root, key.slice(0, 2), checkKey(key));
|
|
68
|
+
// Once per instance, before the first write: a root made by an older
|
|
69
|
+
// store (or by mkdir -p under umask 022) is tightened, which also closes
|
|
70
|
+
// off whatever it already holds. Best effort; on Windows modes are moot.
|
|
71
|
+
let privateRoot = null;
|
|
72
|
+
const makeRootPrivate = () => (privateRoot ??= (async () => {
|
|
73
|
+
await fs.mkdir(root, { recursive: true, mode: DIR_MODE });
|
|
74
|
+
await fs.chmod(root, DIR_MODE).catch(() => {});
|
|
75
|
+
})());
|
|
40
76
|
const pending = new Set();
|
|
41
77
|
const track = (promise) => {
|
|
42
78
|
pending.add(promise);
|
|
@@ -48,6 +84,7 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
|
|
|
48
84
|
const entries = [];
|
|
49
85
|
let total = 0;
|
|
50
86
|
for (const shard of await fs.readdir(root).catch(() => [])) {
|
|
87
|
+
if (shard === 'small') continue;
|
|
51
88
|
const shardDir = path.join(root, shard);
|
|
52
89
|
for (const name of await fs.readdir(shardDir).catch(() => [])) {
|
|
53
90
|
if (!KEY.test(name)) continue;
|
|
@@ -67,9 +104,178 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
|
|
|
67
104
|
}
|
|
68
105
|
};
|
|
69
106
|
|
|
107
|
+
// --- the small-entry log -------------------------------------------------
|
|
108
|
+
const smallDir = path.join(root, 'small');
|
|
109
|
+
const genBytes = Math.max(1, Math.floor(smallMaxBytes / GENERATIONS));
|
|
110
|
+
const genPath = (gen) => path.join(smallDir, `gen-${String(gen).padStart(8, '0')}.log`);
|
|
111
|
+
const index = new Map(); // key -> { gen, offset, length }
|
|
112
|
+
const handles = new Map(); // gen -> Promise<FileHandle> (read)
|
|
113
|
+
let current = 0;
|
|
114
|
+
// Set when the newest generation ends in bytes that do not parse (a torn
|
|
115
|
+
// write): a scan stops there, so anything appended after it would never
|
|
116
|
+
// be indexed again. Writing resumes in a fresh generation instead.
|
|
117
|
+
let sealed = false;
|
|
118
|
+
let queue = [];
|
|
119
|
+
let flushing = null;
|
|
120
|
+
|
|
121
|
+
const listGens = async () => (await fs.readdir(smallDir).catch(() => []))
|
|
122
|
+
.map((name) => GEN_FILE.exec(name)).filter(Boolean).map((m) => Number(m[1])).sort((a, b) => a - b);
|
|
123
|
+
|
|
124
|
+
const forgetGen = async (gen) => {
|
|
125
|
+
for (const [key, at] of index) if (at.gen === gen) index.delete(key);
|
|
126
|
+
const handle = handles.get(gen);
|
|
127
|
+
handles.delete(gen);
|
|
128
|
+
if (handle) await handle.then((h) => h.close(), () => {});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const scan = (gen, bytes) => {
|
|
132
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
133
|
+
let at = 0;
|
|
134
|
+
while (at + 9 <= bytes.length && view.getUint32(at, true) === RECORD_MAGIC) {
|
|
135
|
+
const keyLen = bytes[at + 4];
|
|
136
|
+
if (keyLen < 16 || keyLen > 32 || at + 5 + keyLen + 4 > bytes.length) break;
|
|
137
|
+
const key = Buffer.from(bytes.subarray(at + 5, at + 5 + keyLen)).toString('hex');
|
|
138
|
+
const length = view.getUint32(at + 5 + keyLen, true);
|
|
139
|
+
const offset = at + 9 + keyLen;
|
|
140
|
+
if (offset + length > bytes.length) break;
|
|
141
|
+
index.set(key, { gen, offset, length });
|
|
142
|
+
at = offset + length;
|
|
143
|
+
}
|
|
144
|
+
return at === bytes.length;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Built once, on first use: every generation still on disk is read and
|
|
148
|
+
// indexed (at most smallMaxBytes), later generations overriding earlier.
|
|
149
|
+
let ready = null;
|
|
150
|
+
const smallReady = () => (ready ??= (async () => {
|
|
151
|
+
if (smallMaxBytes === 0) return;
|
|
152
|
+
const gens = await listGens();
|
|
153
|
+
for (const gen of gens.slice(0, Math.max(0, gens.length - GENERATIONS))) {
|
|
154
|
+
await fs.rm(genPath(gen), { force: true });
|
|
155
|
+
}
|
|
156
|
+
for (const gen of gens.slice(-GENERATIONS)) {
|
|
157
|
+
const bytes = await fs.readFile(genPath(gen)).catch(() => null);
|
|
158
|
+
const whole = bytes ? scan(gen, bytes) : true;
|
|
159
|
+
if (gen === gens[gens.length - 1]) sealed = !whole;
|
|
160
|
+
}
|
|
161
|
+
current = gens.length ? gens[gens.length - 1] : 0;
|
|
162
|
+
})());
|
|
163
|
+
|
|
164
|
+
const readSmall = async (key) => {
|
|
165
|
+
const at = index.get(key);
|
|
166
|
+
if (!at) return null;
|
|
167
|
+
let handle = handles.get(at.gen);
|
|
168
|
+
if (!handle) {
|
|
169
|
+
handle = fs.open(genPath(at.gen), 'r');
|
|
170
|
+
handles.set(at.gen, handle);
|
|
171
|
+
handle.catch(() => { if (handles.get(at.gen) === handle) handles.delete(at.gen); });
|
|
172
|
+
}
|
|
173
|
+
let fh;
|
|
174
|
+
try { fh = await handle; } catch (err) {
|
|
175
|
+
// Another process rotated this generation away.
|
|
176
|
+
if (err?.code === 'ENOENT') { await forgetGen(at.gen); return null; }
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
const out = new Uint8Array(at.length);
|
|
180
|
+
const { bytesRead } = await fh.read(out, 0, at.length, at.offset);
|
|
181
|
+
if (bytesRead !== at.length) { index.delete(key); return null; }
|
|
182
|
+
// A hit from an older generation is written again to the newest, so
|
|
183
|
+
// rotation drops what has not been read lately: LRU by generation.
|
|
184
|
+
if (at.gen !== current) enqueue(key, out);
|
|
185
|
+
return out;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const rotate = async () => {
|
|
189
|
+
await makeRootPrivate();
|
|
190
|
+
await fs.mkdir(smallDir, { recursive: true, mode: DIR_MODE });
|
|
191
|
+
const gens = await listGens();
|
|
192
|
+
current = Math.max(current, gens.length ? gens[gens.length - 1] : 0) + 1;
|
|
193
|
+
// Exclusive create; another process that rotated first owns the
|
|
194
|
+
// name, and appending after its records is fine.
|
|
195
|
+
await fs.writeFile(genPath(current), new Uint8Array(0), { flag: 'wx', mode: FILE_MODE }).catch((err) => {
|
|
196
|
+
if (err?.code !== 'EEXIST') throw err;
|
|
197
|
+
});
|
|
198
|
+
for (const gen of (await listGens()).filter((g) => g <= current - GENERATIONS)) {
|
|
199
|
+
await forgetGen(gen);
|
|
200
|
+
await fs.rm(genPath(gen), { force: true });
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const flushSmall = async () => {
|
|
205
|
+
while (queue.length) {
|
|
206
|
+
const batch = queue;
|
|
207
|
+
queue = [];
|
|
208
|
+
const total = batch.reduce((n, r) => n + r.record.length, 0);
|
|
209
|
+
if (!current || sealed) {
|
|
210
|
+
await rotate();
|
|
211
|
+
sealed = false;
|
|
212
|
+
} else {
|
|
213
|
+
const st = await fs.stat(genPath(current)).catch(() => null);
|
|
214
|
+
if (!st || st.size + total > genBytes) await rotate();
|
|
215
|
+
}
|
|
216
|
+
const buf = Buffer.concat(batch.map((r) => r.record));
|
|
217
|
+
const fh = await fs.open(genPath(current), 'a+', FILE_MODE);
|
|
218
|
+
let base;
|
|
219
|
+
try {
|
|
220
|
+
await fh.write(buf);
|
|
221
|
+
// O_APPEND puts the batch at the end; its offset is the size
|
|
222
|
+
// now minus its length, unless another process appended
|
|
223
|
+
// between the write and the stat. So the batch is read back
|
|
224
|
+
// there before it is indexed: records are content-addressed,
|
|
225
|
+
// so wherever these exact bytes sit is a right offset.
|
|
226
|
+
base = (await fh.stat()).size - buf.length;
|
|
227
|
+
if (base >= 0) {
|
|
228
|
+
const back = Buffer.alloc(buf.length);
|
|
229
|
+
const { bytesRead } = await fh.read(back, 0, buf.length, base);
|
|
230
|
+
if (bytesRead !== buf.length || !back.equals(buf)) base = -1;
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
await fh.close();
|
|
234
|
+
}
|
|
235
|
+
if (base < 0) {
|
|
236
|
+
// The end moved: find where the batch landed by scanning the
|
|
237
|
+
// generation, which indexes the other writer's records too.
|
|
238
|
+
// A record still being written stops the scan; whatever lies
|
|
239
|
+
// past it is a miss, not a wrong offset.
|
|
240
|
+
const bytes = await fs.readFile(genPath(current)).catch(() => null);
|
|
241
|
+
if (bytes) scan(current, bytes);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
let at = base;
|
|
245
|
+
for (const r of batch) {
|
|
246
|
+
index.set(r.key, { gen: current, offset: at + r.headerBytes, length: r.length });
|
|
247
|
+
at += r.record.length;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const enqueue = (key, bytes) => {
|
|
253
|
+
if (index.has(key) && index.get(key).gen === current) return;
|
|
254
|
+
const raw = Buffer.from(key, 'hex');
|
|
255
|
+
const headerBytes = 9 + raw.length;
|
|
256
|
+
const record = Buffer.alloc(headerBytes + bytes.byteLength);
|
|
257
|
+
record.writeUInt32LE(RECORD_MAGIC, 0);
|
|
258
|
+
record[4] = raw.length;
|
|
259
|
+
raw.copy(record, 5);
|
|
260
|
+
record.writeUInt32LE(bytes.byteLength, 5 + raw.length);
|
|
261
|
+
record.set(bytes, headerBytes);
|
|
262
|
+
queue.push({ key, record, headerBytes, length: bytes.byteLength });
|
|
263
|
+
// One append per burst: a query stores hundreds of rows at once.
|
|
264
|
+
flushing ??= track(new Promise((resolve) => setImmediate(resolve))
|
|
265
|
+
.then(() => flushSmall())
|
|
266
|
+
.finally(() => { flushing = null; }));
|
|
267
|
+
return flushing;
|
|
268
|
+
};
|
|
269
|
+
|
|
70
270
|
return {
|
|
71
271
|
dir: root,
|
|
72
272
|
async get(key) {
|
|
273
|
+
checkKey(key);
|
|
274
|
+
if (smallMaxBytes > 0) {
|
|
275
|
+
await smallReady();
|
|
276
|
+
const small = await readSmall(key);
|
|
277
|
+
if (small) return small;
|
|
278
|
+
}
|
|
73
279
|
const file = fileOf(key);
|
|
74
280
|
let bytes;
|
|
75
281
|
try { bytes = await fs.readFile(file); } catch (err) {
|
|
@@ -82,15 +288,21 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
|
|
|
82
288
|
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
83
289
|
},
|
|
84
290
|
put(key, bytes) {
|
|
291
|
+
checkKey(key);
|
|
292
|
+
if (bytes.byteLength < SMALL_ENTRY_BYTES) {
|
|
293
|
+
if (smallMaxBytes === 0 || bytes.byteLength > genBytes) return Promise.resolve();
|
|
294
|
+
return track(smallReady().then(() => enqueue(key, bytes)));
|
|
295
|
+
}
|
|
85
296
|
const file = fileOf(key);
|
|
86
297
|
return track((async () => {
|
|
87
298
|
if (bytes.byteLength > maxBytes) return;
|
|
88
|
-
await
|
|
299
|
+
await makeRootPrivate();
|
|
300
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: DIR_MODE });
|
|
89
301
|
// Write-then-rename, so a reader in another process sees
|
|
90
302
|
// either no entry or a whole one.
|
|
91
303
|
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
92
304
|
try {
|
|
93
|
-
await fs.writeFile(tmp, bytes);
|
|
305
|
+
await fs.writeFile(tmp, bytes, { mode: FILE_MODE });
|
|
94
306
|
await fs.rename(tmp, file);
|
|
95
307
|
} catch (err) {
|
|
96
308
|
await fs.rm(tmp, { force: true });
|
|
@@ -100,6 +312,10 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
|
|
|
100
312
|
})());
|
|
101
313
|
},
|
|
102
314
|
async delete(key) {
|
|
315
|
+
checkKey(key);
|
|
316
|
+
// A log entry cannot be removed in place; forgetting it is enough,
|
|
317
|
+
// and rotation reclaims the bytes.
|
|
318
|
+
index.delete(key);
|
|
103
319
|
await fs.rm(fileOf(key), { force: true });
|
|
104
320
|
},
|
|
105
321
|
// Settles every write started so far: for tests, and for a process
|
|
@@ -107,5 +323,15 @@ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_
|
|
|
107
323
|
async flush() {
|
|
108
324
|
while (pending.size) await Promise.allSettled([...pending]);
|
|
109
325
|
},
|
|
326
|
+
// Closes the log's read handles (they otherwise live as long as the
|
|
327
|
+
// process). The store stays usable; handles reopen on demand.
|
|
328
|
+
async close() {
|
|
329
|
+
await this.flush();
|
|
330
|
+
for (const gen of [...handles.keys()]) {
|
|
331
|
+
const handle = handles.get(gen);
|
|
332
|
+
handles.delete(gen);
|
|
333
|
+
await handle.then((h) => h.close(), () => {});
|
|
334
|
+
}
|
|
335
|
+
},
|
|
110
336
|
};
|
|
111
337
|
}
|
package/complete/index.d.ts
CHANGED
|
@@ -96,7 +96,7 @@ export declare function nodeFetch(input: string, init?: {
|
|
|
96
96
|
inflateGzip?: boolean;
|
|
97
97
|
}): Promise<any>;
|
|
98
98
|
|
|
99
|
-
/** openPikeletFile's options.blobCache: bytes keyed by their sha256 (lowercase hex). */
|
|
99
|
+
/** openPikeletFile's options.blobCache: bytes keyed by their sha256 (lowercase hex, 32-64 digits). */
|
|
100
100
|
export interface BlobCache {
|
|
101
101
|
get(key: string): Promise<Uint8Array | null> | Uint8Array | null;
|
|
102
102
|
put(key: string, bytes: Uint8Array): Promise<void> | void;
|
|
@@ -104,17 +104,23 @@ export interface BlobCache {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
* Node only (the 'node' export condition): a BlobCache on disk
|
|
108
|
-
*
|
|
109
|
-
* used
|
|
107
|
+
* Node only (the 'node' export condition): a BlobCache on disk under `dir`
|
|
108
|
+
* (default defaultBlobCacheDir()). Entries of 64 KiB or more get a file each,
|
|
109
|
+
* evicting least recently used past maxBytes (default 2 GiB). Smaller ones
|
|
110
|
+
* (per-query rows, records, digest pages) go to append-only log generations
|
|
111
|
+
* capped at smallMaxBytes in total (default 64 MiB; 0 disables them), the
|
|
112
|
+
* oldest generation dropped whole. flush() settles pending writes; close()
|
|
113
|
+
* releases the log's file handles.
|
|
110
114
|
*/
|
|
111
|
-
export declare function diskBlobCache(dir?: string, options?: { maxBytes?: number }): BlobCache & {
|
|
115
|
+
export declare function diskBlobCache(dir?: string, options?: { maxBytes?: number; smallMaxBytes?: number }): BlobCache & {
|
|
112
116
|
dir: string;
|
|
113
117
|
flush(): Promise<void>;
|
|
118
|
+
close(): Promise<void>;
|
|
114
119
|
};
|
|
115
120
|
/** $PIKELET_CACHE_DIR/blobs, else $XDG_CACHE_HOME/pikelet/blobs, else ~/.cache/pikelet/blobs. */
|
|
116
121
|
export declare function defaultBlobCacheDir(env?: Record<string, string | undefined>): string;
|
|
117
122
|
export declare const DEFAULT_BLOB_CACHE_BYTES: number;
|
|
123
|
+
export declare const DEFAULT_SMALL_CACHE_BYTES: number;
|
|
118
124
|
|
|
119
125
|
export interface CompleteQueryResult {
|
|
120
126
|
/**
|
|
@@ -308,14 +314,18 @@ export declare function openPikeletFile(
|
|
|
308
314
|
*/
|
|
309
315
|
prefetchEncoder?: boolean;
|
|
310
316
|
/**
|
|
311
|
-
* A store for
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
317
|
+
* A store for bytes a pack commits to by sha256. At mount: the sketch's
|
|
318
|
+
* resident prefix and the query-interp segment holding the inline
|
|
319
|
+
* encoder, which dominate a cold URL mount. Per query: rerank vector
|
|
320
|
+
* rows (format 2), corpus records, and the digest pages that verify
|
|
321
|
+
* both. Keys are those digests in lowercase hex (a row's digest may be
|
|
322
|
+
* truncated to 16-31 bytes), so entries never go stale. Every hit is
|
|
323
|
+
* hashed and must match its key (a prefix, for a truncated key); a
|
|
324
|
+
* mismatch is treated as a miss and deleted, so the store need not be
|
|
325
|
+
* trusted, and get/put/delete faults never fail a read. Only reads that
|
|
326
|
+
* are verified on their own use it: nothing when verification is off,
|
|
327
|
+
* and never the lazy lexical index. diskBlobCache() (Node) is a ready
|
|
328
|
+
* implementation.
|
|
319
329
|
*/
|
|
320
330
|
blobCache?: BlobCache;
|
|
321
331
|
/**
|
package/complete/index.mjs
CHANGED
|
@@ -97,9 +97,9 @@ const COVERAGE_BATCH_COUNT = 5;
|
|
|
97
97
|
// Only BM25 hits within this fraction of the top lexical score join fusion.
|
|
98
98
|
// Common query terms give every matching document a near-tied score (idf
|
|
99
99
|
// collapses them flat); a wide cutoff let that tied mass ride into fusion on
|
|
100
|
-
// a technicality.
|
|
101
|
-
//
|
|
102
|
-
//
|
|
100
|
+
// a technicality. Calibration (pikelet/src/calibrate.mjs) gathers its
|
|
101
|
+
// evidence through this reader's own retrieval, so it is fit against the
|
|
102
|
+
// same cutoff and fusion the reader serves.
|
|
103
103
|
export const LEXICAL_CUTOFF = 1.5;
|
|
104
104
|
// Query-interpretation segments above this size open lazily (kind 3's
|
|
105
105
|
// inline encoder is ~25 MiB); smaller ones keep the one-read eager path.
|
|
@@ -1200,12 +1200,16 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1200
1200
|
const pending = (async () => {
|
|
1201
1201
|
const from = Math.min(recordCount, page * pageRecords);
|
|
1202
1202
|
const to = Math.min(recordCount, (page + 1) * pageRecords);
|
|
1203
|
+
const key = toHex(pageTable.subarray(32 * page, 32 * page + 32));
|
|
1204
|
+
const cached = await blobCacheGet(options.blobCache, key, 32 * (to - from));
|
|
1205
|
+
if (cached) return cached;
|
|
1203
1206
|
const bytes = await readChecked(source, corpus.offset + recordDigestsAt + 32 * from, 32 * (to - from),
|
|
1204
1207
|
`corpus digest page ${page}`, maxReadBytes, fileBytes);
|
|
1205
|
-
if (await sha256hex(bytes) !==
|
|
1208
|
+
if (await sha256hex(bytes) !== key) {
|
|
1206
1209
|
digestPages.delete(page);
|
|
1207
1210
|
throw new Error(`.pikelet corpus digest page ${page} failed hash verification`);
|
|
1208
1211
|
}
|
|
1212
|
+
blobCachePut(options.blobCache, key, bytes);
|
|
1209
1213
|
return bytes;
|
|
1210
1214
|
})();
|
|
1211
1215
|
digestPages.set(page, pending);
|
|
@@ -1234,14 +1238,29 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1234
1238
|
}
|
|
1235
1239
|
const start = recordOffsets[id];
|
|
1236
1240
|
const end = recordOffsets[id + 1];
|
|
1237
|
-
|
|
1238
|
-
|
|
1241
|
+
// With options.blobCache a verified record is keyed by its own
|
|
1242
|
+
// digest, so the digest comes first; another process may already
|
|
1243
|
+
// have fetched the record. Only verified reads use the cache.
|
|
1244
|
+
const cacheable = perRecord && verifyRecords && options.blobCache;
|
|
1245
|
+
let digest = null;
|
|
1246
|
+
if (cacheable) {
|
|
1239
1247
|
const page = Math.floor(id / pageRecords);
|
|
1240
|
-
const digests = await pageDigests(page);
|
|
1241
1248
|
const at = 32 * (id - page * pageRecords);
|
|
1242
|
-
|
|
1249
|
+
digest = toHex((await pageDigests(page)).subarray(at, at + 32));
|
|
1250
|
+
const cached = await blobCacheGet(options.blobCache, digest, end - start);
|
|
1251
|
+
if (cached) return cached;
|
|
1252
|
+
}
|
|
1253
|
+
const bytes = await readChecked(source, corpus.offset + start, end - start, `corpus record ${id}`, maxRecordBytes, fileBytes);
|
|
1254
|
+
if (perRecord && verifyRecords) {
|
|
1255
|
+
if (!digest) {
|
|
1256
|
+
const page = Math.floor(id / pageRecords);
|
|
1257
|
+
const at = 32 * (id - page * pageRecords);
|
|
1258
|
+
digest = toHex((await pageDigests(page)).subarray(at, at + 32));
|
|
1259
|
+
}
|
|
1260
|
+
if (await sha256hex(bytes) !== digest) {
|
|
1243
1261
|
throw new Error(`.pikelet corpus record ${id} failed integrity verification`);
|
|
1244
1262
|
}
|
|
1263
|
+
blobCachePut(options.blobCache, digest, bytes);
|
|
1245
1264
|
}
|
|
1246
1265
|
return bytes;
|
|
1247
1266
|
};
|
package/complete/index.node.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { nodeFetch } from './node-fetch.mjs';
|
|
|
9
9
|
|
|
10
10
|
export * from './index.mjs';
|
|
11
11
|
export { nodeFetch };
|
|
12
|
-
export { diskBlobCache, defaultBlobCacheDir, DEFAULT_BLOB_CACHE_BYTES } from './blob-cache.node.mjs';
|
|
12
|
+
export { diskBlobCache, defaultBlobCacheDir, DEFAULT_BLOB_CACHE_BYTES, DEFAULT_SMALL_CACHE_BYTES } from './blob-cache.node.mjs';
|
|
13
13
|
|
|
14
14
|
export function httpRangeSource(url, options = {}) {
|
|
15
15
|
return portableHttpRangeSource(url, { fetch: nodeFetch, ...options });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pikelet-wasm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Vector search engine and .pikelet artifact readers/builders, in WebAssembly. Compile a corpus into one self-contained, hash-verified search file, or use the underlying HNSW engine directly. Runs in Node.js, browsers, and Cloudflare Workers with no native dependencies.",
|
|
5
5
|
"main": "src/pikelet.js",
|
|
6
6
|
"types": "src/pikelet.d.ts",
|
|
@@ -462,25 +462,31 @@ async function sha256BytesAsync(bytes) {
|
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
// Read-through cache for
|
|
466
|
-
// sketch's resident prefix, a pack's query-interp
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
465
|
+
// Read-through cache for bytes a pack commits to by sha256: the whole
|
|
466
|
+
// regions a mount reads (the sketch's resident prefix, a pack's query-interp
|
|
467
|
+
// segment) and the pieces a query reads (rerank rows, corpus records, the
|
|
468
|
+
// digest pages that verify them), keyed by that digest. A key may be a
|
|
469
|
+
// truncated digest of at least 128 bits (sketch row digests are 16-32
|
|
470
|
+
// bytes), and a hit must hash to a digest that starts with it. A hit is
|
|
471
|
+
// hashed exactly as a network read would be, so the cache can only ever hand
|
|
472
|
+
// back the committed bytes: anything else it holds (a partial write, disk
|
|
473
|
+
// corruption, a bug elsewhere) reads as a miss and the entry is dropped.
|
|
474
|
+
// Because the key is the content, entries never go stale and need no
|
|
475
|
+
// invalidation. Cache faults never fail a read. `length`, when given, must
|
|
476
|
+
// match too.
|
|
472
477
|
async function blobCacheGet(cache, digestHex, length) {
|
|
473
478
|
if (!cache || typeof cache.get !== 'function') return null;
|
|
479
|
+
if (typeof digestHex !== 'string' || digestHex.length < 32 || digestHex.length > 64) return null;
|
|
474
480
|
let hit;
|
|
475
481
|
try {
|
|
476
482
|
hit = await cache.get(digestHex);
|
|
477
483
|
if (!hit) return null;
|
|
478
484
|
hit = asUint8Array(hit);
|
|
479
485
|
} catch { return null; }
|
|
480
|
-
const digest = hit.byteLength === length ? await sha256BytesAsync(hit) : null;
|
|
486
|
+
const digest = length === undefined || hit.byteLength === length ? await sha256BytesAsync(hit) : null;
|
|
481
487
|
let hex = '';
|
|
482
488
|
if (digest) for (const b of digest) hex += b.toString(16).padStart(2, '0');
|
|
483
|
-
if (hex
|
|
489
|
+
if (hex && hex.startsWith(digestHex)) return hit;
|
|
484
490
|
try { await cache.delete?.(digestHex); } catch { /* a miss either way */ }
|
|
485
491
|
return null;
|
|
486
492
|
}
|
|
@@ -471,6 +471,9 @@ class PikeletSketchArtifact {
|
|
|
471
471
|
// Per-row verification on format 2 follows the same switch as the
|
|
472
472
|
// resident hash: on by default, verify:false opts the whole open out.
|
|
473
473
|
artifact.verifyRows = version >= 2 && verify;
|
|
474
|
+
// Per-query pieces (digest pages and rows) use the same cache as the
|
|
475
|
+
// resident prefix, but only where each read is verified on its own.
|
|
476
|
+
artifact.blobCache = artifact.verifyRows && options.blobCache ? options.blobCache : null;
|
|
474
477
|
const staged = options.staged === true && artifact.microDims > 0;
|
|
475
478
|
artifact.tier = 'full';
|
|
476
479
|
artifact.fullyResident = Promise.resolve(artifact);
|
|
@@ -781,8 +784,37 @@ class PikeletSketchArtifact {
|
|
|
781
784
|
}
|
|
782
785
|
}
|
|
783
786
|
if (!missing.length) return rows;
|
|
784
|
-
missing.sort((a, b) => a - b);
|
|
785
787
|
const { dim, rowsPerBlock, rowDigestBytes, pageBytes, blockBytes, vectorsOffset } = this;
|
|
788
|
+
// options.blobCache at open: a block's digest page is keyed by its
|
|
789
|
+
// page-table entry and each row by its digest in that page, so a row
|
|
790
|
+
// another process already fetched and verified is read from disk.
|
|
791
|
+
// Every hit is hash-checked against its key (blobCacheGet).
|
|
792
|
+
const cache = this.blobCache;
|
|
793
|
+
if (cache) {
|
|
794
|
+
const byBlock = new Map();
|
|
795
|
+
for (const id of missing) {
|
|
796
|
+
const b = Math.floor(id / rowsPerBlock);
|
|
797
|
+
if (!byBlock.has(b)) byBlock.set(b, []);
|
|
798
|
+
byBlock.get(b).push(id);
|
|
799
|
+
}
|
|
800
|
+
const still = [];
|
|
801
|
+
await mapLimit([...byBlock], 16, async ([b, ids]) => {
|
|
802
|
+
const page = await blobCacheGet(cache, hexOf(this.pageTable.subarray(b * 32, b * 32 + 32)), pageBytes);
|
|
803
|
+
if (!page) { still.push(...ids); return; }
|
|
804
|
+
for (const id of ids) {
|
|
805
|
+
const idx = id - b * rowsPerBlock;
|
|
806
|
+
const hit = await blobCacheGet(cache, hexOf(page.subarray(idx * rowDigestBytes, (idx + 1) * rowDigestBytes)), dim);
|
|
807
|
+
if (!hit) { still.push(id); continue; }
|
|
808
|
+
const row = new Uint8Array(hit);
|
|
809
|
+
rows.set(id, row);
|
|
810
|
+
this.cacheRow(id, row);
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
if (!still.length) return rows;
|
|
814
|
+
missing.length = 0;
|
|
815
|
+
missing.push(...still);
|
|
816
|
+
}
|
|
817
|
+
missing.sort((a, b) => a - b);
|
|
786
818
|
const blocks = new Map();
|
|
787
819
|
for (const id of missing) {
|
|
788
820
|
const b = Math.floor(id / rowsPerBlock);
|
|
@@ -836,6 +868,7 @@ class PikeletSketchArtifact {
|
|
|
836
868
|
throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row digest page failed hash verification', { block: b });
|
|
837
869
|
}
|
|
838
870
|
}
|
|
871
|
+
if (cache) blobCachePut(cache, hexOf(expected), page.slice());
|
|
839
872
|
}
|
|
840
873
|
for (const id of blocks.get(b).ids) {
|
|
841
874
|
const idx = id - b * rowsPerBlock;
|
|
@@ -848,6 +881,7 @@ class PikeletSketchArtifact {
|
|
|
848
881
|
throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row failed digest verification', { id });
|
|
849
882
|
}
|
|
850
883
|
}
|
|
884
|
+
if (cache) blobCachePut(cache, hexOf(slot), row);
|
|
851
885
|
}
|
|
852
886
|
if (rows.has(id)) rows.set(id, row);
|
|
853
887
|
this.cacheRow(id, row);
|