sliftutils 1.4.13 → 1.4.14

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 CHANGED
@@ -1014,6 +1014,13 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1014
1014
  column: string;
1015
1015
  byteSize: number;
1016
1016
  }[]>;
1017
+ getKeyStats(): Promise<{
1018
+ rawKeys: number;
1019
+ finalKeys: number;
1020
+ wastedKeys: number;
1021
+ duplication: number;
1022
+ readers: number;
1023
+ }>;
1017
1024
  getReaderInfo(): Promise<{
1018
1025
  rowCount: number;
1019
1026
  totalBytes: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.13",
3
+ "version": "1.4.14",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -117,6 +117,13 @@ export declare class BulkDatabaseBase<T extends {
117
117
  column: string;
118
118
  byteSize: number;
119
119
  }[]>;
120
+ getKeyStats(): Promise<{
121
+ rawKeys: number;
122
+ finalKeys: number;
123
+ wastedKeys: number;
124
+ duplication: number;
125
+ readers: number;
126
+ }>;
120
127
  getReaderInfo(): Promise<{
121
128
  rowCount: number;
122
129
  totalBytes: number;
@@ -637,7 +637,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
637
637
  syncBroadcastSeal(this.name);
638
638
  this.streamFileName = undefined;
639
639
  const { bulkFiles, streamFiles } = await this.listFiles();
640
- if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles);
640
+ // compact() merges every file on disk, so nothing older survives outside it — tombstones for
641
+ // fully-deleted keys can be dropped rather than carried into a fresh carry stream.
642
+ if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles, true);
641
643
  } finally {
642
644
  releaseMergeLock(this.name, writerId);
643
645
  }
@@ -662,7 +664,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
662
664
  const selStream = streamFiles.filter(f =>
663
665
  f.timestamp <= timeHi && f.timestamp + bulkDatabase2Timing.streamSealAgeMs >= timeLo);
664
666
  if (selBulk.length + selStream.length < 2) return;
665
- await this.mergeFileSet(selBulk, selStream);
667
+ // timeLo <= 0 reaches the start of time: every older file overlaps [timeLo, timeHi] and is in the
668
+ // merge, so no older set survives outside it and surviving tombstones can be dropped.
669
+ await this.mergeFileSet(selBulk, selStream, timeLo <= 0);
666
670
  }
667
671
 
668
672
  // Throws MissingFileError (not a generic error) when the file is gone, so callers can distinguish a
@@ -804,7 +808,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
804
808
  // merge removes them), never a gap. A bulk file is deleted only if we actually read it; a stream file
805
809
  // only if it's aged out (its writer has switched files) or — when cross-tab sync sealed it — its size
806
810
  // didn't change while we read it. Returns whether it produced anything.
807
- private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[]): Promise<boolean> {
811
+ // `includesOldest` means this merge consumes every file at or before its time range — there is no
812
+ // file before it on disk. A surviving tombstone only exists to suppress an OLDER set in some file
813
+ // outside the merge; if nothing older exists, that older set can't exist either, so the tombstone has
814
+ // nothing left to suppress and we drop it instead of carrying it forward. (A full compact and a
815
+ // merge that reaches time 0 are the cases where this holds.)
816
+ private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false): Promise<boolean> {
808
817
  const storage = await this.storage();
809
818
  const timestamp = nextFileTime();
810
819
  const now = Date.now();
@@ -837,7 +846,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
837
846
  newNames.push(name);
838
847
  }
839
848
  }
840
- if (deletes.size) {
849
+ // Carry surviving tombstones forward only if older files exist outside this merge that they still
850
+ // need to suppress; when this merge includes the oldest data there's nothing older to suppress.
851
+ const carriedDeletes = includesOldest ? 0 : deletes.size;
852
+ if (carriedDeletes) {
841
853
  const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
842
854
  await storage.set(carryName, frameDeletes([...deletes].map(([key, time]) => ({ time, key }))));
843
855
  }
@@ -849,7 +861,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
849
861
  }
850
862
 
851
863
  this.resetReader();
852
- return newNames.length > 0 || deletes.size > 0;
864
+ return newNames.length > 0 || carriedDeletes > 0;
853
865
  }
854
866
 
855
867
  // A stream file is safe to delete iff no writer will ever append to it again: it's aged past the seal
@@ -1177,6 +1189,25 @@ export class BulkDatabaseBase<T extends { key: string }> {
1177
1189
  return reader.columns;
1178
1190
  }
1179
1191
 
1192
+ // Raw vs. resolved key counts: how much duplicate/stale key data is sitting on disk that a compact()
1193
+ // would collapse. rawKeys counts every key-slot across all loaded files — each set and each delete
1194
+ // tombstone (a key written into N files counts N times); finalKeys is the number of live resolved
1195
+ // keys (after newest-write-wins and tombstones). Both come straight from the already-loaded reader, so
1196
+ // this is ~free. wastedKeys = rawKeys - finalKeys; duplication = rawKeys / finalKeys (well above 1 ⇒
1197
+ // fragmented, compaction would shrink it).
1198
+ public async getKeyStats(): Promise<{ rawKeys: number; finalKeys: number; wastedKeys: number; duplication: number; readers: number }> {
1199
+ const reader = await this.reader();
1200
+ const rawKeys = reader.rawKeyCount;
1201
+ const finalKeys = reader.keys.length;
1202
+ return {
1203
+ rawKeys,
1204
+ finalKeys,
1205
+ wastedKeys: rawKeys - finalKeys,
1206
+ duplication: finalKeys ? rawKeys / finalKeys : 0,
1207
+ readers: reader.readerCount,
1208
+ };
1209
+ }
1210
+
1180
1211
  public async getReaderInfo() {
1181
1212
  let reader = await this.reader();
1182
1213
  return {
@@ -1212,6 +1243,11 @@ type ResolvedReader = {
1212
1243
  rowCount: number;
1213
1244
  totalBytes: number;
1214
1245
  keys: string[];
1246
+ // Total key-slots across every loaded reader — every set AND every delete tombstone (a key stored in
1247
+ // N files counts N times) — and how many readers there are. Already in memory after the join, so
1248
+ // getKeyStats is ~free; rawKeyCount vs keys.length is how much duplication a compaction would collapse.
1249
+ rawKeyCount: number;
1250
+ readerCount: number;
1215
1251
  columns: { column: string; byteSize: number }[];
1216
1252
  getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
1217
1253
  getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | undefined>;
@@ -1256,6 +1292,8 @@ async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<R
1256
1292
  totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
1257
1293
  rowCount: keys.length,
1258
1294
  keys,
1295
+ rawKeyCount: databases.reduce((acc, db) => acc + db.keyTimes.size + (db.deleteTimes?.size ?? 0), 0),
1296
+ readerCount: databases.length,
1259
1297
  columns,
1260
1298
  async getColumn(column) {
1261
1299
  const perReader = await Promise.all(databases.map(async db => {