pikelet-wasm 0.12.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 +154 -0
- package/README.md +1 -1
- package/complete/blob-cache.node.mjs +337 -0
- package/complete/index.d.ts +41 -0
- package/complete/index.mjs +39 -11
- package/complete/index.node.mjs +1 -0
- package/complete/layer-reader.mjs +4 -0
- package/package.json +1 -1
- package/src/artifact/pikelet-artifact-common.js +38 -0
- package/src/artifact/pikelet-artifact-sketch.js +56 -4
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,160 @@ 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
|
+
|
|
87
|
+
## pikelet-wasm 0.13.0 / pikelet 0.13.0 — 2026-11-01
|
|
88
|
+
|
|
89
|
+
Mounting a pack over HTTP is much cheaper the second time: `pikelet mcp`
|
|
90
|
+
now keeps the two largest mount reads of a URL pack on disk, so a fresh
|
|
91
|
+
process mounting the 649 MiB Wikipedia pack from R2 transfers 9.1 MiB
|
|
92
|
+
instead of 80.1 MiB. A slow remote mount no longer leaves an MCP client
|
|
93
|
+
with no tools. `compile` and `append` now name what they drop instead of
|
|
94
|
+
dropping it silently, and a layered pack keeps its name over MCP. Upgrade
|
|
95
|
+
if you mount packs by URL or update them with `append`. Packs compiled
|
|
96
|
+
before this release still verify and serve, and compiling the same inputs
|
|
97
|
+
gives the same identities as 0.12.0. Both packages move to 0.13.0 together,
|
|
98
|
+
and `pikelet` requires `pikelet-wasm` ^0.13.0.
|
|
99
|
+
|
|
100
|
+
### Compatibility
|
|
101
|
+
|
|
102
|
+
- **`pikelet mcp` writes a cache for URL packs.** The resident index prefix
|
|
103
|
+
and the query encoder of each URL pack mounted (about 71 MiB for the
|
|
104
|
+
Wikipedia pack) are stored in `~/.cache/pikelet/blobs`, up to 2 GiB,
|
|
105
|
+
evicting least recently used entries first. `$PIKELET_CACHE_DIR` or
|
|
106
|
+
`$XDG_CACHE_HOME` moves it, and `--no-cache` turns it off. Local packs
|
|
107
|
+
never use it.
|
|
108
|
+
- **A layered pack mounts under its pack's name.** A chain is named like a
|
|
109
|
+
single pack: from the shelf entry, else the base's manifest name, else
|
|
110
|
+
the file name with the `.0002` layer number dropped. A chain mounted from
|
|
111
|
+
`docs.0002.pikelet` was served as `docs.0002`; it is now `docs`, the
|
|
112
|
+
name its base alone mounted under. Tool calls that name the old pack
|
|
113
|
+
need updating. A single pack keeps a four-digit suffix (`wiki.2025`).
|
|
114
|
+
- **A missing calibration questions file is an error.** A
|
|
115
|
+
`--calibration-queries` file (or `runtime.calibrationQueries`) that does
|
|
116
|
+
not exist used to calibrate without the questions and say nothing; it
|
|
117
|
+
now fails, naming the path.
|
|
118
|
+
- **`tools/list` can answer before the packs have mounted.** After 2
|
|
119
|
+
seconds it answers with the same tools described without pack names,
|
|
120
|
+
uncached, and sends `notifications/tools/list_changed` once the mounts
|
|
121
|
+
settle; `initialize` and `server/discover` advertise
|
|
122
|
+
`tools.listChanged`. Tool calls still wait for the mounts.
|
|
123
|
+
|
|
124
|
+
### Added
|
|
125
|
+
|
|
126
|
+
- **On-disk cache for URL packs (`pikelet-wasm`).** `openPikeletFile`
|
|
127
|
+
takes `blobCache`, a store keyed by sha256. It holds the sketch's
|
|
128
|
+
resident prefix and the query-interp segment that carries the encoder.
|
|
129
|
+
Every hit is hashed against its key, so a corrupt entry is a miss that
|
|
130
|
+
is deleted and refetched, and cache faults never fail an open.
|
|
131
|
+
`diskBlobCache()` and `defaultBlobCacheDir()` (Node, from
|
|
132
|
+
`pikelet-wasm/complete`) provide one on disk, and `pikelet mcp` uses it
|
|
133
|
+
for URL packs. Measured against the Wikipedia pack on R2, a fresh
|
|
134
|
+
process went from 80.1 MiB and 11.5 s to its first answer down to
|
|
135
|
+
9.1 MiB and 6.9 s. Per-query reads are not cached.
|
|
136
|
+
- **A chain reports its pack's name.** `openPikeletChain(...).info().name`
|
|
137
|
+
is the base's name.
|
|
138
|
+
|
|
139
|
+
### Fixed
|
|
140
|
+
|
|
141
|
+
- **A slow remote mount left an MCP client with no tools.** `tools/list`
|
|
142
|
+
waited for every mount; when a URL mount stalled (6.3 MiB in 37 s from
|
|
143
|
+
R2), Claude Code gave up on the list and started the session with no
|
|
144
|
+
tools at all. See Compatibility for how it answers now.
|
|
145
|
+
- **A calibration question naming no record was dropped silently.** Each
|
|
146
|
+
is now logged by name with the reason, and with the title it probably
|
|
147
|
+
meant when Markdown formatting is the difference (titles are matched
|
|
148
|
+
after formatting is stripped). An `expectId` outside the corpus is named
|
|
149
|
+
the same way.
|
|
150
|
+
- **`append` dropped short pages without saying which.** Documents under
|
|
151
|
+
25 whitespace-separated tokens are skipped whole; `compile` and `append`
|
|
152
|
+
now log them with their token counts, and `append`'s refusal when
|
|
153
|
+
nothing is left names them.
|
|
154
|
+
- **`append`'s closing hint was not a usable next step.** It suggested
|
|
155
|
+
`openPikeletChain([...])`, which takes a query vector, not text; it now
|
|
156
|
+
prints `pikelet mcp --pack <layer>#<identity>`.
|
|
157
|
+
|
|
158
|
+
### Documentation
|
|
159
|
+
|
|
160
|
+
- **Deleting is not redacting.** `append --remove` and `--supersede` take a
|
|
161
|
+
record out of search, but earlier files keep its bytes and `get_record`
|
|
162
|
+
serves it by id, marked `tombstoned`, so old citations resolve.
|
|
163
|
+
`compact` writes a base without it. Both READMEs say so.
|
|
164
|
+
|
|
11
165
|
## pikelet-wasm 0.12.0 / pikelet 0.12.0 — 2026-10-30
|
|
12
166
|
|
|
13
167
|
Query embedding is about 1.65x faster, because the inline encoder now
|
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ npx pikelet append --parent docs.0001.pikelet --remove 412 --out docs.0002.pikel
|
|
|
88
88
|
npx pikelet mcp --pack docs.0002.pikelet
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
On a 20-record test pack with a 24.6 MiB base, deleting a record took a 1.2 KiB layer and replacing one took 6.9 KiB. Mounting the newest layer finds and verifies the rest of the chain, locally or over HTTP, and `#<sha256>` on it pins the whole history. Layers inherit the base's calibration until the chain drifts past 20% of the base's records; then it serves `unscored` until `compact` folds it into a fresh base. The format is specified in [`LAYERED_PROFILE.md`](LAYERED_PROFILE.md).
|
|
91
|
+
On a 20-record test pack with a 24.6 MiB base, deleting a record took a 1.2 KiB layer and replacing one took 6.9 KiB. Deleting hides a record from search but does not erase it: the older files still hold its bytes, and `get_record` still serves it by id, marked `tombstoned`, so old citations resolve. `compact` writes a new base without it. Mounting the newest layer finds and verifies the rest of the chain, locally or over HTTP, and `#<sha256>` on it pins the whole history. Layers inherit the base's calibration until the chain drifts past 20% of the base's records; then it serves `unscored` until `compact` folds it into a fresh base. The format is specified in [`LAYERED_PROFILE.md`](LAYERED_PROFILE.md).
|
|
92
92
|
|
|
93
93
|
## Match quality
|
|
94
94
|
|
|
@@ -0,0 +1,337 @@
|
|
|
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.
|
|
14
|
+
//
|
|
15
|
+
// The store is untrusted by design: the reader hashes every hit against the
|
|
16
|
+
// digest it asked for and treats a mismatch as a miss (blobCacheGet in
|
|
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.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs/promises';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
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}$/;
|
|
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;
|
|
44
|
+
|
|
45
|
+
// $PIKELET_CACHE_DIR, else $XDG_CACHE_HOME/pikelet/blobs, else
|
|
46
|
+
// ~/.cache/pikelet/blobs: the same root the CLI already caches encoder
|
|
47
|
+
// weights under.
|
|
48
|
+
export function defaultBlobCacheDir(env = process.env) {
|
|
49
|
+
if (env.PIKELET_CACHE_DIR) return path.resolve(env.PIKELET_CACHE_DIR, 'blobs');
|
|
50
|
+
const base = env.XDG_CACHE_HOME ? path.resolve(env.XDG_CACHE_HOME) : path.join(os.homedir(), '.cache');
|
|
51
|
+
return path.join(base, 'pikelet', 'blobs');
|
|
52
|
+
}
|
|
53
|
+
|
|
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
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const root = path.resolve(dir);
|
|
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;
|
|
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
|
+
})());
|
|
76
|
+
const pending = new Set();
|
|
77
|
+
const track = (promise) => {
|
|
78
|
+
pending.add(promise);
|
|
79
|
+
promise.finally(() => pending.delete(promise)).catch(() => {});
|
|
80
|
+
return promise;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const evict = async (keep) => {
|
|
84
|
+
const entries = [];
|
|
85
|
+
let total = 0;
|
|
86
|
+
for (const shard of await fs.readdir(root).catch(() => [])) {
|
|
87
|
+
if (shard === 'small') continue;
|
|
88
|
+
const shardDir = path.join(root, shard);
|
|
89
|
+
for (const name of await fs.readdir(shardDir).catch(() => [])) {
|
|
90
|
+
if (!KEY.test(name)) continue;
|
|
91
|
+
const file = path.join(shardDir, name);
|
|
92
|
+
const st = await fs.stat(file).catch(() => null);
|
|
93
|
+
if (!st) continue;
|
|
94
|
+
total += st.size;
|
|
95
|
+
entries.push({ file, size: st.size, mtime: st.mtimeMs });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
entries.sort((a, b) => a.mtime - b.mtime);
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (total <= maxBytes) break;
|
|
101
|
+
if (entry.file === keep) continue;
|
|
102
|
+
await fs.rm(entry.file, { force: true });
|
|
103
|
+
total -= entry.size;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
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
|
+
|
|
270
|
+
return {
|
|
271
|
+
dir: root,
|
|
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
|
+
}
|
|
279
|
+
const file = fileOf(key);
|
|
280
|
+
let bytes;
|
|
281
|
+
try { bytes = await fs.readFile(file); } catch (err) {
|
|
282
|
+
if (err?.code === 'ENOENT') return null;
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
// Touch for LRU; best effort.
|
|
286
|
+
const now = new Date();
|
|
287
|
+
fs.utimes(file, now, now).catch(() => {});
|
|
288
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
289
|
+
},
|
|
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
|
+
}
|
|
296
|
+
const file = fileOf(key);
|
|
297
|
+
return track((async () => {
|
|
298
|
+
if (bytes.byteLength > maxBytes) return;
|
|
299
|
+
await makeRootPrivate();
|
|
300
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: DIR_MODE });
|
|
301
|
+
// Write-then-rename, so a reader in another process sees
|
|
302
|
+
// either no entry or a whole one.
|
|
303
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
304
|
+
try {
|
|
305
|
+
await fs.writeFile(tmp, bytes, { mode: FILE_MODE });
|
|
306
|
+
await fs.rename(tmp, file);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
await fs.rm(tmp, { force: true });
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
await evict(file);
|
|
312
|
+
})());
|
|
313
|
+
},
|
|
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);
|
|
319
|
+
await fs.rm(fileOf(key), { force: true });
|
|
320
|
+
},
|
|
321
|
+
// Settles every write started so far: for tests, and for a process
|
|
322
|
+
// that wants its cache populated before it exits.
|
|
323
|
+
async flush() {
|
|
324
|
+
while (pending.size) await Promise.allSettled([...pending]);
|
|
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
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
package/complete/index.d.ts
CHANGED
|
@@ -96,6 +96,32 @@ 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, 32-64 digits). */
|
|
100
|
+
export interface BlobCache {
|
|
101
|
+
get(key: string): Promise<Uint8Array | null> | Uint8Array | null;
|
|
102
|
+
put(key: string, bytes: Uint8Array): Promise<void> | void;
|
|
103
|
+
delete?(key: string): Promise<void> | void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
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.
|
|
114
|
+
*/
|
|
115
|
+
export declare function diskBlobCache(dir?: string, options?: { maxBytes?: number; smallMaxBytes?: number }): BlobCache & {
|
|
116
|
+
dir: string;
|
|
117
|
+
flush(): Promise<void>;
|
|
118
|
+
close(): Promise<void>;
|
|
119
|
+
};
|
|
120
|
+
/** $PIKELET_CACHE_DIR/blobs, else $XDG_CACHE_HOME/pikelet/blobs, else ~/.cache/pikelet/blobs. */
|
|
121
|
+
export declare function defaultBlobCacheDir(env?: Record<string, string | undefined>): string;
|
|
122
|
+
export declare const DEFAULT_BLOB_CACHE_BYTES: number;
|
|
123
|
+
export declare const DEFAULT_SMALL_CACHE_BYTES: number;
|
|
124
|
+
|
|
99
125
|
export interface CompleteQueryResult {
|
|
100
126
|
/**
|
|
101
127
|
* The pack's calibrated verdict on this retrieval. On packs calibrated
|
|
@@ -287,6 +313,21 @@ export declare function openPikeletFile(
|
|
|
287
313
|
* true.
|
|
288
314
|
*/
|
|
289
315
|
prefetchEncoder?: boolean;
|
|
316
|
+
/**
|
|
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.
|
|
329
|
+
*/
|
|
330
|
+
blobCache?: BlobCache;
|
|
290
331
|
/**
|
|
291
332
|
* Fully verify the index's lazy vector rows at open (one streamed pass
|
|
292
333
|
* against the identity-anchored vectorsSha256). Default false: without
|
package/complete/index.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { createAbstentionScorer } from './retrieval-abstention.mjs';
|
|
|
27
27
|
import { openLexicalIndex, openLexicalIndexLazy } from './lexical.mjs';
|
|
28
28
|
import { parseRowMapSegment } from './rowmap.mjs';
|
|
29
29
|
import { PikeletSketchArtifact } from '../src/artifact/pikelet-artifact.js';
|
|
30
|
+
import { blobCacheGet, blobCachePut } from '../src/artifact/pikelet-artifact-common.js';
|
|
30
31
|
import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
|
|
31
32
|
|
|
32
33
|
import {
|
|
@@ -96,9 +97,9 @@ const COVERAGE_BATCH_COUNT = 5;
|
|
|
96
97
|
// Only BM25 hits within this fraction of the top lexical score join fusion.
|
|
97
98
|
// Common query terms give every matching document a near-tied score (idf
|
|
98
99
|
// collapses them flat); a wide cutoff let that tied mass ride into fusion on
|
|
99
|
-
// a technicality.
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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.
|
|
102
103
|
export const LEXICAL_CUTOFF = 1.5;
|
|
103
104
|
// Query-interpretation segments above this size open lazily (kind 3's
|
|
104
105
|
// inline encoder is ~25 MiB); smaller ones keep the one-read eager path.
|
|
@@ -785,6 +786,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
785
786
|
// the same bytes the sketch parses (see expectedHeaderSha256).
|
|
786
787
|
PikeletSketchArtifact.open(windowSource(source, idx.offset, idx.length), {
|
|
787
788
|
maxReadBytes,
|
|
789
|
+
...(options.blobCache ? { blobCache: options.blobCache } : {}),
|
|
788
790
|
...(perRecord ? { expectedHeaderSha256: manifest.index.headerSha256 } : {}),
|
|
789
791
|
}),
|
|
790
792
|
readChecked(source, corpus.offset, tablesBytes, 'corpus tables', maxReadBytes, fileBytes),
|
|
@@ -1004,9 +1006,16 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1004
1006
|
const loadEmbedder = async () => {
|
|
1005
1007
|
let encBytes = encoderBytes;
|
|
1006
1008
|
if (qiDeferred) {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1009
|
+
// The ~25 MiB encoder is the other bulk of a cold mount;
|
|
1010
|
+
// options.blobCache serves it by the manifest's segment
|
|
1011
|
+
// digest (hash-checked on the way out, as a fetch is).
|
|
1012
|
+
const cached = await blobCacheGet(options.blobCache, qi.sha256, qi.length);
|
|
1013
|
+
const full = cached || await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
|
|
1014
|
+
if (!cached) {
|
|
1015
|
+
if (await sha256hex(full) !== qi.sha256) {
|
|
1016
|
+
throw new Error('.pikelet query-interp segment failed hash verification');
|
|
1017
|
+
}
|
|
1018
|
+
blobCachePut(options.blobCache, qi.sha256, full);
|
|
1010
1019
|
}
|
|
1011
1020
|
// The header and calibration slices used at open were
|
|
1012
1021
|
// unverified; the digest-verified segment must contain
|
|
@@ -1191,12 +1200,16 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1191
1200
|
const pending = (async () => {
|
|
1192
1201
|
const from = Math.min(recordCount, page * pageRecords);
|
|
1193
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;
|
|
1194
1206
|
const bytes = await readChecked(source, corpus.offset + recordDigestsAt + 32 * from, 32 * (to - from),
|
|
1195
1207
|
`corpus digest page ${page}`, maxReadBytes, fileBytes);
|
|
1196
|
-
if (await sha256hex(bytes) !==
|
|
1208
|
+
if (await sha256hex(bytes) !== key) {
|
|
1197
1209
|
digestPages.delete(page);
|
|
1198
1210
|
throw new Error(`.pikelet corpus digest page ${page} failed hash verification`);
|
|
1199
1211
|
}
|
|
1212
|
+
blobCachePut(options.blobCache, key, bytes);
|
|
1200
1213
|
return bytes;
|
|
1201
1214
|
})();
|
|
1202
1215
|
digestPages.set(page, pending);
|
|
@@ -1225,14 +1238,29 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1225
1238
|
}
|
|
1226
1239
|
const start = recordOffsets[id];
|
|
1227
1240
|
const end = recordOffsets[id + 1];
|
|
1228
|
-
|
|
1229
|
-
|
|
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) {
|
|
1230
1247
|
const page = Math.floor(id / pageRecords);
|
|
1231
|
-
const digests = await pageDigests(page);
|
|
1232
1248
|
const at = 32 * (id - page * pageRecords);
|
|
1233
|
-
|
|
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) {
|
|
1234
1261
|
throw new Error(`.pikelet corpus record ${id} failed integrity verification`);
|
|
1235
1262
|
}
|
|
1263
|
+
blobCachePut(options.blobCache, digest, bytes);
|
|
1236
1264
|
}
|
|
1237
1265
|
return bytes;
|
|
1238
1266
|
};
|
package/complete/index.node.mjs
CHANGED
|
@@ -9,6 +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, DEFAULT_SMALL_CACHE_BYTES } from './blob-cache.node.mjs';
|
|
12
13
|
|
|
13
14
|
export function httpRangeSource(url, options = {}) {
|
|
14
15
|
return portableHttpRangeSource(url, { fetch: nodeFetch, ...options });
|
|
@@ -369,6 +369,10 @@ export async function openPikeletChain(members, options = {}) {
|
|
|
369
369
|
profile: LAYER_PROFILE,
|
|
370
370
|
identity: chainMembers[chainMembers.length - 1].identity,
|
|
371
371
|
baseIdentity: base.identity,
|
|
372
|
+
// The pack's name is the base's: layers add records, not a
|
|
373
|
+
// new name. Without it a host fell back to naming the
|
|
374
|
+
// chain after its newest layer's file.
|
|
375
|
+
name: opened[0].info().name ?? null,
|
|
372
376
|
layers: chainMembers.length,
|
|
373
377
|
searchTiers: searchTiers.length,
|
|
374
378
|
records: table.rowTotal,
|
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,6 +462,42 @@ async function sha256BytesAsync(bytes) {
|
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
464
|
|
|
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.
|
|
477
|
+
async function blobCacheGet(cache, digestHex, length) {
|
|
478
|
+
if (!cache || typeof cache.get !== 'function') return null;
|
|
479
|
+
if (typeof digestHex !== 'string' || digestHex.length < 32 || digestHex.length > 64) return null;
|
|
480
|
+
let hit;
|
|
481
|
+
try {
|
|
482
|
+
hit = await cache.get(digestHex);
|
|
483
|
+
if (!hit) return null;
|
|
484
|
+
hit = asUint8Array(hit);
|
|
485
|
+
} catch { return null; }
|
|
486
|
+
const digest = length === undefined || hit.byteLength === length ? await sha256BytesAsync(hit) : null;
|
|
487
|
+
let hex = '';
|
|
488
|
+
if (digest) for (const b of digest) hex += b.toString(16).padStart(2, '0');
|
|
489
|
+
if (hex && hex.startsWith(digestHex)) return hit;
|
|
490
|
+
try { await cache.delete?.(digestHex); } catch { /* a miss either way */ }
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Store verified bytes. Fire-and-forget: a slow or failing cache must not
|
|
495
|
+
// hold up the open that just fetched them.
|
|
496
|
+
function blobCachePut(cache, digestHex, bytes) {
|
|
497
|
+
if (!cache || typeof cache.put !== 'function') return;
|
|
498
|
+
Promise.resolve().then(() => cache.put(digestHex, bytes)).catch(() => {});
|
|
499
|
+
}
|
|
500
|
+
|
|
465
501
|
// Verify bytes against an expected 32-byte SHA-256 digest. Verification was
|
|
466
502
|
// requested by the caller, so a missing crypto backend fails closed rather
|
|
467
503
|
// than admitting unverified bytes.
|
|
@@ -556,6 +592,8 @@ module.exports = {
|
|
|
556
592
|
parseUint8Snapshot,
|
|
557
593
|
sha256Bytes,
|
|
558
594
|
sha256BytesAsync,
|
|
595
|
+
blobCacheGet,
|
|
596
|
+
blobCachePut,
|
|
559
597
|
verifySha256,
|
|
560
598
|
verifySegmentSha256,
|
|
561
599
|
};
|
|
@@ -17,6 +17,8 @@ const {
|
|
|
17
17
|
parseUint8Snapshot,
|
|
18
18
|
sha256Bytes,
|
|
19
19
|
sha256BytesAsync,
|
|
20
|
+
blobCacheGet,
|
|
21
|
+
blobCachePut,
|
|
20
22
|
verifySegmentSha256,
|
|
21
23
|
} = require('./pikelet-artifact-common.js');
|
|
22
24
|
const {
|
|
@@ -28,6 +30,12 @@ const {
|
|
|
28
30
|
// ============================================================================
|
|
29
31
|
// Sketch artifact profile (.pikelet-sketch) — spec/SKETCH_PROFILE.md
|
|
30
32
|
// ============================================================================
|
|
33
|
+
|
|
34
|
+
function hexOf(bytes) {
|
|
35
|
+
let hex = '';
|
|
36
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
|
37
|
+
return hex;
|
|
38
|
+
}
|
|
31
39
|
//
|
|
32
40
|
// Layout: 256-byte header | scales f32[count] | offsets f32[count] |
|
|
33
41
|
// packed pooled sketches | zero padding to 16-byte alignment | raw u8 rows.
|
|
@@ -463,12 +471,23 @@ class PikeletSketchArtifact {
|
|
|
463
471
|
// Per-row verification on format 2 follows the same switch as the
|
|
464
472
|
// resident hash: on by default, verify:false opts the whole open out.
|
|
465
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;
|
|
466
477
|
const staged = options.staged === true && artifact.microDims > 0;
|
|
467
478
|
artifact.tier = 'full';
|
|
468
479
|
artifact.fullyResident = Promise.resolve(artifact);
|
|
469
480
|
|
|
470
|
-
|
|
471
|
-
|
|
481
|
+
// options.blobCache: the resident prefix is the bulk of a mount (46 MiB
|
|
482
|
+
// of a 649 MiB wiki pack) and is committed by residentSha256, so a
|
|
483
|
+
// cache keyed by that digest serves it to later processes. A hit is
|
|
484
|
+
// hash-checked inside blobCacheGet; only verified opens consult it,
|
|
485
|
+
// since verify:false has nothing to check a hit against. A cache hit
|
|
486
|
+
// also skips staging: the full tier is already local.
|
|
487
|
+
const cacheKey = verify && options.blobCache ? hexOf(header.subarray(56, 88)) : null;
|
|
488
|
+
const cached = cacheKey ? await blobCacheGet(options.blobCache, cacheKey, vectorsOffset - SKETCH_HEADER_BYTES) : null;
|
|
489
|
+
if (!staged || cached) {
|
|
490
|
+
const resident = cached || await readChecked(source, SKETCH_HEADER_BYTES, vectorsOffset - SKETCH_HEADER_BYTES, 'resident prefix', artifact.maxReadBytes);
|
|
472
491
|
if (resident.byteLength !== vectorsOffset - SKETCH_HEADER_BYTES) {
|
|
473
492
|
throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact resident prefix is truncated');
|
|
474
493
|
}
|
|
@@ -477,7 +496,9 @@ class PikeletSketchArtifact {
|
|
|
477
496
|
const residentCopy = new Uint8Array(resident.byteLength);
|
|
478
497
|
residentCopy.set(resident);
|
|
479
498
|
artifact._adoptResident(residentCopy, count, sketchRowBytes, microRowBytes, microOffset);
|
|
480
|
-
if (
|
|
499
|
+
if (cached) artifact.residentVerified = true;
|
|
500
|
+
else if (verify) await artifact._verifyFullResident(residentCopy, header);
|
|
501
|
+
if (cacheKey && !cached) blobCachePut(options.blobCache, cacheKey, residentCopy);
|
|
481
502
|
return artifact;
|
|
482
503
|
}
|
|
483
504
|
|
|
@@ -763,8 +784,37 @@ class PikeletSketchArtifact {
|
|
|
763
784
|
}
|
|
764
785
|
}
|
|
765
786
|
if (!missing.length) return rows;
|
|
766
|
-
missing.sort((a, b) => a - b);
|
|
767
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);
|
|
768
818
|
const blocks = new Map();
|
|
769
819
|
for (const id of missing) {
|
|
770
820
|
const b = Math.floor(id / rowsPerBlock);
|
|
@@ -818,6 +868,7 @@ class PikeletSketchArtifact {
|
|
|
818
868
|
throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row digest page failed hash verification', { block: b });
|
|
819
869
|
}
|
|
820
870
|
}
|
|
871
|
+
if (cache) blobCachePut(cache, hexOf(expected), page.slice());
|
|
821
872
|
}
|
|
822
873
|
for (const id of blocks.get(b).ids) {
|
|
823
874
|
const idx = id - b * rowsPerBlock;
|
|
@@ -830,6 +881,7 @@ class PikeletSketchArtifact {
|
|
|
830
881
|
throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row failed digest verification', { id });
|
|
831
882
|
}
|
|
832
883
|
}
|
|
884
|
+
if (cache) blobCachePut(cache, hexOf(slot), row);
|
|
833
885
|
}
|
|
834
886
|
if (rows.has(id)) rows.set(id, row);
|
|
835
887
|
this.cacheRow(id, row);
|