sliftutils 1.4.45 → 1.4.47
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 +44 -2
- package/package.json +1 -1
- package/render-utils/mobxTyped.d.ts +1 -1
- package/render-utils/mobxTyped.ts +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +29 -1
- package/storage/BulkDatabase2/BulkDatabase2.ts +32 -2
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +14 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +130 -4
package/index.d.ts
CHANGED
|
@@ -715,7 +715,7 @@ declare module "sliftutils/render-utils/colors" {
|
|
|
715
715
|
}
|
|
716
716
|
|
|
717
717
|
declare module "sliftutils/render-utils/mobxTyped" {
|
|
718
|
-
export { observable, runInAction, computed, autorun } from "mobx";
|
|
718
|
+
export { observable, runInAction, computed, autorun, onBecomeObserved, onBecomeUnobserved } from "mobx";
|
|
719
719
|
export declare function configureMobxNextFrameScheduler(): void;
|
|
720
720
|
|
|
721
721
|
}
|
|
@@ -763,7 +763,7 @@ declare module "sliftutils/render-utils/observer" {
|
|
|
763
763
|
}
|
|
764
764
|
|
|
765
765
|
declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
766
|
-
import { BulkDatabaseBase, BulkDatabase2Config } from "./BulkDatabaseBase";
|
|
766
|
+
import { BulkDatabaseBase, ReactiveDeps, BulkDatabase2Config } from "./BulkDatabaseBase";
|
|
767
767
|
export { BulkDatabaseBase, noopReactiveDeps, bulkDatabase2Timing } from "./BulkDatabaseBase";
|
|
768
768
|
export type { ReactiveDeps, StorageFactory, BulkDatabase2Config } from "./BulkDatabaseBase";
|
|
769
769
|
/** Per-column on-disk size info, as reported by getColumnInfo/getReaderInfo. */
|
|
@@ -845,6 +845,25 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
845
845
|
value: T[Column];
|
|
846
846
|
time: number;
|
|
847
847
|
}[] | undefined;
|
|
848
|
+
/**
|
|
849
|
+
* Reactive: whether (key, column) is loaded yet — true once we know the answer (value, absent, or
|
|
850
|
+
* deleted), false while it's still loading. getSingleFieldObjSync returns undefined for BOTH "loading"
|
|
851
|
+
* and "absent", so use this to tell them apart (e.g. show a spinner only when this is false).
|
|
852
|
+
*/
|
|
853
|
+
isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
|
|
854
|
+
/** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
|
|
855
|
+
isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
|
|
856
|
+
/**
|
|
857
|
+
* Whether a row (key) is currently being watched by some reactive observer (getSingleFieldObjSync /
|
|
858
|
+
* getSingleFieldSync). Lets callers skip per-row work when nothing's watching. Non-reactive query;
|
|
859
|
+
* returns true if the backend can't tell.
|
|
860
|
+
*/
|
|
861
|
+
isKeyWatched(key: string): boolean;
|
|
862
|
+
/**
|
|
863
|
+
* Drop all of this collection's in-memory loaded caches and re-trigger every watcher, which re-requests
|
|
864
|
+
* and reloads from disk. Pending un-flushed writes are kept. Per-collection.
|
|
865
|
+
*/
|
|
866
|
+
reloadFromDisk(): void;
|
|
848
867
|
/** The columns present on disk and their byte sizes (no row data read). */
|
|
849
868
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
850
869
|
/** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
|
|
@@ -893,6 +912,15 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
893
912
|
* most callers want compact() or tryMergeNow(). */
|
|
894
913
|
merge(timeLo: number, timeHi: number): Promise<void>;
|
|
895
914
|
}
|
|
915
|
+
export declare class MobxReactiveDeps implements ReactiveDeps {
|
|
916
|
+
private boxes;
|
|
917
|
+
private observed;
|
|
918
|
+
private box;
|
|
919
|
+
observe(signal: string): void;
|
|
920
|
+
invalidate(signal: string): void;
|
|
921
|
+
batch(fn: () => void): void;
|
|
922
|
+
isObserved(signal: string): boolean;
|
|
923
|
+
}
|
|
896
924
|
export declare class BulkDatabase2<T extends {
|
|
897
925
|
key: string;
|
|
898
926
|
}> extends BulkDatabaseBase<T> implements IBulkDatabase2<T> {
|
|
@@ -916,11 +944,15 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
916
944
|
streamFoldHardLimitBytes: number;
|
|
917
945
|
writeFlushMaxDelayMs: number;
|
|
918
946
|
fileSetPollIntervalMs: number;
|
|
947
|
+
memoryFlushHeapBytes: number;
|
|
948
|
+
memoryFlushMinCollectionBytes: number;
|
|
949
|
+
memoryFlushThrottleMs: number;
|
|
919
950
|
};
|
|
920
951
|
export interface ReactiveDeps {
|
|
921
952
|
observe(signal: string): void;
|
|
922
953
|
invalidate(signal: string): void;
|
|
923
954
|
batch(fn: () => void): void;
|
|
955
|
+
isObserved?(signal: string): boolean;
|
|
924
956
|
}
|
|
925
957
|
export declare const noopReactiveDeps: ReactiveDeps;
|
|
926
958
|
export type StorageFactory = (path: string) => Promise<FileStorage>;
|
|
@@ -935,6 +967,11 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
935
967
|
private storageFactory;
|
|
936
968
|
private config;
|
|
937
969
|
constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
|
|
970
|
+
private static liveInstances;
|
|
971
|
+
private static memoryWatchdogStarted;
|
|
972
|
+
private static lastMemoryFlushMs;
|
|
973
|
+
private static startMemoryWatchdog;
|
|
974
|
+
static checkMemoryPressure(usedHeapBytes: number): void;
|
|
938
975
|
private pendingAppends;
|
|
939
976
|
private flushTimer;
|
|
940
977
|
private flushChain;
|
|
@@ -955,6 +992,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
955
992
|
private columnCache;
|
|
956
993
|
private readerKeys;
|
|
957
994
|
private loadedFileSet;
|
|
995
|
+
private loadedTotalBytes;
|
|
958
996
|
private readerEpoch;
|
|
959
997
|
private fileSetPollTimer;
|
|
960
998
|
private bulkReaderCache;
|
|
@@ -972,6 +1010,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
972
1010
|
private lastMergeCheck;
|
|
973
1011
|
private getStreamFileName;
|
|
974
1012
|
private invalidateOverlay;
|
|
1013
|
+
isKeyWatched(key: string): boolean;
|
|
975
1014
|
private isLiveNow;
|
|
976
1015
|
private invalidateSignal;
|
|
977
1016
|
private flushSignals;
|
|
@@ -985,6 +1024,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
985
1024
|
private clearReaderState;
|
|
986
1025
|
private resetReader;
|
|
987
1026
|
private reloadReader;
|
|
1027
|
+
reloadFromDisk(): void;
|
|
988
1028
|
private readWithReload;
|
|
989
1029
|
private pollFileSet;
|
|
990
1030
|
write(entry: T): Promise<void>;
|
|
@@ -1058,6 +1098,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
1058
1098
|
value: T[Column];
|
|
1059
1099
|
time: number;
|
|
1060
1100
|
}[] | undefined;
|
|
1101
|
+
isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
|
|
1102
|
+
isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
|
|
1061
1103
|
getColumnInfo(): Promise<{
|
|
1062
1104
|
column: string;
|
|
1063
1105
|
byteSize: number;
|
package/package.json
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { observable, runInAction, computed, autorun } from "mobx";
|
|
1
|
+
export { observable, runInAction, computed, autorun, onBecomeObserved, onBecomeUnobserved } from "mobx";
|
|
2
2
|
export declare function configureMobxNextFrameScheduler(): void;
|
|
@@ -3,7 +3,7 @@ import { batchFunction } from "socket-function/src/batching";
|
|
|
3
3
|
|
|
4
4
|
// Re-export the core mobx primitives so downstream packages (which may have their own duplicate mobx
|
|
5
5
|
// in node_modules) can share THIS mobx instance, otherwise reactivity doesn't cross package boundaries.
|
|
6
|
-
export { observable, runInAction, computed, autorun } from "mobx";
|
|
6
|
+
export { observable, runInAction, computed, autorun, onBecomeObserved, onBecomeUnobserved } from "mobx";
|
|
7
7
|
export function configureMobxNextFrameScheduler() {
|
|
8
8
|
// NOTE: This makes a big difference if we do await calls in a loop which mutates observable state. BUT... we should probably just do those await calls before the loop?
|
|
9
9
|
let batchReactionScheduler = batchFunction({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BulkDatabaseBase, BulkDatabase2Config } from "./BulkDatabaseBase";
|
|
1
|
+
import { BulkDatabaseBase, ReactiveDeps, BulkDatabase2Config } from "./BulkDatabaseBase";
|
|
2
2
|
export { BulkDatabaseBase, noopReactiveDeps, bulkDatabase2Timing } from "./BulkDatabaseBase";
|
|
3
3
|
export type { ReactiveDeps, StorageFactory, BulkDatabase2Config } from "./BulkDatabaseBase";
|
|
4
4
|
/** Per-column on-disk size info, as reported by getColumnInfo/getReaderInfo. */
|
|
@@ -80,6 +80,25 @@ export interface IBulkDatabase2<T extends {
|
|
|
80
80
|
value: T[Column];
|
|
81
81
|
time: number;
|
|
82
82
|
}[] | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* Reactive: whether (key, column) is loaded yet — true once we know the answer (value, absent, or
|
|
85
|
+
* deleted), false while it's still loading. getSingleFieldObjSync returns undefined for BOTH "loading"
|
|
86
|
+
* and "absent", so use this to tell them apart (e.g. show a spinner only when this is false).
|
|
87
|
+
*/
|
|
88
|
+
isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
|
|
89
|
+
/** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
|
|
90
|
+
isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Whether a row (key) is currently being watched by some reactive observer (getSingleFieldObjSync /
|
|
93
|
+
* getSingleFieldSync). Lets callers skip per-row work when nothing's watching. Non-reactive query;
|
|
94
|
+
* returns true if the backend can't tell.
|
|
95
|
+
*/
|
|
96
|
+
isKeyWatched(key: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Drop all of this collection's in-memory loaded caches and re-trigger every watcher, which re-requests
|
|
99
|
+
* and reloads from disk. Pending un-flushed writes are kept. Per-collection.
|
|
100
|
+
*/
|
|
101
|
+
reloadFromDisk(): void;
|
|
83
102
|
/** The columns present on disk and their byte sizes (no row data read). */
|
|
84
103
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
85
104
|
/** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
|
|
@@ -128,6 +147,15 @@ export interface IBulkDatabase2<T extends {
|
|
|
128
147
|
* most callers want compact() or tryMergeNow(). */
|
|
129
148
|
merge(timeLo: number, timeHi: number): Promise<void>;
|
|
130
149
|
}
|
|
150
|
+
export declare class MobxReactiveDeps implements ReactiveDeps {
|
|
151
|
+
private boxes;
|
|
152
|
+
private observed;
|
|
153
|
+
private box;
|
|
154
|
+
observe(signal: string): void;
|
|
155
|
+
invalidate(signal: string): void;
|
|
156
|
+
batch(fn: () => void): void;
|
|
157
|
+
isObserved(signal: string): boolean;
|
|
158
|
+
}
|
|
131
159
|
export declare class BulkDatabase2<T extends {
|
|
132
160
|
key: string;
|
|
133
161
|
}> extends BulkDatabaseBase<T> implements IBulkDatabase2<T> {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getFileStorageNested2 } from "../FileFolderAPI";
|
|
2
|
-
import { observable, runInAction } from "../../render-utils/mobxTyped";
|
|
2
|
+
import { observable, runInAction, onBecomeObserved, onBecomeUnobserved } from "../../render-utils/mobxTyped";
|
|
3
3
|
import { BulkDatabaseBase, ReactiveDeps, BulkDatabase2Config } from "./BulkDatabaseBase";
|
|
4
4
|
|
|
5
5
|
export { BulkDatabaseBase, noopReactiveDeps, bulkDatabase2Timing } from "./BulkDatabaseBase";
|
|
@@ -65,6 +65,28 @@ export interface IBulkDatabase2<T extends { key: string }> {
|
|
|
65
65
|
/** Synchronous, reactive read of a whole column ({ key, value, time }). undefined while still loading. */
|
|
66
66
|
getColumnSync<Column extends keyof T>(column: Column): { key: string; value: T[Column]; time: number }[] | undefined;
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Reactive: whether (key, column) is loaded yet — true once we know the answer (value, absent, or
|
|
70
|
+
* deleted), false while it's still loading. getSingleFieldObjSync returns undefined for BOTH "loading"
|
|
71
|
+
* and "absent", so use this to tell them apart (e.g. show a spinner only when this is false).
|
|
72
|
+
*/
|
|
73
|
+
isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
|
|
74
|
+
/** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
|
|
75
|
+
isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether a row (key) is currently being watched by some reactive observer (getSingleFieldObjSync /
|
|
79
|
+
* getSingleFieldSync). Lets callers skip per-row work when nothing's watching. Non-reactive query;
|
|
80
|
+
* returns true if the backend can't tell.
|
|
81
|
+
*/
|
|
82
|
+
isKeyWatched(key: string): boolean;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Drop all of this collection's in-memory loaded caches and re-trigger every watcher, which re-requests
|
|
86
|
+
* and reloads from disk. Pending un-flushed writes are kept. Per-collection.
|
|
87
|
+
*/
|
|
88
|
+
reloadFromDisk(): void;
|
|
89
|
+
|
|
68
90
|
/** The columns present on disk and their byte sizes (no row data read). */
|
|
69
91
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
70
92
|
/** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
|
|
@@ -112,13 +134,18 @@ export interface IBulkDatabase2<T extends { key: string }> {
|
|
|
112
134
|
// observer/autorun that calls it tracks that box) and invalidate() bumps it (so those reactions re-run).
|
|
113
135
|
// This reproduces the fine-grained per-key + load-version reactivity the class had when it used
|
|
114
136
|
// observable.map/observable.box directly, while keeping all that logic in the mobx-free base.
|
|
115
|
-
class MobxReactiveDeps implements ReactiveDeps {
|
|
137
|
+
export class MobxReactiveDeps implements ReactiveDeps {
|
|
116
138
|
private boxes = new Map<string, { get(): number; set(value: number): void }>();
|
|
139
|
+
// Signals that currently have at least one observer, kept current via mobx's onBecomeObserved/
|
|
140
|
+
// onBecomeUnobserved hooks. Lets isObserved() answer "is anything watching this row" cheaply.
|
|
141
|
+
private observed = new Set<string>();
|
|
117
142
|
private box(signal: string) {
|
|
118
143
|
let box = this.boxes.get(signal);
|
|
119
144
|
if (!box) {
|
|
120
145
|
box = observable.box(0);
|
|
121
146
|
this.boxes.set(signal, box);
|
|
147
|
+
onBecomeObserved(box, () => this.observed.add(signal));
|
|
148
|
+
onBecomeUnobserved(box, () => this.observed.delete(signal));
|
|
122
149
|
}
|
|
123
150
|
return box;
|
|
124
151
|
}
|
|
@@ -132,6 +159,9 @@ class MobxReactiveDeps implements ReactiveDeps {
|
|
|
132
159
|
batch(fn: () => void) {
|
|
133
160
|
runInAction(fn);
|
|
134
161
|
}
|
|
162
|
+
isObserved(signal: string) {
|
|
163
|
+
return this.observed.has(signal);
|
|
164
|
+
}
|
|
135
165
|
}
|
|
136
166
|
|
|
137
167
|
// Backwards-compatible BulkDatabase2: the mobx-reactive flavor. All behavior lives in BulkDatabaseBase;
|
|
@@ -12,11 +12,15 @@ export declare const bulkDatabase2Timing: {
|
|
|
12
12
|
streamFoldHardLimitBytes: number;
|
|
13
13
|
writeFlushMaxDelayMs: number;
|
|
14
14
|
fileSetPollIntervalMs: number;
|
|
15
|
+
memoryFlushHeapBytes: number;
|
|
16
|
+
memoryFlushMinCollectionBytes: number;
|
|
17
|
+
memoryFlushThrottleMs: number;
|
|
15
18
|
};
|
|
16
19
|
export interface ReactiveDeps {
|
|
17
20
|
observe(signal: string): void;
|
|
18
21
|
invalidate(signal: string): void;
|
|
19
22
|
batch(fn: () => void): void;
|
|
23
|
+
isObserved?(signal: string): boolean;
|
|
20
24
|
}
|
|
21
25
|
export declare const noopReactiveDeps: ReactiveDeps;
|
|
22
26
|
export type StorageFactory = (path: string) => Promise<FileStorage>;
|
|
@@ -31,6 +35,11 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
31
35
|
private storageFactory;
|
|
32
36
|
private config;
|
|
33
37
|
constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
|
|
38
|
+
private static liveInstances;
|
|
39
|
+
private static memoryWatchdogStarted;
|
|
40
|
+
private static lastMemoryFlushMs;
|
|
41
|
+
private static startMemoryWatchdog;
|
|
42
|
+
static checkMemoryPressure(usedHeapBytes: number): void;
|
|
34
43
|
private pendingAppends;
|
|
35
44
|
private flushTimer;
|
|
36
45
|
private flushChain;
|
|
@@ -51,6 +60,7 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
51
60
|
private columnCache;
|
|
52
61
|
private readerKeys;
|
|
53
62
|
private loadedFileSet;
|
|
63
|
+
private loadedTotalBytes;
|
|
54
64
|
private readerEpoch;
|
|
55
65
|
private fileSetPollTimer;
|
|
56
66
|
private bulkReaderCache;
|
|
@@ -68,6 +78,7 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
68
78
|
private lastMergeCheck;
|
|
69
79
|
private getStreamFileName;
|
|
70
80
|
private invalidateOverlay;
|
|
81
|
+
isKeyWatched(key: string): boolean;
|
|
71
82
|
private isLiveNow;
|
|
72
83
|
private invalidateSignal;
|
|
73
84
|
private flushSignals;
|
|
@@ -81,6 +92,7 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
81
92
|
private clearReaderState;
|
|
82
93
|
private resetReader;
|
|
83
94
|
private reloadReader;
|
|
95
|
+
reloadFromDisk(): void;
|
|
84
96
|
private readWithReload;
|
|
85
97
|
private pollFileSet;
|
|
86
98
|
write(entry: T): Promise<void>;
|
|
@@ -154,6 +166,8 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
154
166
|
value: T[Column];
|
|
155
167
|
time: number;
|
|
156
168
|
}[] | undefined;
|
|
169
|
+
isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
|
|
170
|
+
isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
|
|
157
171
|
getColumnInfo(): Promise<{
|
|
158
172
|
column: string;
|
|
159
173
|
byteSize: number;
|
|
@@ -34,6 +34,10 @@ const FILE_EXTENSION = ".bulk";
|
|
|
34
34
|
const ROLLOVER_ROWS = 5000;
|
|
35
35
|
const ROLLOVER_BYTES = 5 * 1024 * 1024;
|
|
36
36
|
|
|
37
|
+
// How often the global memory-pressure watchdog samples the heap (the ACTION it may take is throttled
|
|
38
|
+
// separately, see bulkDatabase2Timing.memoryFlushThrottleMs).
|
|
39
|
+
const MEMORY_WATCHDOG_INTERVAL_MS = 60 * 1000;
|
|
40
|
+
|
|
37
41
|
// An unreadable (corrupt/torn, not merely missing) file might be a write still in progress, so we
|
|
38
42
|
// can't delete it on sight. Once it's been unreadable for longer than this (by its name timestamp),
|
|
39
43
|
// no writer is plausibly still working on it, so we delete it. Until then we just warn.
|
|
@@ -104,6 +108,13 @@ export const bulkDatabase2Timing = {
|
|
|
104
108
|
// We proactively re-list files this often; if the set changed under us (another tab/process merged) we
|
|
105
109
|
// reload the index, so reads pick up the change even without first hitting a missing-file error.
|
|
106
110
|
fileSetPollIntervalMs: 30 * 60 * 1000,
|
|
111
|
+
// Memory-pressure auto-flush (browser only; needs performance.memory). When the JS heap exceeds
|
|
112
|
+
// memoryFlushHeapBytes, at most once per memoryFlushThrottleMs, drop the in-memory observable state of
|
|
113
|
+
// every collection whose loaded data exceeds memoryFlushMinCollectionBytes (they reload lazily). Tuned
|
|
114
|
+
// so it does nothing for a healthy app (heap fine) or small collections.
|
|
115
|
+
memoryFlushHeapBytes: 1500 * 1024 * 1024,
|
|
116
|
+
memoryFlushMinCollectionBytes: 100 * 1024 * 1024,
|
|
117
|
+
memoryFlushThrottleMs: 15 * 60 * 1000,
|
|
107
118
|
};
|
|
108
119
|
|
|
109
120
|
// Marks a key as deleted in the in-memory overlay.
|
|
@@ -139,14 +150,19 @@ export interface ReactiveDeps {
|
|
|
139
150
|
invalidate(signal: string): void;
|
|
140
151
|
// Run a group of mutations + invalidations as one batch, so observers re-run at most once.
|
|
141
152
|
batch(fn: () => void): void;
|
|
153
|
+
// Whether `signal` currently has any observer watching it. Optional — a backend that can't tell
|
|
154
|
+
// returns undefined (treated as "assume watched"). Lets writes skip per-key notification work for rows
|
|
155
|
+
// nothing is watching (see isWatchedSync).
|
|
156
|
+
isObserved?(signal: string): boolean;
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
// A non-reactive ReactiveDeps: sync reads still return current values, they just never trigger
|
|
145
|
-
// re-renders. Use this when you don't need a UI to react to writes.
|
|
160
|
+
// re-renders. Use this when you don't need a UI to react to writes. Nothing is ever observed.
|
|
146
161
|
export const noopReactiveDeps: ReactiveDeps = {
|
|
147
162
|
observe() { },
|
|
148
163
|
invalidate() { },
|
|
149
164
|
batch(fn) { fn(); },
|
|
165
|
+
isObserved() { return false; },
|
|
150
166
|
};
|
|
151
167
|
|
|
152
168
|
// Provides the FileStorage for a given path (the caller decides where data physically lives, so this
|
|
@@ -264,6 +280,50 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
264
280
|
// stay correct even without first hitting a missing-file error.
|
|
265
281
|
this.fileSetPollTimer = setInterval(() => void this.pollFileSet(), bulkDatabase2Timing.fileSetPollIntervalMs);
|
|
266
282
|
(this.fileSetPollTimer as { unref?: () => void }).unref?.();
|
|
283
|
+
// Register for the global memory-pressure watchdog (browser only — needs performance.memory).
|
|
284
|
+
BulkDatabaseBase.liveInstances.add(this);
|
|
285
|
+
BulkDatabaseBase.startMemoryWatchdog();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- memory-pressure watchdog (global, browser-only) ----
|
|
289
|
+
// When the JS heap gets large, the biggest contributors are usually a few collections whose in-memory
|
|
290
|
+
// observable state (loaded reader + base column/field caches) has grown. Rather than cap every
|
|
291
|
+
// collection all the time, we watch the heap and, only when it's actually under pressure, drop the
|
|
292
|
+
// observable state of the large collections (they reload lazily on demand). Throttled hard so it never
|
|
293
|
+
// thrashes a healthy app.
|
|
294
|
+
private static liveInstances = new Set<BulkDatabaseBase<{ key: string }>>();
|
|
295
|
+
private static memoryWatchdogStarted = false;
|
|
296
|
+
private static lastMemoryFlushMs = 0;
|
|
297
|
+
private static startMemoryWatchdog() {
|
|
298
|
+
if (BulkDatabaseBase.memoryWatchdogStarted) return;
|
|
299
|
+
BulkDatabaseBase.memoryWatchdogStarted = true;
|
|
300
|
+
const usedHeap = (): number | undefined => {
|
|
301
|
+
try { return (performance as unknown as { memory?: { usedJSHeapSize?: number } })?.memory?.usedJSHeapSize; }
|
|
302
|
+
catch { return undefined; }
|
|
303
|
+
};
|
|
304
|
+
if (typeof performance === "undefined" || usedHeap() === undefined) return; // no heap API (Node / non-Chromium)
|
|
305
|
+
const timer = setInterval(() => {
|
|
306
|
+
const used = usedHeap();
|
|
307
|
+
if (used !== undefined) BulkDatabaseBase.checkMemoryPressure(used);
|
|
308
|
+
}, MEMORY_WATCHDOG_INTERVAL_MS);
|
|
309
|
+
(timer as { unref?: () => void }).unref?.();
|
|
310
|
+
}
|
|
311
|
+
// If the heap is over the limit (and we haven't flushed recently), drop the in-memory observable state
|
|
312
|
+
// of every collection whose loaded data exceeds the size threshold, so it reloads lazily and frees the
|
|
313
|
+
// rest. Exposed (with the heap value injected) so it's callable/testable without the real timer.
|
|
314
|
+
public static checkMemoryPressure(usedHeapBytes: number): void {
|
|
315
|
+
if (usedHeapBytes < bulkDatabase2Timing.memoryFlushHeapBytes) return;
|
|
316
|
+
const now = Date.now();
|
|
317
|
+
if (now - BulkDatabaseBase.lastMemoryFlushMs < bulkDatabase2Timing.memoryFlushThrottleMs) return;
|
|
318
|
+
BulkDatabaseBase.lastMemoryFlushMs = now;
|
|
319
|
+
const flushed: string[] = [];
|
|
320
|
+
for (const db of BulkDatabaseBase.liveInstances) {
|
|
321
|
+
if (db.loadedTotalBytes > bulkDatabase2Timing.memoryFlushMinCollectionBytes) {
|
|
322
|
+
flushed.push(`${db.name} (${fmtBytes(db.loadedTotalBytes)})`);
|
|
323
|
+
db.reloadFromDisk();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (flushed.length) console.log(`[bulk2] heap ${fmtBytes(usedHeapBytes)} over ${fmtBytes(bulkDatabase2Timing.memoryFlushHeapBytes)} — flushed observable state of ${flushed.length} large collection(s) to reclaim memory: ${flushed.join(", ")}`);
|
|
267
327
|
}
|
|
268
328
|
|
|
269
329
|
// ---- buffered stream writes (per collection) ----
|
|
@@ -341,6 +401,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
341
401
|
// under us; we notice either when a read hits a deleted file (readWithReload) or via the periodic poll
|
|
342
402
|
// (pollFileSet), and reload the index — cheap, since a build only reads headers + key columns.
|
|
343
403
|
private loadedFileSet: Set<string> | undefined; // file names the current reader was built from
|
|
404
|
+
// Total (uncompressed) size of the data the current reader spans, as a proxy for this collection's
|
|
405
|
+
// in-memory footprint — used by the memory-pressure watchdog. 0 when no reader is loaded.
|
|
406
|
+
private loadedTotalBytes = 0;
|
|
344
407
|
// Bumped every time the index is cleared/reloaded. A failed read captures it before reading and only
|
|
345
408
|
// triggers a reload if it's unchanged afterward — so N concurrent failures coalesce onto ONE rebuild
|
|
346
409
|
// (the rest see the bumped epoch, skip their own reload, and just await the in-flight one).
|
|
@@ -406,10 +469,20 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
406
469
|
this.dataGen++;
|
|
407
470
|
if (columns === "all") this.columnCache.clear();
|
|
408
471
|
else for (const c of columns) this.columnCache.delete(c);
|
|
409
|
-
|
|
472
|
+
// The per-key signal only re-runs getSingleFieldObjSync watchers of THIS key; skip it when nothing
|
|
473
|
+
// is watching the row (a new watcher reads the current overlay anyway). The overlay-wide signal +
|
|
474
|
+
// column-cache drop above still cover whole-column watchers and read correctness.
|
|
475
|
+
if (this.deps.isObserved?.(key) ?? true) this.invalidateSignal(key);
|
|
410
476
|
this.invalidateSignal(OVERLAY_SIGNAL);
|
|
411
477
|
}
|
|
412
478
|
|
|
479
|
+
// Whether a row (key) is currently being watched — i.e. some reactive observer is subscribed to it via
|
|
480
|
+
// getSingleFieldObjSync / getSingleFieldSync. Useful for skipping per-row work when nothing's watching.
|
|
481
|
+
// Returns true if the backend can't tell (conservative).
|
|
482
|
+
public isKeyWatched(key: string): boolean {
|
|
483
|
+
return this.deps.isObserved?.(key) ?? true;
|
|
484
|
+
}
|
|
485
|
+
|
|
413
486
|
// Whether `key` is currently a live key in the resolved view (overlay wins over disk). A write to a key
|
|
414
487
|
// that's already live only changes the columns it sets; a write to an absent key (or one currently
|
|
415
488
|
// deleted in the overlay) makes it appear in every column, so every column's cache must drop.
|
|
@@ -526,6 +599,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
526
599
|
this.readerKeys = new Set(joined.keys);
|
|
527
600
|
// The files this index was built from, so the poll can detect a concurrent merge changing the set.
|
|
528
601
|
this.loadedFileSet = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
|
|
602
|
+
this.loadedTotalBytes = joined.totalBytes; // footprint proxy for the memory-pressure watchdog
|
|
603
|
+
|
|
529
604
|
// Evict cached sub-readers for files a merge removed, so the caches track the live file set.
|
|
530
605
|
this.pruneFileCaches(bulkFiles, streamFiles);
|
|
531
606
|
|
|
@@ -588,6 +663,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
588
663
|
this.columnCache.clear();
|
|
589
664
|
this.readerKeys = undefined; // the next build repopulates it
|
|
590
665
|
this.loadedFileSet = undefined; // ditto
|
|
666
|
+
this.loadedTotalBytes = 0; // nothing loaded ⇒ no footprint for the memory watchdog
|
|
591
667
|
// The next build re-measures the stream; clear the estimate so a just-folded stream doesn't keep
|
|
592
668
|
// looking "heavy" and re-trigger a fold before then.
|
|
593
669
|
this.streamRowsOnDisk = 0;
|
|
@@ -620,6 +696,23 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
620
696
|
});
|
|
621
697
|
}
|
|
622
698
|
|
|
699
|
+
// Drop ALL of this collection's in-memory loaded caches (the resolved reader, the sync base column /
|
|
700
|
+
// field caches and their stale fallbacks, the per-file decoded readers) and re-trigger every watcher,
|
|
701
|
+
// so they re-request and reload from disk. Unlike the automatic reloads, this is a genuine full clear
|
|
702
|
+
// (no stale-fallback served — watchers go through a real loading state). Pending un-flushed writes (the
|
|
703
|
+
// overlay) are KEPT, so nothing not-yet-on-disk is lost. Per-collection (this instance only).
|
|
704
|
+
public reloadFromDisk(): void {
|
|
705
|
+
this.deps.batch(() => {
|
|
706
|
+
this.clearReaderState(); // drops reader + base caches (→ stale) + column cache; bumps gens
|
|
707
|
+
this.staleBaseColumns.clear(); // and the stale fallbacks — a genuine full clear, not a swap
|
|
708
|
+
this.staleBaseFields.clear();
|
|
709
|
+
this.bulkReaderCache.clear(); // force re-decode from disk
|
|
710
|
+
this.streamReaderCache.clear();
|
|
711
|
+
this.invalidateSignal(LOAD_SIGNAL);
|
|
712
|
+
this.invalidateSignal(OVERLAY_SIGNAL);
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
623
716
|
// Run a read against the loaded index; if a file vanished mid-read (a concurrent merge deleted it),
|
|
624
717
|
// reload the index and retry — looping until it succeeds or we've tried MAX_INDEX_RELOAD_ATTEMPTS times.
|
|
625
718
|
// Reloads coalesce: a failing read only resets the index if nobody has reset it since the read grabbed
|
|
@@ -1168,7 +1261,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1168
1261
|
...streamFiles.map(f => ({ name: f.fileName, size: streamData.sizes.get(f.fileName) ?? 0 })),
|
|
1169
1262
|
];
|
|
1170
1263
|
const inTotal = inputs.reduce((a, f) => a + f.size, 0);
|
|
1171
|
-
|
|
1264
|
+
const mergeStartMs = Date.now();
|
|
1265
|
+
console.log(`${blue(this.name)} merge: reading ${inputs.length} files (${fmtBytes(inTotal)}) at ${new Date(mergeStartMs).toISOString()}`);
|
|
1172
1266
|
for (const f of inputs) console.log(` in ${f.name} ${fmtBytes(f.size)}`);
|
|
1173
1267
|
|
|
1174
1268
|
const { rows, times, deletes } = await this.resolveReaders(readers);
|
|
@@ -1195,7 +1289,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1195
1289
|
// Log the result (files + on-disk sizes), so the before→after of the merge is visible.
|
|
1196
1290
|
const outputs = await Promise.all(outNames.map(async n => ({ name: n, size: (await storage.getInfo(n).catch(() => undefined))?.size ?? 0 })));
|
|
1197
1291
|
const outTotal = outputs.reduce((a, f) => a + f.size, 0);
|
|
1198
|
-
console.log(`${blue(this.name)} merge: wrote ${outputs.length} files (${fmtBytes(outTotal)}, from ${fmtBytes(inTotal)})${carriedDeletes ? `, ${carriedDeletes} tombstones carried` : ""}`);
|
|
1292
|
+
console.log(`${blue(this.name)} merge: wrote ${outputs.length} files (${fmtBytes(outTotal)}, from ${fmtBytes(inTotal)})${carriedDeletes ? `, ${carriedDeletes} tombstones carried` : ""} at ${new Date().toISOString()} (took ${formatTime(Date.now() - mergeStartMs)})`);
|
|
1199
1293
|
for (const f of outputs) console.log(` out ${f.name} ${fmtBytes(f.size)}`);
|
|
1200
1294
|
|
|
1201
1295
|
const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
|
|
@@ -1604,6 +1698,38 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1604
1698
|
return result;
|
|
1605
1699
|
}
|
|
1606
1700
|
|
|
1701
|
+
// Reactive: whether (key, column) is available to read synchronously yet. true once it's loaded — we
|
|
1702
|
+
// know the answer, whether that's a value, absent, or deleted; false while it's still loading from disk.
|
|
1703
|
+
// Pairs with getSingleFieldObjSync, which returns undefined for BOTH "loading" and "absent" — use this
|
|
1704
|
+
// to tell them apart (e.g. show a spinner only when this is false). Triggers the load if not started,
|
|
1705
|
+
// and (like the sync reads) counts the last-known value served during a reload as loaded.
|
|
1706
|
+
public isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean {
|
|
1707
|
+
void this.syncSetup();
|
|
1708
|
+
this.deps.observe(LOAD_SIGNAL);
|
|
1709
|
+
this.deps.observe(key);
|
|
1710
|
+
const entry = this.overlay.get(key);
|
|
1711
|
+
if (entry !== undefined) {
|
|
1712
|
+
if (entry.value === DELETED) return true; // known: deleted
|
|
1713
|
+
if (String(column) in entry.value) return true; // known: overlay holds this column
|
|
1714
|
+
// else: this column falls through to disk — check the base caches below
|
|
1715
|
+
}
|
|
1716
|
+
const cacheKey = nullJoin(String(column), key);
|
|
1717
|
+
if (this.baseFields.has(cacheKey) || this.staleBaseFields.has(cacheKey)) return true;
|
|
1718
|
+
this.ensureBaseField(key, String(column));
|
|
1719
|
+
return false;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Reactive: whether a whole column is available to read synchronously yet (see isFieldLoadedSync).
|
|
1723
|
+
public isColumnLoadedSync<Column extends keyof T>(column: Column): boolean {
|
|
1724
|
+
void this.syncSetup();
|
|
1725
|
+
this.deps.observe(LOAD_SIGNAL);
|
|
1726
|
+
this.deps.observe(OVERLAY_SIGNAL);
|
|
1727
|
+
const col = String(column);
|
|
1728
|
+
if (this.columnCache.has(col) || this.baseColumns.has(col) || this.staleBaseColumns.has(col)) return true;
|
|
1729
|
+
this.ensureBaseColumn(col);
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1607
1733
|
public async getColumnInfo() {
|
|
1608
1734
|
let reader = await this.reader();
|
|
1609
1735
|
return reader.columns;
|