sliftutils 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +51 -2
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +26 -2
- package/storage/BulkDatabase2/BulkDatabase2.ts +19 -4
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +25 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +261 -99
- package/storage/BulkDatabase2/mergeLock.ts +5 -3
- package/storage/BulkDatabase2/streamLog.ts +4 -7
package/index.d.ts
CHANGED
|
@@ -813,20 +813,37 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
813
813
|
getKeys(): Promise<string[]>;
|
|
814
814
|
/** One field's value for a key, or undefined if the key/column isn't set or the key is deleted. */
|
|
815
815
|
getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined>;
|
|
816
|
-
/**
|
|
816
|
+
/**
|
|
817
|
+
* Like getSingleField but returns { key, value, time } (the same shape a getColumn entry has), where
|
|
818
|
+
* time is roughly when the value last changed. undefined only when the key isn't present/live.
|
|
819
|
+
*/
|
|
820
|
+
getSingleFieldObj<Column extends keyof T>(key: string, column: Column): Promise<{
|
|
821
|
+
key: string;
|
|
822
|
+
value: T[Column];
|
|
823
|
+
time: number;
|
|
824
|
+
} | undefined>;
|
|
825
|
+
/** A whole column as { key, value, time } for every live key (time ≈ when each value last changed). */
|
|
817
826
|
getColumn<Column extends keyof T>(column: Column): Promise<{
|
|
818
827
|
key: string;
|
|
819
828
|
value: T[Column];
|
|
829
|
+
time: number;
|
|
820
830
|
}[]>;
|
|
821
831
|
/**
|
|
822
832
|
* Synchronous, reactive read of one field. Returns undefined while the base value is still loading
|
|
823
833
|
* (and re-renders once it arrives, under a mobx observer); reflects pending writes immediately.
|
|
824
834
|
*/
|
|
825
835
|
getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
|
|
826
|
-
/**
|
|
836
|
+
/** Sync, reactive counterpart of getSingleFieldObj: { key, value, time } once loaded, else undefined. */
|
|
837
|
+
getSingleFieldObjSync<Column extends keyof T>(key: string, column: Column): {
|
|
838
|
+
key: string;
|
|
839
|
+
value: T[Column];
|
|
840
|
+
time: number;
|
|
841
|
+
} | undefined;
|
|
842
|
+
/** Synchronous, reactive read of a whole column ({ key, value, time }). undefined while still loading. */
|
|
827
843
|
getColumnSync<Column extends keyof T>(column: Column): {
|
|
828
844
|
key: string;
|
|
829
845
|
value: T[Column];
|
|
846
|
+
time: number;
|
|
830
847
|
}[] | undefined;
|
|
831
848
|
/** The columns present on disk and their byte sizes (no row data read). */
|
|
832
849
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
@@ -834,6 +851,13 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
834
851
|
getReaderInfo(): Promise<BulkReaderInfo>;
|
|
835
852
|
/** Consolidate on-disk files. Optional to call; the database also does this in the background. */
|
|
836
853
|
compact(): Promise<void>;
|
|
854
|
+
/**
|
|
855
|
+
* Flush buffered stream writes to disk now. Writes are coalesced and flushed on a ramping delay (to
|
|
856
|
+
* avoid the browser rewriting the whole stream file per write), so a write's promise resolving means
|
|
857
|
+
* "accepted" (in memory + cross-tab), not necessarily "on disk". Call this to force durability — it's
|
|
858
|
+
* also run automatically on tab hide/close and before every merge.
|
|
859
|
+
*/
|
|
860
|
+
flush(): Promise<void>;
|
|
837
861
|
/**
|
|
838
862
|
* Run one merge pass now (the same policy the database runs on a timer): consolidate recent
|
|
839
863
|
* fragmentation and dedup a key range if it's worth it. Returns whether it merged anything and
|
|
@@ -861,8 +885,10 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
861
885
|
export declare const bulkDatabase2Timing: {
|
|
862
886
|
streamSealAgeMs: number;
|
|
863
887
|
mergeCheckIntervalMs: number;
|
|
888
|
+
mergeSpacingMs: number;
|
|
864
889
|
firstMergeTriggerFiles: number;
|
|
865
890
|
firstMergeTriggerRangeMs: number;
|
|
891
|
+
writeFlushMaxDelayMs: number;
|
|
866
892
|
};
|
|
867
893
|
export interface ReactiveDeps {
|
|
868
894
|
observe(signal: string): void;
|
|
@@ -878,6 +904,11 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
878
904
|
protected deps: ReactiveDeps;
|
|
879
905
|
private storageFactory;
|
|
880
906
|
constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory);
|
|
907
|
+
private pendingAppends;
|
|
908
|
+
private flushTimer;
|
|
909
|
+
private flushChain;
|
|
910
|
+
private currentFlushDelay;
|
|
911
|
+
private lastWriteTime;
|
|
881
912
|
static clearCache(): void;
|
|
882
913
|
storage: {
|
|
883
914
|
(): Promise<FileStorage>;
|
|
@@ -902,6 +933,10 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
902
933
|
writeBatch(entries: T[]): Promise<void>;
|
|
903
934
|
delete(key: string): Promise<void>;
|
|
904
935
|
deleteBatch(keys: string[]): Promise<void>;
|
|
936
|
+
private streamAppend;
|
|
937
|
+
flush(): Promise<void>;
|
|
938
|
+
private flushPending;
|
|
939
|
+
private doFlush;
|
|
905
940
|
update(entry: Partial<T> & {
|
|
906
941
|
key: string;
|
|
907
942
|
}): Promise<void>;
|
|
@@ -927,13 +962,21 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
927
962
|
private resolveReaders;
|
|
928
963
|
private mergeFileSet;
|
|
929
964
|
private canDeleteStream;
|
|
965
|
+
private mergeSpacingDelay;
|
|
930
966
|
private testMerge;
|
|
967
|
+
private findDuplicateGroups;
|
|
931
968
|
private formatInfo;
|
|
932
969
|
private patchColumn;
|
|
933
970
|
getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined>;
|
|
971
|
+
getSingleFieldObj<Column extends keyof T>(key: string, column: Column): Promise<{
|
|
972
|
+
key: string;
|
|
973
|
+
value: T[Column];
|
|
974
|
+
time: number;
|
|
975
|
+
} | undefined>;
|
|
934
976
|
getColumn<Column extends keyof T>(column: Column): Promise<{
|
|
935
977
|
key: string;
|
|
936
978
|
value: T[Column];
|
|
979
|
+
time: number;
|
|
937
980
|
}[]>;
|
|
938
981
|
getKeys(): Promise<string[]>;
|
|
939
982
|
private baseColumns;
|
|
@@ -943,9 +986,15 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
943
986
|
private ensureBaseColumn;
|
|
944
987
|
private ensureBaseField;
|
|
945
988
|
getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
|
|
989
|
+
getSingleFieldObjSync<Column extends keyof T>(key: string, column: Column): {
|
|
990
|
+
key: string;
|
|
991
|
+
value: T[Column];
|
|
992
|
+
time: number;
|
|
993
|
+
} | undefined;
|
|
946
994
|
getColumnSync<Column extends keyof T>(column: Column): {
|
|
947
995
|
key: string;
|
|
948
996
|
value: T[Column];
|
|
997
|
+
time: number;
|
|
949
998
|
}[] | undefined;
|
|
950
999
|
getColumnInfo(): Promise<{
|
|
951
1000
|
column: string;
|
package/package.json
CHANGED
|
@@ -48,20 +48,37 @@ export interface IBulkDatabase2<T extends {
|
|
|
48
48
|
getKeys(): Promise<string[]>;
|
|
49
49
|
/** One field's value for a key, or undefined if the key/column isn't set or the key is deleted. */
|
|
50
50
|
getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined>;
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Like getSingleField but returns { key, value, time } (the same shape a getColumn entry has), where
|
|
53
|
+
* time is roughly when the value last changed. undefined only when the key isn't present/live.
|
|
54
|
+
*/
|
|
55
|
+
getSingleFieldObj<Column extends keyof T>(key: string, column: Column): Promise<{
|
|
56
|
+
key: string;
|
|
57
|
+
value: T[Column];
|
|
58
|
+
time: number;
|
|
59
|
+
} | undefined>;
|
|
60
|
+
/** A whole column as { key, value, time } for every live key (time ≈ when each value last changed). */
|
|
52
61
|
getColumn<Column extends keyof T>(column: Column): Promise<{
|
|
53
62
|
key: string;
|
|
54
63
|
value: T[Column];
|
|
64
|
+
time: number;
|
|
55
65
|
}[]>;
|
|
56
66
|
/**
|
|
57
67
|
* Synchronous, reactive read of one field. Returns undefined while the base value is still loading
|
|
58
68
|
* (and re-renders once it arrives, under a mobx observer); reflects pending writes immediately.
|
|
59
69
|
*/
|
|
60
70
|
getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
|
|
61
|
-
/**
|
|
71
|
+
/** Sync, reactive counterpart of getSingleFieldObj: { key, value, time } once loaded, else undefined. */
|
|
72
|
+
getSingleFieldObjSync<Column extends keyof T>(key: string, column: Column): {
|
|
73
|
+
key: string;
|
|
74
|
+
value: T[Column];
|
|
75
|
+
time: number;
|
|
76
|
+
} | undefined;
|
|
77
|
+
/** Synchronous, reactive read of a whole column ({ key, value, time }). undefined while still loading. */
|
|
62
78
|
getColumnSync<Column extends keyof T>(column: Column): {
|
|
63
79
|
key: string;
|
|
64
80
|
value: T[Column];
|
|
81
|
+
time: number;
|
|
65
82
|
}[] | undefined;
|
|
66
83
|
/** The columns present on disk and their byte sizes (no row data read). */
|
|
67
84
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
@@ -69,6 +86,13 @@ export interface IBulkDatabase2<T extends {
|
|
|
69
86
|
getReaderInfo(): Promise<BulkReaderInfo>;
|
|
70
87
|
/** Consolidate on-disk files. Optional to call; the database also does this in the background. */
|
|
71
88
|
compact(): Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* Flush buffered stream writes to disk now. Writes are coalesced and flushed on a ramping delay (to
|
|
91
|
+
* avoid the browser rewriting the whole stream file per write), so a write's promise resolving means
|
|
92
|
+
* "accepted" (in memory + cross-tab), not necessarily "on disk". Call this to force durability — it's
|
|
93
|
+
* also run automatically on tab hide/close and before every merge.
|
|
94
|
+
*/
|
|
95
|
+
flush(): Promise<void>;
|
|
72
96
|
/**
|
|
73
97
|
* Run one merge pass now (the same policy the database runs on a timer): consolidate recent
|
|
74
98
|
* fragmentation and dedup a key range if it's worth it. Returns whether it merged anything and
|
|
@@ -47,16 +47,23 @@ export interface IBulkDatabase2<T extends { key: string }> {
|
|
|
47
47
|
getKeys(): Promise<string[]>;
|
|
48
48
|
/** One field's value for a key, or undefined if the key/column isn't set or the key is deleted. */
|
|
49
49
|
getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined>;
|
|
50
|
-
/**
|
|
51
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Like getSingleField but returns { key, value, time } (the same shape a getColumn entry has), where
|
|
52
|
+
* time is roughly when the value last changed. undefined only when the key isn't present/live.
|
|
53
|
+
*/
|
|
54
|
+
getSingleFieldObj<Column extends keyof T>(key: string, column: Column): Promise<{ key: string; value: T[Column]; time: number } | undefined>;
|
|
55
|
+
/** A whole column as { key, value, time } for every live key (time ≈ when each value last changed). */
|
|
56
|
+
getColumn<Column extends keyof T>(column: Column): Promise<{ key: string; value: T[Column]; time: number }[]>;
|
|
52
57
|
|
|
53
58
|
/**
|
|
54
59
|
* Synchronous, reactive read of one field. Returns undefined while the base value is still loading
|
|
55
60
|
* (and re-renders once it arrives, under a mobx observer); reflects pending writes immediately.
|
|
56
61
|
*/
|
|
57
62
|
getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
|
|
58
|
-
/**
|
|
59
|
-
|
|
63
|
+
/** Sync, reactive counterpart of getSingleFieldObj: { key, value, time } once loaded, else undefined. */
|
|
64
|
+
getSingleFieldObjSync<Column extends keyof T>(key: string, column: Column): { key: string; value: T[Column]; time: number } | undefined;
|
|
65
|
+
/** Synchronous, reactive read of a whole column ({ key, value, time }). undefined while still loading. */
|
|
66
|
+
getColumnSync<Column extends keyof T>(column: Column): { key: string; value: T[Column]; time: number }[] | undefined;
|
|
60
67
|
|
|
61
68
|
/** The columns present on disk and their byte sizes (no row data read). */
|
|
62
69
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
@@ -66,6 +73,14 @@ export interface IBulkDatabase2<T extends { key: string }> {
|
|
|
66
73
|
/** Consolidate on-disk files. Optional to call; the database also does this in the background. */
|
|
67
74
|
compact(): Promise<void>;
|
|
68
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Flush buffered stream writes to disk now. Writes are coalesced and flushed on a ramping delay (to
|
|
78
|
+
* avoid the browser rewriting the whole stream file per write), so a write's promise resolving means
|
|
79
|
+
* "accepted" (in memory + cross-tab), not necessarily "on disk". Call this to force durability — it's
|
|
80
|
+
* also run automatically on tab hide/close and before every merge.
|
|
81
|
+
*/
|
|
82
|
+
flush(): Promise<void>;
|
|
83
|
+
|
|
69
84
|
/**
|
|
70
85
|
* Run one merge pass now (the same policy the database runs on a timer): consolidate recent
|
|
71
86
|
* fragmentation and dedup a key range if it's worth it. Returns whether it merged anything and
|
|
@@ -2,8 +2,10 @@ import type { FileStorage } from "../FileFolderAPI";
|
|
|
2
2
|
export declare const bulkDatabase2Timing: {
|
|
3
3
|
streamSealAgeMs: number;
|
|
4
4
|
mergeCheckIntervalMs: number;
|
|
5
|
+
mergeSpacingMs: number;
|
|
5
6
|
firstMergeTriggerFiles: number;
|
|
6
7
|
firstMergeTriggerRangeMs: number;
|
|
8
|
+
writeFlushMaxDelayMs: number;
|
|
7
9
|
};
|
|
8
10
|
export interface ReactiveDeps {
|
|
9
11
|
observe(signal: string): void;
|
|
@@ -19,6 +21,11 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
19
21
|
protected deps: ReactiveDeps;
|
|
20
22
|
private storageFactory;
|
|
21
23
|
constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory);
|
|
24
|
+
private pendingAppends;
|
|
25
|
+
private flushTimer;
|
|
26
|
+
private flushChain;
|
|
27
|
+
private currentFlushDelay;
|
|
28
|
+
private lastWriteTime;
|
|
22
29
|
static clearCache(): void;
|
|
23
30
|
storage: {
|
|
24
31
|
(): Promise<FileStorage>;
|
|
@@ -43,6 +50,10 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
43
50
|
writeBatch(entries: T[]): Promise<void>;
|
|
44
51
|
delete(key: string): Promise<void>;
|
|
45
52
|
deleteBatch(keys: string[]): Promise<void>;
|
|
53
|
+
private streamAppend;
|
|
54
|
+
flush(): Promise<void>;
|
|
55
|
+
private flushPending;
|
|
56
|
+
private doFlush;
|
|
46
57
|
update(entry: Partial<T> & {
|
|
47
58
|
key: string;
|
|
48
59
|
}): Promise<void>;
|
|
@@ -68,13 +79,21 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
68
79
|
private resolveReaders;
|
|
69
80
|
private mergeFileSet;
|
|
70
81
|
private canDeleteStream;
|
|
82
|
+
private mergeSpacingDelay;
|
|
71
83
|
private testMerge;
|
|
84
|
+
private findDuplicateGroups;
|
|
72
85
|
private formatInfo;
|
|
73
86
|
private patchColumn;
|
|
74
87
|
getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined>;
|
|
88
|
+
getSingleFieldObj<Column extends keyof T>(key: string, column: Column): Promise<{
|
|
89
|
+
key: string;
|
|
90
|
+
value: T[Column];
|
|
91
|
+
time: number;
|
|
92
|
+
} | undefined>;
|
|
75
93
|
getColumn<Column extends keyof T>(column: Column): Promise<{
|
|
76
94
|
key: string;
|
|
77
95
|
value: T[Column];
|
|
96
|
+
time: number;
|
|
78
97
|
}[]>;
|
|
79
98
|
getKeys(): Promise<string[]>;
|
|
80
99
|
private baseColumns;
|
|
@@ -84,9 +103,15 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
84
103
|
private ensureBaseColumn;
|
|
85
104
|
private ensureBaseField;
|
|
86
105
|
getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
|
|
106
|
+
getSingleFieldObjSync<Column extends keyof T>(key: string, column: Column): {
|
|
107
|
+
key: string;
|
|
108
|
+
value: T[Column];
|
|
109
|
+
time: number;
|
|
110
|
+
} | undefined;
|
|
87
111
|
getColumnSync<Column extends keyof T>(column: Column): {
|
|
88
112
|
key: string;
|
|
89
113
|
value: T[Column];
|
|
114
|
+
time: number;
|
|
90
115
|
}[] | undefined;
|
|
91
116
|
getColumnInfo(): Promise<{
|
|
92
117
|
column: string;
|
|
@@ -8,6 +8,7 @@ import { blockCache, encodeCompressedBlocks, GetRange } from "./blockCache";
|
|
|
8
8
|
import { STREAM_EXTENSION, StreamEntry, frameRows, frameDeletes, parseStream, streamReaderFromEntries } from "./streamLog";
|
|
9
9
|
import { connect as syncConnect, broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, isSyncSupported, RemoteWrite } from "./syncClient";
|
|
10
10
|
import { tryAcquireMergeLock, releaseMergeLock } from "./mergeLock";
|
|
11
|
+
import { isNode } from "typesafecss";
|
|
11
12
|
import type { FileStorage } from "../FileFolderAPI";
|
|
12
13
|
|
|
13
14
|
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -52,6 +53,13 @@ const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
|
|
|
52
53
|
const KEY_GROUP_BYTES = 800 * 1024 * 1024;
|
|
53
54
|
const DUP_THRESHOLD = 0.4;
|
|
54
55
|
|
|
56
|
+
// The browser File System Access API has no real append — every "append" rewrites the whole file — so
|
|
57
|
+
// streaming one write at a time is quadratic. We coalesce stream writes and flush on a RAMPING delay:
|
|
58
|
+
// the first write after a lull flushes immediately (a single checkbox-then-close is saved at once), and
|
|
59
|
+
// as writes keep coming the delay doubles from this step up to writeFlushMaxDelayMs, batching a burst
|
|
60
|
+
// into one rewrite. (Node has a real append, so there the default delay is 0 = flush every write.)
|
|
61
|
+
const WRITE_FLUSH_FIRST_STEP_MS = 250;
|
|
62
|
+
|
|
55
63
|
// Time thresholds, mutable so tests can shrink them from hours to milliseconds.
|
|
56
64
|
export const bulkDatabase2Timing = {
|
|
57
65
|
// A writer stops appending to its current stream file once it's this old (starts a fresh one). A
|
|
@@ -60,10 +68,20 @@ export const bulkDatabase2Timing = {
|
|
|
60
68
|
streamSealAgeMs: 10 * 60 * 60 * 1000,
|
|
61
69
|
// Per-instance throttle: a write triggers at most one background testMerge scan per this interval.
|
|
62
70
|
mergeCheckIntervalMs: 30 * 60 * 1000,
|
|
71
|
+
// A single testMerge can do several merges (one pass-1 + several pass-2 groups). We wait this long
|
|
72
|
+
// after each one before the next, so a burst of writes doesn't rewrite every index on disk at once —
|
|
73
|
+
// it spreads the work out to keep peak lag low (important in the browser). The merge lock is kept
|
|
74
|
+
// alive across the wait. Set to 0 to disable spacing (tests).
|
|
75
|
+
mergeSpacingMs: 5 * 60 * 1000,
|
|
63
76
|
// The first merge fires when the recent (up to FIRST_MERGE_BYTES) files number more than this...
|
|
64
77
|
firstMergeTriggerFiles: 20,
|
|
65
78
|
// ...or span a wider write-time range than this (data trickling in slowly still gets consolidated).
|
|
66
79
|
firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
|
|
80
|
+
// Max delay (per collection) before buffered stream writes are flushed to disk; the delay ramps up to
|
|
81
|
+
// this under sustained writing (see WRITE_FLUSH_FIRST_STEP_MS). 0 = flush every write immediately —
|
|
82
|
+
// the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
|
|
83
|
+
// the whole stream file per write.
|
|
84
|
+
writeFlushMaxDelayMs: isNode() ? 0 : 15 * 1000,
|
|
67
85
|
};
|
|
68
86
|
|
|
69
87
|
// Marks a key as deleted in the in-memory overlay.
|
|
@@ -177,7 +195,32 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
177
195
|
public readonly name: string,
|
|
178
196
|
protected deps: ReactiveDeps,
|
|
179
197
|
private storageFactory: StorageFactory,
|
|
180
|
-
) {
|
|
198
|
+
) {
|
|
199
|
+
// Best-effort: persist buffered stream writes when the tab is hidden/closing. visibilitychange →
|
|
200
|
+
// hidden fires early enough that the (async) flush usually completes; pagehide is the backstop.
|
|
201
|
+
// No-op outside a browser window (Node).
|
|
202
|
+
if (typeof window !== "undefined") {
|
|
203
|
+
try {
|
|
204
|
+
window.addEventListener("pagehide", () => void this.flushPending());
|
|
205
|
+
if (typeof document !== "undefined") {
|
|
206
|
+
document.addEventListener("visibilitychange", () => {
|
|
207
|
+
if (document.visibilityState === "hidden") void this.flushPending();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
} catch { /* not in a DOM context */ }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- buffered stream writes (per collection) ----
|
|
215
|
+
// The browser rewrites the whole stream file on every append, so we coalesce writes and flush on a
|
|
216
|
+
// ramping delay (see WRITE_FLUSH_FIRST_STEP_MS / writeFlushMaxDelayMs). Each buffered item keeps an
|
|
217
|
+
// `apply` that re-applies its overlay mutation, so resetReader (which clears the overlay) can restore
|
|
218
|
+
// writes that aren't on disk yet. Removed from the buffer only once their append succeeds.
|
|
219
|
+
private pendingAppends: { framed: Buffer; apply: () => void }[] = [];
|
|
220
|
+
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
221
|
+
private flushChain: Promise<void> = Promise.resolve();
|
|
222
|
+
private currentFlushDelay = 0;
|
|
223
|
+
private lastWriteTime = 0;
|
|
181
224
|
|
|
182
225
|
// Block range cache is global and immutable-file-safe; clear it to simulate a cold page load
|
|
183
226
|
// (e.g. between an untimed prep step and the timed benchmark).
|
|
@@ -331,7 +374,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
331
374
|
}
|
|
332
375
|
|
|
333
376
|
// Reset the loaded reader and all derived in-memory caches/overlay. Used only on structural
|
|
334
|
-
// changes (large direct-bulk write, rollover, compact) after the data has been persisted.
|
|
377
|
+
// changes (large direct-bulk write, rollover, compact) after the data has been persisted. Writes
|
|
378
|
+
// still buffered (not yet on disk) are re-applied so the reset doesn't drop them from reads — they
|
|
379
|
+
// aren't in the reloaded reader until their append lands.
|
|
335
380
|
private resetReader() {
|
|
336
381
|
this.deps.batch(() => {
|
|
337
382
|
this.reader.reset();
|
|
@@ -340,6 +385,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
340
385
|
this.baseFields.clear();
|
|
341
386
|
this.baseFieldsLoading.clear();
|
|
342
387
|
this.overlay.clear();
|
|
388
|
+
for (const p of this.pendingAppends) p.apply();
|
|
343
389
|
this.deps.invalidate(LOAD_SIGNAL);
|
|
344
390
|
this.deps.invalidate(OVERLAY_SIGNAL);
|
|
345
391
|
});
|
|
@@ -367,14 +413,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
367
413
|
return;
|
|
368
414
|
}
|
|
369
415
|
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
this.deps.batch(() => {
|
|
375
|
-
for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time);
|
|
376
|
-
});
|
|
416
|
+
// Reflect in the overlay + broadcast to other tabs IMMEDIATELY (in-memory + cross-tab are always
|
|
417
|
+
// current), but buffer the disk append and flush it on a ramping schedule.
|
|
418
|
+
const apply = () => { for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time); };
|
|
419
|
+
this.deps.batch(apply);
|
|
377
420
|
for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
|
|
421
|
+
await this.streamAppend(framed, apply);
|
|
378
422
|
void this.maybeMerge();
|
|
379
423
|
}
|
|
380
424
|
|
|
@@ -386,15 +430,61 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
386
430
|
if (!keys.length) return;
|
|
387
431
|
void this.syncSetup();
|
|
388
432
|
const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
this.deps.batch(() => {
|
|
392
|
-
for (const { time, key } of stamped) this.setOverlayDeleted(key, time);
|
|
393
|
-
});
|
|
433
|
+
const apply = () => { for (const { time, key } of stamped) this.setOverlayDeleted(key, time); };
|
|
434
|
+
this.deps.batch(apply);
|
|
394
435
|
for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
|
|
436
|
+
await this.streamAppend(frameDeletes(stamped), apply);
|
|
395
437
|
void this.maybeMerge();
|
|
396
438
|
}
|
|
397
439
|
|
|
440
|
+
// Buffers framed stream bytes and flushes on a ramping per-collection schedule (see the field block).
|
|
441
|
+
// `apply` re-applies this write's overlay mutation if resetReader clears the overlay before it's on
|
|
442
|
+
// disk. Awaits durability only on an immediate (idle/Node) flush, so a single action — then close — is
|
|
443
|
+
// saved at once; a burst returns fast and is flushed in the background.
|
|
444
|
+
private async streamAppend(framed: Buffer, apply: () => void): Promise<void> {
|
|
445
|
+
this.pendingAppends.push({ framed, apply });
|
|
446
|
+
const max = bulkDatabase2Timing.writeFlushMaxDelayMs;
|
|
447
|
+
const now = Date.now();
|
|
448
|
+
// Immediate when batching is off (Node / real append), or for the first write after a lull.
|
|
449
|
+
if (max <= 0 || this.currentFlushDelay <= 0 || now - this.lastWriteTime > max) {
|
|
450
|
+
this.lastWriteTime = now;
|
|
451
|
+
this.currentFlushDelay = max > 0 ? Math.min(max, WRITE_FLUSH_FIRST_STEP_MS) : 0;
|
|
452
|
+
await this.flushPending();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
// Active burst: coalesce into one scheduled flush and ramp the delay toward max.
|
|
456
|
+
this.lastWriteTime = now;
|
|
457
|
+
if (this.flushTimer === undefined) {
|
|
458
|
+
this.flushTimer = setTimeout(() => { this.flushTimer = undefined; void this.flushPending(); }, this.currentFlushDelay);
|
|
459
|
+
}
|
|
460
|
+
this.currentFlushDelay = Math.min(max, this.currentFlushDelay * 2);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Flushes all buffered stream writes to disk as one append. Serialized (so two flushes never write the
|
|
464
|
+
// same file concurrently) and best-effort: a failed append keeps the data buffered for the next try.
|
|
465
|
+
public async flush(): Promise<void> {
|
|
466
|
+
await this.flushPending();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private async flushPending(): Promise<void> {
|
|
470
|
+
if (this.flushTimer !== undefined) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
|
|
471
|
+
this.flushChain = this.flushChain.then(() => this.doFlush()).catch(e => {
|
|
472
|
+
console.warn(`${this.name}: stream flush failed, will retry: ${(e as Error).message}`);
|
|
473
|
+
});
|
|
474
|
+
return this.flushChain;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private async doFlush(): Promise<void> {
|
|
478
|
+
if (!this.pendingAppends.length) return;
|
|
479
|
+
const batch = this.pendingAppends.slice();
|
|
480
|
+
const combined = Buffer.concat(batch.map(p => p.framed));
|
|
481
|
+
const storage = await this.storage();
|
|
482
|
+
// Throws on failure -> we don't splice, so the data stays buffered and a later flush retries it.
|
|
483
|
+
await storage.append(this.getStreamFileName(), combined);
|
|
484
|
+
// New writes added during the await are after `batch`, so removing the front is exactly the flushed set.
|
|
485
|
+
this.pendingAppends.splice(0, batch.length);
|
|
486
|
+
}
|
|
487
|
+
|
|
398
488
|
public async update(entry: Partial<T> & { key: string }): Promise<void> {
|
|
399
489
|
return this.updateBatch([entry]);
|
|
400
490
|
}
|
|
@@ -539,6 +629,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
539
629
|
public async compact(): Promise<void> {
|
|
540
630
|
if (!tryAcquireMergeLock(this.name, writerId)) return; // someone else is already merging; fine
|
|
541
631
|
try {
|
|
632
|
+
await this.flushPending(); // get buffered writes on disk so they're folded in
|
|
542
633
|
syncBroadcastSeal(this.name);
|
|
543
634
|
this.streamFileName = undefined;
|
|
544
635
|
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
@@ -773,17 +864,44 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
773
864
|
return !!info && info.size === readSize;
|
|
774
865
|
}
|
|
775
866
|
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
867
|
+
// Waits mergeSpacingMs between merges so a burst of work doesn't rewrite every index at once (keeps
|
|
868
|
+
// peak lag low for the browser). Heartbeats the merge lock through the wait; returns false if another
|
|
869
|
+
// tab took the lock over, so the caller stops doing further merges.
|
|
870
|
+
private async mergeSpacingDelay(): Promise<boolean> {
|
|
871
|
+
const total = bulkDatabase2Timing.mergeSpacingMs;
|
|
872
|
+
if (total <= 0) return tryAcquireMergeLock(this.name, writerId);
|
|
873
|
+
const step = 15 * 1000; // re-stamp the lock well within its TTL
|
|
874
|
+
let waited = 0;
|
|
875
|
+
while (waited < total) {
|
|
876
|
+
await new Promise<void>(r => setTimeout(r, Math.min(step, total - waited)));
|
|
877
|
+
waited += step;
|
|
878
|
+
if (!tryAcquireMergeLock(this.name, writerId)) return false; // another tab took over
|
|
879
|
+
}
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// The merge policy. Two passes, with a mergeSpacingMs pause after every merge (so a write burst
|
|
884
|
+
// doesn't rewrite all indexes at once):
|
|
885
|
+
// 1) Consolidate recent fragmentation (one merge): take the newest files up to ~FIRST_MERGE_BYTES
|
|
886
|
+
// and, if they number more than firstMergeTriggerFiles or span more than firstMergeTriggerRangeMs,
|
|
887
|
+
// merge them into one file. Seals first so recent stream data is complete; in Node (no cross-tab
|
|
888
|
+
// seal) only aged streams are folded, so we never re-fold the same un-deletable stream forever.
|
|
889
|
+
// 2) Key-stratify (possibly several merges): sort all keys, walk them in ~KEY_GROUP_BYTES groups, and
|
|
890
|
+
// rewrite EVERY group whose fraction of duplicate (multi-file) keys exceeds DUP_THRESHOLD —
|
|
891
|
+
// highest-duplication first — merging the bulk files overlapping that key range. Groups have
|
|
892
|
+
// disjoint key ranges, so one group's merge doesn't change another's duplication; we re-select
|
|
893
|
+
// each group's files at merge time (the file set shifts as we go). Over time this sorts the data
|
|
894
|
+
// into key-disjoint files. Returns whether any merge happened.
|
|
785
895
|
private async testMerge(): Promise<boolean> {
|
|
786
896
|
let merged = false;
|
|
897
|
+
await this.flushPending(); // get buffered writes on disk so this pass can fold/consider them
|
|
898
|
+
// Run a merge, pausing first if we've already merged this pass (so the pause is BETWEEN merges,
|
|
899
|
+
// never before the first or after the last). Returns false if we lost the lock — stop entirely.
|
|
900
|
+
const runMerge = async (bulk: BulkFileInfo[], stream: StreamFileInfo[]): Promise<boolean> => {
|
|
901
|
+
if (merged && !await this.mergeSpacingDelay()) return false;
|
|
902
|
+
if (await this.mergeFileSet(bulk, stream)) merged = true;
|
|
903
|
+
return true;
|
|
904
|
+
};
|
|
787
905
|
|
|
788
906
|
// ── Pass 1: consolidate recent files. ──
|
|
789
907
|
// Only seal (ask peers + ourselves to abandon current stream files) when cross-tab sync can
|
|
@@ -822,106 +940,128 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
822
940
|
if (triggered) {
|
|
823
941
|
const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
|
|
824
942
|
const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
|
|
825
|
-
if (await
|
|
943
|
+
if (!await runMerge(rb, rs)) return merged;
|
|
826
944
|
}
|
|
827
945
|
}
|
|
828
946
|
|
|
829
947
|
// ── Pass 2: key-stratify the bulk files to remove duplication. ──
|
|
830
|
-
|
|
948
|
+
// Compute the qualifying groups' key ranges once (over a post-pass-1 listing); their key ranges
|
|
949
|
+
// are disjoint, so they stay valid as we merge each in turn.
|
|
950
|
+
const groups = await this.findDuplicateGroups();
|
|
951
|
+
for (const g of groups) {
|
|
952
|
+
// Re-select the files overlapping this group's key range now (earlier merges shifted the set).
|
|
831
953
|
const { bulkFiles } = await this.listFiles();
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
return { file: f, keys: [] as string[], bytes: 0, min: undefined as string | undefined, max: undefined as string | undefined };
|
|
842
|
-
}
|
|
843
|
-
}));
|
|
844
|
-
const usable = infos.filter(i => i.keys.length > 0);
|
|
845
|
-
const keyCount = new Map<string, number>();
|
|
846
|
-
let totalSlots = 0, totalBytes = 0;
|
|
847
|
-
for (const i of usable) {
|
|
848
|
-
totalBytes += i.bytes;
|
|
849
|
-
for (const k of i.keys) { keyCount.set(k, (keyCount.get(k) || 0) + 1); totalSlots++; }
|
|
850
|
-
}
|
|
851
|
-
if (totalSlots > 0) {
|
|
852
|
-
const bytesPerSlot = totalBytes / totalSlots;
|
|
853
|
-
const sortedKeys = [...keyCount.keys()].sort();
|
|
854
|
-
// Walk sorted keys forming ~KEY_GROUP_BYTES groups; remember the group with the highest
|
|
855
|
-
// duplicate fraction over the threshold (the most benefit), then merge its files.
|
|
856
|
-
let best: { lo: string; hi: string; dup: number } | undefined;
|
|
857
|
-
let gStart = 0, gBytes = 0, gSlots = 0, gUnique = 0;
|
|
858
|
-
for (let i = 0; i < sortedKeys.length; i++) {
|
|
859
|
-
const c = keyCount.get(sortedKeys[i])!;
|
|
860
|
-
gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
|
|
861
|
-
if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
|
|
862
|
-
const dup = (gSlots - gUnique) / gSlots;
|
|
863
|
-
if (dup > DUP_THRESHOLD && (!best || dup > best.dup)) best = { lo: sortedKeys[gStart], hi: sortedKeys[i], dup };
|
|
864
|
-
gStart = i + 1; gBytes = 0; gSlots = 0; gUnique = 0;
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
if (best) {
|
|
868
|
-
const lo = best.lo, hi = best.hi;
|
|
869
|
-
const groupFiles = usable
|
|
870
|
-
.filter(i => i.min !== undefined && i.max !== undefined && i.min <= hi && i.max >= lo)
|
|
871
|
-
.map(i => i.file);
|
|
872
|
-
if (groupFiles.length >= 2 && await this.mergeFileSet(groupFiles, [])) merged = true;
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
}
|
|
954
|
+
const headers = await Promise.all(bulkFiles.map(f => this.readBulkHeader(f.fileName)));
|
|
955
|
+
const groupFiles = bulkFiles.filter((f, i) => {
|
|
956
|
+
const h = headers[i];
|
|
957
|
+
if (!h) return false;
|
|
958
|
+
// Old files without a key range are treated as spanning all keys (so always overlap).
|
|
959
|
+
if (h.minKey === undefined || h.maxKey === undefined) return true;
|
|
960
|
+
return h.minKey <= g.hi && h.maxKey >= g.lo;
|
|
961
|
+
});
|
|
962
|
+
if (groupFiles.length >= 2) { if (!await runMerge(groupFiles, [])) return merged; }
|
|
876
963
|
}
|
|
877
964
|
|
|
878
965
|
return merged;
|
|
879
966
|
}
|
|
880
967
|
|
|
968
|
+
// Finds key-range groups worth deduping: sorts all bulk keys, walks them in ~KEY_GROUP_BYTES groups,
|
|
969
|
+
// and returns the [lo, hi] of every group whose duplicate (multi-file) key fraction exceeds
|
|
970
|
+
// DUP_THRESHOLD, highest-duplication first (most benefit). Empty when nothing is worth it.
|
|
971
|
+
private async findDuplicateGroups(): Promise<{ lo: string; hi: string; dup: number }[]> {
|
|
972
|
+
const { bulkFiles } = await this.listFiles();
|
|
973
|
+
if (bulkFiles.length < 2) return [];
|
|
974
|
+
const infos = await Promise.all(bulkFiles.map(async f => {
|
|
975
|
+
try {
|
|
976
|
+
const reader = await this.loadFileReader(f.fileName);
|
|
977
|
+
return { keys: reader.keys, bytes: reader.totalBytes };
|
|
978
|
+
} catch {
|
|
979
|
+
return { keys: [] as string[], bytes: 0 };
|
|
980
|
+
}
|
|
981
|
+
}));
|
|
982
|
+
const keyCount = new Map<string, number>();
|
|
983
|
+
let totalSlots = 0, totalBytes = 0;
|
|
984
|
+
for (const i of infos) {
|
|
985
|
+
totalBytes += i.bytes;
|
|
986
|
+
for (const k of i.keys) { keyCount.set(k, (keyCount.get(k) || 0) + 1); totalSlots++; }
|
|
987
|
+
}
|
|
988
|
+
if (totalSlots === 0) return [];
|
|
989
|
+
const bytesPerSlot = totalBytes / totalSlots;
|
|
990
|
+
const sortedKeys = [...keyCount.keys()].sort();
|
|
991
|
+
const groups: { lo: string; hi: string; dup: number }[] = [];
|
|
992
|
+
let gStart = 0, gBytes = 0, gSlots = 0, gUnique = 0;
|
|
993
|
+
for (let i = 0; i < sortedKeys.length; i++) {
|
|
994
|
+
const c = keyCount.get(sortedKeys[i])!;
|
|
995
|
+
gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
|
|
996
|
+
if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
|
|
997
|
+
const dup = (gSlots - gUnique) / gSlots;
|
|
998
|
+
if (dup > DUP_THRESHOLD) groups.push({ lo: sortedKeys[gStart], hi: sortedKeys[i], dup });
|
|
999
|
+
gStart = i + 1; gBytes = 0; gSlots = 0; gUnique = 0;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
groups.sort((a, b) => b.dup - a.dup);
|
|
1003
|
+
return groups;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
881
1006
|
private formatInfo(reader: ResolvedReader): string {
|
|
882
1007
|
return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
|
|
883
1008
|
}
|
|
884
1009
|
|
|
885
1010
|
// Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty. An
|
|
886
|
-
// overlay entry that doesn't include this column leaves the base (disk) value in place — a
|
|
887
|
-
// write/update only overrides the columns it set; everything else falls through.
|
|
888
|
-
|
|
1011
|
+
// overlay entry that doesn't include this column leaves the base (disk) value+time in place — a
|
|
1012
|
+
// partial write/update only overrides the columns it set; everything else falls through. An overlay
|
|
1013
|
+
// override carries the overlay write's time (when that pending write happened).
|
|
1014
|
+
private patchColumn(base: { key: string; value: unknown; time: number }[], column: string): { key: string; value: unknown; time: number }[] {
|
|
889
1015
|
if (this.overlay.size === 0) return base;
|
|
890
|
-
const map = new Map(base.map(e => [e.key, e.value]));
|
|
1016
|
+
const map = new Map(base.map(e => [e.key, { value: e.value, time: e.time }]));
|
|
891
1017
|
for (const [key, entry] of this.overlay) {
|
|
892
1018
|
if (entry.value === DELETED) { map.delete(key); continue; }
|
|
893
|
-
if (column in entry.value) map.set(key, entry.value[column]);
|
|
894
|
-
else if (!map.has(key)) map.set(key, undefined);
|
|
1019
|
+
if (column in entry.value) map.set(key, { value: entry.value[column], time: entry.time });
|
|
1020
|
+
else if (!map.has(key)) map.set(key, { value: undefined, time: entry.time });
|
|
895
1021
|
}
|
|
896
|
-
return [...map].map(([key,
|
|
1022
|
+
return [...map].map(([key, v]) => ({ key, value: v.value, time: v.time }));
|
|
897
1023
|
}
|
|
898
1024
|
|
|
899
1025
|
// ---- async reads (overlay-aware) ----
|
|
900
1026
|
|
|
901
1027
|
public async getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined> {
|
|
1028
|
+
return (await this.getSingleFieldObj(key, column))?.value;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Like getSingleField, but returns the same shape a getColumn entry has: { key, value, time }, where
|
|
1032
|
+
// time is roughly when that value last changed (the resolved write-time; for a row-merged value it's
|
|
1033
|
+
// the newest contributing write). Returns undefined only when the key isn't present/live.
|
|
1034
|
+
public async getSingleFieldObj<Column extends keyof T>(key: string, column: Column): Promise<{ key: string; value: T[Column]; time: number } | undefined> {
|
|
902
1035
|
void this.syncSetup();
|
|
1036
|
+
const col = String(column);
|
|
903
1037
|
const entry = this.overlay.get(key);
|
|
904
1038
|
if (entry !== undefined) {
|
|
905
1039
|
if (entry.value === DELETED) return undefined;
|
|
906
|
-
if (
|
|
907
|
-
// column not set in the overlay entry — fall through to disk
|
|
1040
|
+
if (col in entry.value) return { key, value: entry.value[col] as T[Column], time: entry.time };
|
|
1041
|
+
// column not set in the overlay entry — fall through to disk for this column
|
|
908
1042
|
}
|
|
909
1043
|
let time = Date.now();
|
|
910
1044
|
let reader = await this.reader();
|
|
911
|
-
|
|
1045
|
+
const r = await reader.getSingleField(key, col);
|
|
912
1046
|
time = Date.now() - time;
|
|
913
1047
|
if (time > 50) {
|
|
914
|
-
console.log(`${blue(`${this.name}.
|
|
1048
|
+
console.log(`${blue(`${this.name}.getSingleFieldObj(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
|
|
915
1049
|
}
|
|
916
|
-
|
|
1050
|
+
if (r === undefined) {
|
|
1051
|
+
// Not live on disk; but if the overlay holds the key (a partial write of a not-yet-on-disk
|
|
1052
|
+
// key) it's live with this column unset.
|
|
1053
|
+
if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
|
|
1054
|
+
return undefined;
|
|
1055
|
+
}
|
|
1056
|
+
return { key, value: r.value as T[Column], time: r.time };
|
|
917
1057
|
}
|
|
918
1058
|
|
|
919
|
-
public async getColumn<Column extends keyof T>(column: Column): Promise<{ key: string; value: T[Column] }[]> {
|
|
1059
|
+
public async getColumn<Column extends keyof T>(column: Column): Promise<{ key: string; value: T[Column]; time: number }[]> {
|
|
920
1060
|
void this.syncSetup();
|
|
921
1061
|
let time = Date.now();
|
|
922
1062
|
let reader = await this.reader();
|
|
923
|
-
let base = await reader.getColumn(String(column))
|
|
924
|
-
let result = this.patchColumn(base, String(column)) as { key: string; value: T[Column] }[];
|
|
1063
|
+
let base = await reader.getColumn(String(column));
|
|
1064
|
+
let result = this.patchColumn(base, String(column)) as { key: string; value: T[Column]; time: number }[];
|
|
925
1065
|
time = Date.now() - time;
|
|
926
1066
|
if (time > 50) {
|
|
927
1067
|
console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
|
|
@@ -947,9 +1087,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
947
1087
|
// loaded once and cached; the overlay is layered on top (we can't async-cache the combined result
|
|
948
1088
|
// because the overlay mutates).
|
|
949
1089
|
|
|
950
|
-
private baseColumns = new Map<string, { key: string; value: unknown }[]>();
|
|
1090
|
+
private baseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
|
|
951
1091
|
private baseColumnsLoading = new Set<string>();
|
|
952
|
-
|
|
1092
|
+
// The disk-resolved field: { value, time } when the key is live on disk, or undefined when it isn't
|
|
1093
|
+
// (Map.has distinguishes "loaded" from "not loaded yet").
|
|
1094
|
+
private baseFields = new Map<string, { value: unknown; time: number } | undefined>();
|
|
953
1095
|
private baseFieldsLoading = new Set<string>();
|
|
954
1096
|
|
|
955
1097
|
private ensureBaseColumn(column: string) {
|
|
@@ -972,9 +1114,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
972
1114
|
this.baseFieldsLoading.add(cacheKey);
|
|
973
1115
|
void (async () => {
|
|
974
1116
|
let reader = await this.reader();
|
|
975
|
-
let
|
|
1117
|
+
let resolved = await reader.getSingleField(key, column);
|
|
976
1118
|
this.deps.batch(() => {
|
|
977
|
-
this.baseFields.set(cacheKey,
|
|
1119
|
+
this.baseFields.set(cacheKey, resolved);
|
|
978
1120
|
this.baseFieldsLoading.delete(cacheKey);
|
|
979
1121
|
this.deps.invalidate(LOAD_SIGNAL);
|
|
980
1122
|
});
|
|
@@ -982,6 +1124,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
982
1124
|
}
|
|
983
1125
|
|
|
984
1126
|
public getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined {
|
|
1127
|
+
return this.getSingleFieldObjSync(key, column)?.value;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Sync (reactive) counterpart of getSingleFieldObj: { key, value, time } once loaded, undefined while
|
|
1131
|
+
// loading or when the key isn't present/live. time is roughly when the value last changed.
|
|
1132
|
+
public getSingleFieldObjSync<Column extends keyof T>(key: string, column: Column): { key: string; value: T[Column]; time: number } | undefined {
|
|
985
1133
|
void this.syncSetup();
|
|
986
1134
|
this.deps.observe(LOAD_SIGNAL);
|
|
987
1135
|
this.deps.observe(key);
|
|
@@ -989,18 +1137,24 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
989
1137
|
let entry = this.overlay.get(key);
|
|
990
1138
|
if (entry !== undefined) {
|
|
991
1139
|
if (entry.value === DELETED) return undefined;
|
|
992
|
-
if (col in entry.value) return entry.value[col] as T[Column];
|
|
993
|
-
// column not set in the overlay entry — fall through to the base field cache
|
|
1140
|
+
if (col in entry.value) return { key, value: entry.value[col] as T[Column], time: entry.time };
|
|
1141
|
+
// column not set in the overlay entry — fall through to the base field cache for this column
|
|
994
1142
|
}
|
|
995
1143
|
let cacheKey = nullJoin(col, key);
|
|
996
1144
|
if (!this.baseFields.has(cacheKey)) {
|
|
997
1145
|
this.ensureBaseField(key, col);
|
|
998
1146
|
return undefined;
|
|
999
1147
|
}
|
|
1000
|
-
|
|
1148
|
+
const base = this.baseFields.get(cacheKey);
|
|
1149
|
+
if (base === undefined) {
|
|
1150
|
+
// Not live on disk; but an overlay entry for the key (partial write) makes it live, column unset.
|
|
1151
|
+
if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
|
|
1152
|
+
return undefined;
|
|
1153
|
+
}
|
|
1154
|
+
return { key, value: base.value as T[Column], time: base.time };
|
|
1001
1155
|
}
|
|
1002
1156
|
|
|
1003
|
-
public getColumnSync<Column extends keyof T>(column: Column): { key: string; value: T[Column] }[] | undefined {
|
|
1157
|
+
public getColumnSync<Column extends keyof T>(column: Column): { key: string; value: T[Column]; time: number }[] | undefined {
|
|
1004
1158
|
void this.syncSetup();
|
|
1005
1159
|
this.deps.observe(LOAD_SIGNAL);
|
|
1006
1160
|
// Observe the overlay-wide signal so we recompute once the base arrives or the overlay changes.
|
|
@@ -1011,7 +1165,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1011
1165
|
this.ensureBaseColumn(col);
|
|
1012
1166
|
return undefined;
|
|
1013
1167
|
}
|
|
1014
|
-
return this.patchColumn(base, col) as { key: string; value: T[Column] }[];
|
|
1168
|
+
return this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
|
|
1015
1169
|
}
|
|
1016
1170
|
|
|
1017
1171
|
public async getColumnInfo() {
|
|
@@ -1031,15 +1185,17 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1031
1185
|
}
|
|
1032
1186
|
}
|
|
1033
1187
|
|
|
1034
|
-
// The merged, time-resolved view over all readers. getColumn/getSingleField return
|
|
1035
|
-
//
|
|
1188
|
+
// The merged, time-resolved view over all readers. getColumn/getSingleField return the resolved value
|
|
1189
|
+
// AND its write-time (so reads can expose roughly when a value last changed); the base layers the
|
|
1190
|
+
// overlay on top of these. getSingleField returns undefined only when the key isn't live (deleted or
|
|
1191
|
+
// absent) — a live key whose column is merely unset returns { value: undefined, time: rowTime }.
|
|
1036
1192
|
type ResolvedReader = {
|
|
1037
1193
|
rowCount: number;
|
|
1038
1194
|
totalBytes: number;
|
|
1039
1195
|
keys: string[];
|
|
1040
1196
|
columns: { column: string; byteSize: number }[];
|
|
1041
|
-
getColumn: (column: string) => Promise<{ key: string; value: unknown }[]>;
|
|
1042
|
-
getSingleField: (key: string, column: string) => Promise<unknown | undefined>;
|
|
1197
|
+
getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
|
|
1198
|
+
getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | undefined>;
|
|
1043
1199
|
};
|
|
1044
1200
|
|
|
1045
1201
|
// Resolve every read by ACTUAL write-time across all readers (stream + bulk), per key and per column:
|
|
@@ -1097,10 +1253,15 @@ async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<R
|
|
|
1097
1253
|
if (!cell || cell.value === ABSENT) continue;
|
|
1098
1254
|
if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
|
|
1099
1255
|
}
|
|
1100
|
-
|
|
1256
|
+
// time = the column value's write-time when this column has one, else the row's last
|
|
1257
|
+
// write-time (the key is live but never set this column).
|
|
1258
|
+
const live = found && bestTime > delOf(key);
|
|
1259
|
+
return { key, value: live ? bestVal : undefined, time: live ? bestTime : (keyTime.get(key) ?? 0) };
|
|
1101
1260
|
});
|
|
1102
1261
|
},
|
|
1103
1262
|
async getSingleField(key, column) {
|
|
1263
|
+
const kt = keyTime.get(key);
|
|
1264
|
+
if (kt === undefined || kt <= delOf(key)) return undefined; // key not live
|
|
1104
1265
|
let bestTime = -Infinity;
|
|
1105
1266
|
let bestVal: unknown;
|
|
1106
1267
|
let found = false;
|
|
@@ -1110,7 +1271,8 @@ async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<R
|
|
|
1110
1271
|
if (r === ABSENT) continue;
|
|
1111
1272
|
if (r.time > bestTime) { bestTime = r.time; bestVal = r.value; found = true; }
|
|
1112
1273
|
}
|
|
1113
|
-
|
|
1274
|
+
const live = found && bestTime > delOf(key);
|
|
1275
|
+
return { value: live ? bestVal : undefined, time: live ? bestTime : kt };
|
|
1114
1276
|
},
|
|
1115
1277
|
};
|
|
1116
1278
|
}
|
|
@@ -21,15 +21,17 @@ function lockKey(collection: string): string {
|
|
|
21
21
|
return "bulkDatabase2-merge:" + collection;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// Returns true if we now hold the lock (or there's no localStorage to coordinate through, in which
|
|
25
|
-
//
|
|
24
|
+
// Returns true if we now hold the lock (or there's no localStorage to coordinate through, in which case
|
|
25
|
+
// we proceed — concurrent merges are harmless). Returns false only if a DIFFERENT holder has a fresh
|
|
26
|
+
// lock. Re-stamping our own lock always succeeds, so this doubles as a heartbeat: call it periodically
|
|
27
|
+
// during a long, spaced-out merge to keep the lock alive (and detect if another tab took it over).
|
|
26
28
|
export function tryAcquireMergeLock(collection: string, holderId: string): boolean {
|
|
27
29
|
const ls = getLocalStorage();
|
|
28
30
|
if (!ls) return true;
|
|
29
31
|
const key = lockKey(collection);
|
|
30
32
|
const now = Date.now();
|
|
31
33
|
const existing = ls.getItem(key);
|
|
32
|
-
if (existing) {
|
|
34
|
+
if (existing && !existing.startsWith(holderId + ":")) {
|
|
33
35
|
const t = parseInt(existing.slice(existing.lastIndexOf(":") + 1), 10);
|
|
34
36
|
if (Number.isFinite(t) && now - t < LOCK_TTL_MS) return false;
|
|
35
37
|
}
|
|
@@ -24,13 +24,10 @@ function frame(payload: Buffer): Buffer {
|
|
|
24
24
|
return Buffer.concat([len, payload, len]);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// batching gives the lowest possible latency for callers who genuinely want single writes — which
|
|
32
|
-
// matters because the tab can be closed at any moment, so we want each write on disk as soon as
|
|
33
|
-
// possible rather than sitting in a pending buffer that a close would lose.
|
|
27
|
+
// Framing only — no batching here. BulkDatabase2 coalesces and flushes these framed bytes on a ramping
|
|
28
|
+
// per-collection schedule (see streamAppend in BulkDatabaseBase), because the browser File System Access
|
|
29
|
+
// API rewrites the whole file on every append, so one-append-per-write is quadratic. The first write
|
|
30
|
+
// after a lull still flushes immediately, so a single action then a tab close is saved at once.
|
|
34
31
|
|
|
35
32
|
// Times are assigned by the caller (BulkDatabase2) so the exact same timestamp lands on disk, in the
|
|
36
33
|
// in-memory overlay, and in the cross-tab broadcast — keeping the global write order consistent.
|