sliftutils 1.4.47 → 1.4.49
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/index.d.ts +375 -71
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +36 -70
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +292 -1236
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +39 -1
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +242 -26
- package/storage/BulkDatabase2/BulkDatabaseMerge.d.ts +49 -0
- package/storage/BulkDatabase2/BulkDatabaseMerge.ts +398 -0
- package/storage/BulkDatabase2/BulkDatabaseReader.d.ts +71 -0
- package/storage/BulkDatabase2/BulkDatabaseReader.ts +273 -0
- package/storage/BulkDatabase2/LoadedIndex.d.ts +133 -0
- package/storage/BulkDatabase2/LoadedIndex.ts +402 -0
- package/storage/BulkDatabase2/WriteOverlay.d.ts +30 -0
- package/storage/BulkDatabase2/WriteOverlay.ts +52 -0
- package/storage/BulkDatabase2/blockCache.d.ts +1 -0
- package/storage/BulkDatabase2/blockCache.ts +6 -0
- package/storage/BulkDatabase2/streamLog.ts +78 -1
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import type { FileStorage } from "../FileFolderAPI";
|
|
2
|
+
import { ABSENT, BaseBulkDatabaseReader, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase } from "./BulkDatabaseFormat";
|
|
3
|
+
import { blockCache, GetRange } from "./blockCache";
|
|
4
|
+
import { STREAM_EXTENSION, StreamEntry, parseStream, streamReaderFromEntries } from "./streamLog";
|
|
5
|
+
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
6
|
+
import { blue, red } from "socket-function/src/formatting/logColors";
|
|
7
|
+
|
|
8
|
+
export type BulkFileInfo = { fileName: string; level: number; timestamp: number };
|
|
9
|
+
export type StreamFileInfo = { fileName: string; timestamp: number };
|
|
10
|
+
export type StreamReaderCacheEntry = { readSize: number; parsedPos: number; entries: StreamEntry[] };
|
|
11
|
+
|
|
12
|
+
export class MissingFileError extends Error { }
|
|
13
|
+
class FilesChangedError extends Error { }
|
|
14
|
+
|
|
15
|
+
export type ResolvedReader = {
|
|
16
|
+
rowCount: number;
|
|
17
|
+
totalBytes: number;
|
|
18
|
+
keys: string[];
|
|
19
|
+
rawKeyCount: number;
|
|
20
|
+
readerCount: number;
|
|
21
|
+
columns: { column: string; byteSize: number }[];
|
|
22
|
+
keyTimes: Map<string, number>;
|
|
23
|
+
deleteTimes: Map<string, number>;
|
|
24
|
+
getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
|
|
25
|
+
getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | undefined>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SubReaderCaches = {
|
|
29
|
+
bulk: Map<string, BaseBulkDatabaseReader>;
|
|
30
|
+
stream: Map<string, StreamReaderCacheEntry>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MAX_READ_ATTEMPTS = 8;
|
|
34
|
+
|
|
35
|
+
function nullJoin(a: string, b: string): string {
|
|
36
|
+
return a + String.fromCharCode(0) + b;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A loaded snapshot of one collection's on-disk state: the resolved reader + the per-file decoded sub-
|
|
40
|
+
// readers it stitched, the stream file sizes/times it observed, and the sync (reactive) caches that
|
|
41
|
+
// surface column/field reads. Built once via LoadedIndex.build — eagerly, so the swap into a fresh
|
|
42
|
+
// LoadedIndex never blocks a read on a synchronous rebuild.
|
|
43
|
+
export class LoadedIndex<T extends { key: string }> {
|
|
44
|
+
private constructor(
|
|
45
|
+
public readonly name: string,
|
|
46
|
+
public readonly storage: FileStorage,
|
|
47
|
+
public readonly bulkFiles: BulkFileInfo[],
|
|
48
|
+
public readonly streamFiles: StreamFileInfo[],
|
|
49
|
+
public readonly reader: ResolvedReader,
|
|
50
|
+
public readonly streamTimes: Map<string, number>,
|
|
51
|
+
public readonly streamSizes: Map<string, number>,
|
|
52
|
+
public readonly streamRowsOnDisk: number,
|
|
53
|
+
public readonly streamBytesOnDisk: number,
|
|
54
|
+
public readonly subCaches: SubReaderCaches,
|
|
55
|
+
) {
|
|
56
|
+
this.keys = new Set(reader.keys);
|
|
57
|
+
this.fileSet = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public readonly keys: Set<string>;
|
|
61
|
+
public readonly fileSet: Set<string>;
|
|
62
|
+
|
|
63
|
+
private baseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
|
|
64
|
+
private baseColumnsLoading = new Set<string>();
|
|
65
|
+
private baseFields = new Map<string, { value: unknown; time: number } | undefined>();
|
|
66
|
+
private baseFieldsLoading = new Set<string>();
|
|
67
|
+
// Last-known values from the previous index, served until this index loads its own fresh value, so
|
|
68
|
+
// a swap doesn't flash empty in sync reads. Cleared per-entry when the fresh value lands.
|
|
69
|
+
private staleBaseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
|
|
70
|
+
private staleBaseFields = new Map<string, { value: unknown; time: number } | undefined>();
|
|
71
|
+
|
|
72
|
+
get totalBytes(): number { return this.reader.totalBytes; }
|
|
73
|
+
get rowCount(): number { return this.reader.rowCount; }
|
|
74
|
+
|
|
75
|
+
isLive(key: string): boolean { return this.keys.has(key); }
|
|
76
|
+
|
|
77
|
+
static async build<T extends { key: string }>(config: {
|
|
78
|
+
name: string;
|
|
79
|
+
storage: FileStorage;
|
|
80
|
+
bulkFiles: BulkFileInfo[];
|
|
81
|
+
streamFiles: StreamFileInfo[];
|
|
82
|
+
subCaches: SubReaderCaches;
|
|
83
|
+
onUnreadableFile?: (file: BulkFileInfo, message: string) => Promise<void>;
|
|
84
|
+
}): Promise<LoadedIndex<T>> {
|
|
85
|
+
const start = Date.now();
|
|
86
|
+
let { bulkFiles, streamFiles } = config;
|
|
87
|
+
let attempt = 0;
|
|
88
|
+
while (true) {
|
|
89
|
+
const tolerateMissing = attempt >= MAX_READ_ATTEMPTS;
|
|
90
|
+
try {
|
|
91
|
+
let filesChanged = false;
|
|
92
|
+
const [bulkReadersRaw, streamData] = await Promise.all([
|
|
93
|
+
Promise.all(bulkFiles.map(async f => {
|
|
94
|
+
try {
|
|
95
|
+
return await loadFileReader(config.name, config.storage, f, config.subCaches.bulk);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e instanceof MissingFileError) { filesChanged = true; return undefined; }
|
|
98
|
+
if (config.onUnreadableFile) await config.onUnreadableFile(f, (e as Error).message);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
})),
|
|
102
|
+
loadStreamEntries(config.name, config.storage, streamFiles, config.subCaches.stream),
|
|
103
|
+
]);
|
|
104
|
+
if (streamData.missing) filesChanged = true;
|
|
105
|
+
if (filesChanged && !tolerateMissing) throw new FilesChangedError();
|
|
106
|
+
|
|
107
|
+
const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
|
|
108
|
+
const readers: BaseBulkDatabaseReader[] = [];
|
|
109
|
+
let streamTimes = new Map<string, number>();
|
|
110
|
+
const ordered = orderStreamEntries(streamData.entries);
|
|
111
|
+
if (ordered.length) {
|
|
112
|
+
const stream = streamReaderFromEntries(ordered, streamData.totalBytes);
|
|
113
|
+
readers.push(stream.reader);
|
|
114
|
+
streamTimes = stream.times;
|
|
115
|
+
}
|
|
116
|
+
readers.push(...bulkReaders);
|
|
117
|
+
const reader = joinBulkDatabases(readers);
|
|
118
|
+
|
|
119
|
+
pruneSubCaches(bulkFiles, streamFiles, config.subCaches);
|
|
120
|
+
|
|
121
|
+
const elapsed = Date.now() - start;
|
|
122
|
+
if (elapsed > 50) {
|
|
123
|
+
let bytesRead = streamData.totalBytes;
|
|
124
|
+
for (const r of bulkReaders) bytesRead += r.columns.find(c => c.column === KEY_COLUMN)?.byteSize ?? 0;
|
|
125
|
+
console.log(`${blue(config.name)} loaded in ${red(formatTime(elapsed))} (${blue(formatNumber(reader.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files, read ${blue(formatNumber(bytesRead))}B)`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return new LoadedIndex<T>(
|
|
129
|
+
config.name,
|
|
130
|
+
config.storage,
|
|
131
|
+
bulkFiles,
|
|
132
|
+
streamFiles,
|
|
133
|
+
reader,
|
|
134
|
+
streamTimes,
|
|
135
|
+
streamData.sizes,
|
|
136
|
+
streamData.entries.length,
|
|
137
|
+
streamData.totalBytes,
|
|
138
|
+
config.subCaches,
|
|
139
|
+
);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e instanceof FilesChangedError) {
|
|
142
|
+
attempt++;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
throw e;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Adopts the previous index's loaded base values as STALE on this one, so a sync read mid-swap can
|
|
151
|
+
// serve the old value (patched with the current overlay) instead of flashing empty while the fresh
|
|
152
|
+
// load runs. Per-entry: dropped when the fresh value lands here.
|
|
153
|
+
inheritStaleFrom(prev: LoadedIndex<T>): void {
|
|
154
|
+
for (const [k, v] of prev.baseColumns) this.staleBaseColumns.set(k, v);
|
|
155
|
+
for (const [k, v] of prev.staleBaseColumns) if (!this.staleBaseColumns.has(k)) this.staleBaseColumns.set(k, v);
|
|
156
|
+
for (const [k, v] of prev.baseFields) this.staleBaseFields.set(k, v);
|
|
157
|
+
for (const [k, v] of prev.staleBaseFields) if (!this.staleBaseFields.has(k)) this.staleBaseFields.set(k, v);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async getColumn(column: string): Promise<{ key: string; value: unknown; time: number }[]> {
|
|
161
|
+
return this.reader.getColumn(column);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getSingleField(key: string, column: string): Promise<{ value: unknown; time: number } | undefined> {
|
|
165
|
+
return this.reader.getSingleField(key, column);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
ensureBaseColumn(column: string, onLoaded: () => void): void {
|
|
169
|
+
if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
|
|
170
|
+
this.baseColumnsLoading.add(column);
|
|
171
|
+
void (async () => {
|
|
172
|
+
try {
|
|
173
|
+
const value = await this.reader.getColumn(column);
|
|
174
|
+
this.baseColumns.set(column, value);
|
|
175
|
+
this.staleBaseColumns.delete(column);
|
|
176
|
+
this.baseColumnsLoading.delete(column);
|
|
177
|
+
onLoaded();
|
|
178
|
+
} catch (e) {
|
|
179
|
+
this.baseColumnsLoading.delete(column);
|
|
180
|
+
console.warn(`${this.name}.getColumnSync(${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
ensureBaseField(key: string, column: string, onLoaded: () => void): void {
|
|
186
|
+
const ck = nullJoin(column, key);
|
|
187
|
+
if (this.baseFields.has(ck) || this.baseFieldsLoading.has(ck)) return;
|
|
188
|
+
this.baseFieldsLoading.add(ck);
|
|
189
|
+
void (async () => {
|
|
190
|
+
try {
|
|
191
|
+
const value = await this.reader.getSingleField(key, column);
|
|
192
|
+
this.baseFields.set(ck, value);
|
|
193
|
+
this.staleBaseFields.delete(ck);
|
|
194
|
+
this.baseFieldsLoading.delete(ck);
|
|
195
|
+
onLoaded();
|
|
196
|
+
} catch (e) {
|
|
197
|
+
this.baseFieldsLoading.delete(ck);
|
|
198
|
+
console.warn(`${this.name}.getSingleFieldSync(${JSON.stringify(key)}, ${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
|
|
199
|
+
}
|
|
200
|
+
})();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Returns the loaded value (fresh if available, else last-known stale) for the column, or undefined
|
|
204
|
+
// if neither is present yet. The boolean indicates whether the returned value is fresh; callers may
|
|
205
|
+
// skip caching a stale value.
|
|
206
|
+
getBaseColumn(column: string): { entries: { key: string; value: unknown; time: number }[]; fresh: boolean } | undefined {
|
|
207
|
+
const fresh = this.baseColumns.get(column);
|
|
208
|
+
if (fresh) return { entries: fresh, fresh: true };
|
|
209
|
+
const stale = this.staleBaseColumns.get(column);
|
|
210
|
+
if (stale) return { entries: stale, fresh: false };
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
getBaseField(key: string, column: string): { value: { value: unknown; time: number } | undefined; fresh: boolean; loaded: boolean } {
|
|
215
|
+
const ck = nullJoin(column, key);
|
|
216
|
+
if (this.baseFields.has(ck)) return { value: this.baseFields.get(ck), fresh: true, loaded: true };
|
|
217
|
+
if (this.staleBaseFields.has(ck)) return { value: this.staleBaseFields.get(ck), fresh: false, loaded: true };
|
|
218
|
+
return { value: undefined, fresh: false, loaded: false };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
isBaseColumnLoaded(column: string): boolean {
|
|
222
|
+
return this.baseColumns.has(column) || this.staleBaseColumns.has(column);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
isBaseFieldLoaded(key: string, column: string): boolean {
|
|
226
|
+
const ck = nullJoin(column, key);
|
|
227
|
+
return this.baseFields.has(ck) || this.staleBaseFields.has(ck);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Drops in-memory loaded values for this index, so a subsequent read reloads fresh from disk. Used
|
|
231
|
+
// by reloadFromDisk() — a hard reset, no stale fallback survives.
|
|
232
|
+
dropLoadedValues(): void {
|
|
233
|
+
this.baseColumns.clear();
|
|
234
|
+
this.baseColumnsLoading.clear();
|
|
235
|
+
this.baseFields.clear();
|
|
236
|
+
this.baseFieldsLoading.clear();
|
|
237
|
+
this.staleBaseColumns.clear();
|
|
238
|
+
this.staleBaseFields.clear();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function makeRawGetRange(storage: FileStorage, fileName: string): Promise<{ rawGetRange: GetRange; size: number }> {
|
|
243
|
+
const info = await storage.getInfo(fileName);
|
|
244
|
+
if (!info) throw new MissingFileError(`bulk file ${fileName} is missing`);
|
|
245
|
+
const rawGetRange: GetRange = async (start, end) => {
|
|
246
|
+
if (end <= start) return EMPTY_BUFFER;
|
|
247
|
+
const buf = await storage.getRange(fileName, { start, end });
|
|
248
|
+
if (!buf) throw new MissingFileError(`range [${start}, ${end}) of ${fileName} is missing`);
|
|
249
|
+
return buf;
|
|
250
|
+
};
|
|
251
|
+
return { rawGetRange, size: info.size };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function loadFileReader(name: string, storage: FileStorage, f: BulkFileInfo, cache: Map<string, BaseBulkDatabaseReader>): Promise<BaseBulkDatabaseReader> {
|
|
255
|
+
const cached = cache.get(f.fileName);
|
|
256
|
+
if (cached) return cached;
|
|
257
|
+
const raw = await makeRawGetRange(storage, f.fileName);
|
|
258
|
+
const fileId = nullJoin(name, f.fileName);
|
|
259
|
+
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
260
|
+
const reader = await loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
|
|
261
|
+
cache.set(f.fileName, reader);
|
|
262
|
+
return reader;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function loadStreamEntries(
|
|
266
|
+
name: string,
|
|
267
|
+
storage: FileStorage,
|
|
268
|
+
streamFiles: StreamFileInfo[],
|
|
269
|
+
cache: Map<string, StreamReaderCacheEntry>,
|
|
270
|
+
): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number; missing: boolean; sizes: Map<string, number> }> {
|
|
271
|
+
const sizes = new Map<string, number>();
|
|
272
|
+
if (!streamFiles.length) return { entries: [], totalBytes: 0, missing: false, sizes };
|
|
273
|
+
let missing = false;
|
|
274
|
+
const perFile = await Promise.all(streamFiles.map(async (f): Promise<{ fileName: string; size: number; entries: StreamEntry[] } | undefined> => {
|
|
275
|
+
try {
|
|
276
|
+
const info = await storage.getInfo(f.fileName);
|
|
277
|
+
if (!info) { missing = true; return undefined; }
|
|
278
|
+
const size = info.size;
|
|
279
|
+
sizes.set(f.fileName, size);
|
|
280
|
+
const cached = cache.get(f.fileName);
|
|
281
|
+
if (cached && cached.readSize === size) return { fileName: f.fileName, size, entries: cached.entries };
|
|
282
|
+
if (size === 0) { cache.set(f.fileName, { readSize: 0, parsedPos: 0, entries: [] }); return { fileName: f.fileName, size: 0, entries: [] }; }
|
|
283
|
+
// Append-only: a larger size means new bytes at the tail, parse only those.
|
|
284
|
+
if (cached && size > cached.readSize) {
|
|
285
|
+
const suffix = await storage.getRange(f.fileName, { start: cached.parsedPos, end: size });
|
|
286
|
+
if (!suffix) { missing = true; return undefined; }
|
|
287
|
+
const parsed = parseStream(suffix);
|
|
288
|
+
if (parsed.badBytes > 0) console.warn(`${name} stream file ${f.fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
|
|
289
|
+
const entries = parsed.entries.length ? cached.entries.concat(parsed.entries) : cached.entries;
|
|
290
|
+
const parsedPos = cached.parsedPos + (suffix.length - parsed.badBytes);
|
|
291
|
+
cache.set(f.fileName, { readSize: size, parsedPos, entries });
|
|
292
|
+
return { fileName: f.fileName, size, entries };
|
|
293
|
+
}
|
|
294
|
+
const buffer = await storage.getRange(f.fileName, { start: 0, end: size });
|
|
295
|
+
if (!buffer) { missing = true; return undefined; }
|
|
296
|
+
const parsed = parseStream(buffer);
|
|
297
|
+
if (parsed.badBytes > 0) console.warn(`${name} stream file ${f.fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
|
|
298
|
+
cache.set(f.fileName, { readSize: size, parsedPos: size - parsed.badBytes, entries: parsed.entries });
|
|
299
|
+
return { fileName: f.fileName, size, entries: parsed.entries };
|
|
300
|
+
} catch {
|
|
301
|
+
missing = true;
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
}));
|
|
305
|
+
const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
|
|
306
|
+
let totalBytes = 0;
|
|
307
|
+
for (const pf of perFile) {
|
|
308
|
+
if (!pf) continue;
|
|
309
|
+
totalBytes += pf.size;
|
|
310
|
+
for (const entry of pf.entries) entries.push({ time: entry.time, fileName: pf.fileName, entry });
|
|
311
|
+
}
|
|
312
|
+
return { entries, totalBytes, missing, sizes };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function orderStreamEntries(entries: { time: number; fileName: string; entry: StreamEntry }[]): StreamEntry[] {
|
|
316
|
+
entries.sort((a, b) => {
|
|
317
|
+
if (a.time !== b.time) return a.time - b.time;
|
|
318
|
+
return a.fileName < b.fileName && -1 || a.fileName > b.fileName && 1 || 0;
|
|
319
|
+
});
|
|
320
|
+
return entries.map(e => e.entry);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function pruneSubCaches(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], subCaches: SubReaderCaches) {
|
|
324
|
+
const liveBulk = new Set(bulkFiles.map(f => f.fileName));
|
|
325
|
+
const liveStream = new Set(streamFiles.map(f => f.fileName));
|
|
326
|
+
for (const n of subCaches.bulk.keys()) if (!liveBulk.has(n)) subCaches.bulk.delete(n);
|
|
327
|
+
for (const n of subCaches.stream.keys()) if (!liveStream.has(n)) subCaches.stream.delete(n);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): ResolvedReader {
|
|
331
|
+
const deleteTime = new Map<string, number>();
|
|
332
|
+
for (const db of databases) {
|
|
333
|
+
if (!db.deleteTimes) continue;
|
|
334
|
+
for (const [key, t] of db.deleteTimes) deleteTime.set(key, Math.max(deleteTime.get(key) ?? -Infinity, t));
|
|
335
|
+
}
|
|
336
|
+
const keyTime = new Map<string, number>();
|
|
337
|
+
for (const db of databases) {
|
|
338
|
+
for (const [key, t] of db.keyTimes) keyTime.set(key, Math.max(keyTime.get(key) ?? -Infinity, t));
|
|
339
|
+
}
|
|
340
|
+
const delOf = (key: string) => deleteTime.get(key) ?? -Infinity;
|
|
341
|
+
const keys: string[] = [];
|
|
342
|
+
for (const [key, t] of keyTime) if (t > delOf(key)) keys.push(key);
|
|
343
|
+
|
|
344
|
+
const columns: { column: string; byteSize: number }[] = [];
|
|
345
|
+
const columnByName = new Map<string, { column: string; byteSize: number }>();
|
|
346
|
+
for (const db of databases) {
|
|
347
|
+
for (const col of db.columns) {
|
|
348
|
+
let existing = columnByName.get(col.column);
|
|
349
|
+
if (!existing) {
|
|
350
|
+
existing = { column: col.column, byteSize: 0 };
|
|
351
|
+
columnByName.set(col.column, existing);
|
|
352
|
+
columns.push(existing);
|
|
353
|
+
}
|
|
354
|
+
existing.byteSize += col.byteSize;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
|
|
360
|
+
rowCount: keys.length,
|
|
361
|
+
keys,
|
|
362
|
+
rawKeyCount: databases.reduce((acc, db) => acc + db.keyTimes.size + (db.deleteTimes?.size ?? 0), 0),
|
|
363
|
+
readerCount: databases.length,
|
|
364
|
+
columns,
|
|
365
|
+
keyTimes: keyTime,
|
|
366
|
+
deleteTimes: deleteTime,
|
|
367
|
+
async getColumn(column) {
|
|
368
|
+
const perReader = await Promise.all(databases.map(async db => {
|
|
369
|
+
if (!db.columns.some(c => c.column === column)) return undefined;
|
|
370
|
+
const entries = await db.getColumn(column);
|
|
371
|
+
return new Map(entries.map(e => [e.key, { value: e.value, time: e.time }]));
|
|
372
|
+
}));
|
|
373
|
+
return keys.map(key => {
|
|
374
|
+
let bestTime = -Infinity;
|
|
375
|
+
let bestVal: unknown;
|
|
376
|
+
let found = false;
|
|
377
|
+
for (const m of perReader) {
|
|
378
|
+
const cell = m && m.get(key);
|
|
379
|
+
if (!cell || cell.value === ABSENT) continue;
|
|
380
|
+
if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
|
|
381
|
+
}
|
|
382
|
+
const live = found && bestTime > delOf(key);
|
|
383
|
+
return { key, value: live ? bestVal : undefined, time: live ? bestTime : (keyTime.get(key) ?? 0) };
|
|
384
|
+
});
|
|
385
|
+
},
|
|
386
|
+
async getSingleField(key, column) {
|
|
387
|
+
const kt = keyTime.get(key);
|
|
388
|
+
if (kt === undefined || kt <= delOf(key)) return undefined;
|
|
389
|
+
let bestTime = -Infinity;
|
|
390
|
+
let bestVal: unknown;
|
|
391
|
+
let found = false;
|
|
392
|
+
for (const db of databases) {
|
|
393
|
+
if (!db.columns.some(c => c.column === column)) continue;
|
|
394
|
+
const r = await db.getSingleField(key, column);
|
|
395
|
+
if (r === ABSENT) continue;
|
|
396
|
+
if (r.time > bestTime) { bestTime = r.time; bestVal = r.value; found = true; }
|
|
397
|
+
}
|
|
398
|
+
const live = found && bestTime > delOf(key);
|
|
399
|
+
return { value: live ? bestVal : undefined, time: live ? bestTime : kt };
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const DELETED: unique symbol;
|
|
2
|
+
export type OverlayEntry = {
|
|
3
|
+
time: number;
|
|
4
|
+
value: Record<string, unknown> | typeof DELETED;
|
|
5
|
+
};
|
|
6
|
+
export declare class WriteOverlay {
|
|
7
|
+
private entries;
|
|
8
|
+
get size(): number;
|
|
9
|
+
get(key: string): OverlayEntry | undefined;
|
|
10
|
+
has(key: string): boolean;
|
|
11
|
+
keys(): IterableIterator<string>;
|
|
12
|
+
[Symbol.iterator](): IterableIterator<[string, OverlayEntry]>;
|
|
13
|
+
writeRow(key: string, row: Record<string, unknown>, time: number, wasLive: boolean): {
|
|
14
|
+
invalidatedColumns: Iterable<string> | "all";
|
|
15
|
+
};
|
|
16
|
+
deleteKey(key: string, time: number, wasLive: boolean): {
|
|
17
|
+
invalidatedColumns: Iterable<string> | "all";
|
|
18
|
+
};
|
|
19
|
+
clear(): void;
|
|
20
|
+
sweepCovered(authority: (key: string) => number): void;
|
|
21
|
+
patchColumn(base: {
|
|
22
|
+
key: string;
|
|
23
|
+
value: unknown;
|
|
24
|
+
time: number;
|
|
25
|
+
}[], column: string): {
|
|
26
|
+
key: string;
|
|
27
|
+
value: unknown;
|
|
28
|
+
time: number;
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const DELETED = Symbol("deleted");
|
|
2
|
+
|
|
3
|
+
export type OverlayEntry = { time: number; value: Record<string, unknown> | typeof DELETED };
|
|
4
|
+
|
|
5
|
+
// In-memory pending mutations layered on top of a LoadedIndex. Each entry carries the write's unique
|
|
6
|
+
// timestamp so cross-tab writes can be ordered + the swap can drop entries the new index already
|
|
7
|
+
// covers. Methods that mutate report which columns invalidate ("all" if the key's liveness flipped),
|
|
8
|
+
// so the host can drop the right caches without scanning every column.
|
|
9
|
+
export class WriteOverlay {
|
|
10
|
+
private entries = new Map<string, OverlayEntry>();
|
|
11
|
+
|
|
12
|
+
get size(): number { return this.entries.size; }
|
|
13
|
+
get(key: string): OverlayEntry | undefined { return this.entries.get(key); }
|
|
14
|
+
has(key: string): boolean { return this.entries.has(key); }
|
|
15
|
+
keys(): IterableIterator<string> { return this.entries.keys(); }
|
|
16
|
+
[Symbol.iterator]() { return this.entries[Symbol.iterator](); }
|
|
17
|
+
|
|
18
|
+
writeRow(key: string, row: Record<string, unknown>, time: number, wasLive: boolean): { invalidatedColumns: Iterable<string> | "all" } {
|
|
19
|
+
const existing = this.entries.get(key);
|
|
20
|
+
const value = existing && existing.value !== DELETED ? { ...existing.value, ...row } : { ...row };
|
|
21
|
+
this.entries.set(key, { time, value });
|
|
22
|
+
return { invalidatedColumns: wasLive ? Object.keys(row) : "all" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
deleteKey(key: string, time: number, wasLive: boolean): { invalidatedColumns: Iterable<string> | "all" } {
|
|
26
|
+
this.entries.set(key, { time, value: DELETED });
|
|
27
|
+
return { invalidatedColumns: wasLive ? "all" : [] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear(): void { this.entries.clear(); }
|
|
31
|
+
|
|
32
|
+
// Drop entries the new index already reflects (its keyTime or deleteTime ≥ the entry's time). The
|
|
33
|
+
// remaining entries are writes the index doesn't yet see — they have to keep serving reads.
|
|
34
|
+
sweepCovered(authority: (key: string) => number): void {
|
|
35
|
+
for (const [key, entry] of this.entries) {
|
|
36
|
+
if (entry.time <= authority(key)) this.entries.delete(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Apply this overlay onto the base column entries. A column-unset overlay entry leaves the base
|
|
41
|
+
// value+time in place (a partial write only overrides the columns it set).
|
|
42
|
+
patchColumn(base: { key: string; value: unknown; time: number }[], column: string): { key: string; value: unknown; time: number }[] {
|
|
43
|
+
if (this.entries.size === 0) return base;
|
|
44
|
+
const map = new Map(base.map(e => [e.key, { value: e.value, time: e.time }]));
|
|
45
|
+
for (const [key, entry] of this.entries) {
|
|
46
|
+
if (entry.value === DELETED) { map.delete(key); continue; }
|
|
47
|
+
if (column in entry.value) map.set(key, { value: entry.value[column], time: entry.time });
|
|
48
|
+
else if (!map.has(key)) map.set(key, { value: undefined, time: entry.time });
|
|
49
|
+
}
|
|
50
|
+
return [...map].map(([key, v]) => ({ key, value: v.value, time: v.time }));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -68,6 +68,12 @@ export class BlockCache {
|
|
|
68
68
|
this.indexes.clear();
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
public evict(fileId: string) {
|
|
72
|
+
this.indexes.delete(fileId);
|
|
73
|
+
const prefix = fileId + ":";
|
|
74
|
+
for (const key of this.blocks.keys()) if (key.startsWith(prefix)) this.blocks.delete(key);
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
private touch(key: string, value: Promise<Buffer>) {
|
|
72
78
|
this.blocks.delete(key);
|
|
73
79
|
this.blocks.set(key, value);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import cborx from "cbor-x";
|
|
2
|
-
import { ABSENT, BaseBulkDatabaseReader } from "./BulkDatabaseFormat";
|
|
2
|
+
import { ABSENT, BaseBulkDatabaseReader, ColumnIndex, EMPTY_BUFFER, encodeValue, RawCell, TYPE_ABSENT_TAG } from "./BulkDatabaseFormat";
|
|
3
3
|
|
|
4
4
|
// Tier-0 streaming format: an append log of whole-row writes and deletes (row-format, not columnar),
|
|
5
5
|
// so small mutations are a single cheap append instead of rewriting a columnar file. Each block is:
|
|
@@ -104,6 +104,7 @@ export function streamReaderFromEntries(entries: StreamEntry[], totalBytes: numb
|
|
|
104
104
|
if (t < minTime) minTime = t;
|
|
105
105
|
if (t > maxTime) maxTime = t;
|
|
106
106
|
}
|
|
107
|
+
const keyIndexMap = buildKeyIndex(keys);
|
|
107
108
|
let reader: BaseBulkDatabaseReader = {
|
|
108
109
|
totalBytes,
|
|
109
110
|
rowCount: keys.length,
|
|
@@ -126,6 +127,82 @@ export function streamReaderFromEntries(entries: StreamEntry[], totalBytes: numb
|
|
|
126
127
|
if (!row || !(column in row)) return ABSENT;
|
|
127
128
|
return { value: row[column], time: times.get(key) || 0 };
|
|
128
129
|
},
|
|
130
|
+
// Stream values are decoded objects (CBOR), so unlike a bulk file we have to encode them to raw
|
|
131
|
+
// cells here. Stream data is small (tier-0, size-capped), so this is cheap. A column the row never
|
|
132
|
+
// set is omitted (ABSENT → fall through); a stored undefined is kept (a real clear).
|
|
133
|
+
async getRawColumn(column) {
|
|
134
|
+
let map = new Map<string, RawCell>();
|
|
135
|
+
for (let key of keys) {
|
|
136
|
+
let row = byKey.get(key);
|
|
137
|
+
if (!row || !(column in row)) continue;
|
|
138
|
+
map.set(key, encodeValue(row[column]));
|
|
139
|
+
}
|
|
140
|
+
return map;
|
|
141
|
+
},
|
|
142
|
+
// Per-column index in the same shape a bulk file exposes, materialized in memory once (stream data
|
|
143
|
+
// is tier-0, size-capped, so the encode is cheap). Lets the planned merge treat stream + bulk
|
|
144
|
+
// sources uniformly: it loads each (source, column) index, uses offsets/types to plan the output's
|
|
145
|
+
// byte layout, and reads contiguous runs from this index's in-memory data buffer during execute.
|
|
146
|
+
async getColumnIndex(column) {
|
|
147
|
+
return getStreamColumnIndex(keys, byKey, column);
|
|
148
|
+
},
|
|
149
|
+
rowOfKey(key) {
|
|
150
|
+
return keyIndexMap.get(key);
|
|
151
|
+
},
|
|
129
152
|
};
|
|
130
153
|
return { reader, times };
|
|
131
154
|
}
|
|
155
|
+
|
|
156
|
+
// keys[] is the column-side row order for this stream reader; rowOfKey is used by the planner to look up
|
|
157
|
+
// a winning cell's source row without going through a column read. Stays consistent with getColumnIndex,
|
|
158
|
+
// which builds its offsets/types over the same `keys` array in the same order.
|
|
159
|
+
function buildKeyIndex(keys: string[]): Map<string, number> {
|
|
160
|
+
const m = new Map<string, number>();
|
|
161
|
+
for (let i = 0; i < keys.length; i++) m.set(keys[i], i);
|
|
162
|
+
return m;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Build a stream-side ColumnIndex (offsets + types + a contiguous-row byte slicer) over the in-memory
|
|
166
|
+
// merged rows. Encodes each row's value for this column once into a single backing buffer; readValueBytes
|
|
167
|
+
// then slices it. Cached per (reader, column) so a merge planning + execution pass doesn't re-encode.
|
|
168
|
+
function getStreamColumnIndex(keys: string[], byKey: Map<string, Record<string, unknown>>, column: string): ColumnIndex {
|
|
169
|
+
let cached = columnIndexCache.get(byKey)?.get(column);
|
|
170
|
+
if (cached) return cached;
|
|
171
|
+
const offsets = new Uint32Array(keys.length + 1);
|
|
172
|
+
const types = new Uint8Array(keys.length);
|
|
173
|
+
const parts: Buffer[] = [];
|
|
174
|
+
let pos = 0;
|
|
175
|
+
for (let i = 0; i < keys.length; i++) {
|
|
176
|
+
const row = byKey.get(keys[i]);
|
|
177
|
+
offsets[i] = pos;
|
|
178
|
+
if (!row || !(column in row)) {
|
|
179
|
+
types[i] = TYPE_ABSENT_TAG;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const { type, bytes } = encodeValue(row[column]);
|
|
183
|
+
types[i] = type;
|
|
184
|
+
parts.push(bytes);
|
|
185
|
+
pos += bytes.length;
|
|
186
|
+
}
|
|
187
|
+
offsets[keys.length] = pos;
|
|
188
|
+
const data = parts.length ? Buffer.concat(parts) : EMPTY_BUFFER;
|
|
189
|
+
const idx: ColumnIndex = {
|
|
190
|
+
offsets,
|
|
191
|
+
types,
|
|
192
|
+
async readValueBytes(startRow, endRow) {
|
|
193
|
+
if (endRow <= startRow) return EMPTY_BUFFER;
|
|
194
|
+
const start = offsets[startRow];
|
|
195
|
+
const end = offsets[endRow];
|
|
196
|
+
if (end <= start) return EMPTY_BUFFER;
|
|
197
|
+
return data.subarray(start, end);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
let perReader = columnIndexCache.get(byKey);
|
|
201
|
+
if (!perReader) { perReader = new Map(); columnIndexCache.set(byKey, perReader); }
|
|
202
|
+
perReader.set(column, idx);
|
|
203
|
+
return idx;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Per stream-reader cache of its column indexes. Keyed by the reader's byKey map (a fresh
|
|
207
|
+
// streamReaderFromEntries call produces a fresh map, so this is effectively per-reader).
|
|
208
|
+
const columnIndexCache = new WeakMap<Map<string, Record<string, unknown>>, Map<string, ColumnIndex>>();
|