sliftutils 1.4.46 → 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 +34 -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 +21 -1
- package/storage/BulkDatabase2/BulkDatabase2.ts +23 -2
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +12 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +98 -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. */
|
|
@@ -853,6 +853,17 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
853
853
|
isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
|
|
854
854
|
/** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
|
|
855
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;
|
|
856
867
|
/** The columns present on disk and their byte sizes (no row data read). */
|
|
857
868
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
858
869
|
/** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
|
|
@@ -901,6 +912,15 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
901
912
|
* most callers want compact() or tryMergeNow(). */
|
|
902
913
|
merge(timeLo: number, timeHi: number): Promise<void>;
|
|
903
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
|
+
}
|
|
904
924
|
export declare class BulkDatabase2<T extends {
|
|
905
925
|
key: string;
|
|
906
926
|
}> extends BulkDatabaseBase<T> implements IBulkDatabase2<T> {
|
|
@@ -924,11 +944,15 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
924
944
|
streamFoldHardLimitBytes: number;
|
|
925
945
|
writeFlushMaxDelayMs: number;
|
|
926
946
|
fileSetPollIntervalMs: number;
|
|
947
|
+
memoryFlushHeapBytes: number;
|
|
948
|
+
memoryFlushMinCollectionBytes: number;
|
|
949
|
+
memoryFlushThrottleMs: number;
|
|
927
950
|
};
|
|
928
951
|
export interface ReactiveDeps {
|
|
929
952
|
observe(signal: string): void;
|
|
930
953
|
invalidate(signal: string): void;
|
|
931
954
|
batch(fn: () => void): void;
|
|
955
|
+
isObserved?(signal: string): boolean;
|
|
932
956
|
}
|
|
933
957
|
export declare const noopReactiveDeps: ReactiveDeps;
|
|
934
958
|
export type StorageFactory = (path: string) => Promise<FileStorage>;
|
|
@@ -943,6 +967,11 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
943
967
|
private storageFactory;
|
|
944
968
|
private config;
|
|
945
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;
|
|
946
975
|
private pendingAppends;
|
|
947
976
|
private flushTimer;
|
|
948
977
|
private flushChain;
|
|
@@ -963,6 +992,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
963
992
|
private columnCache;
|
|
964
993
|
private readerKeys;
|
|
965
994
|
private loadedFileSet;
|
|
995
|
+
private loadedTotalBytes;
|
|
966
996
|
private readerEpoch;
|
|
967
997
|
private fileSetPollTimer;
|
|
968
998
|
private bulkReaderCache;
|
|
@@ -980,6 +1010,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
980
1010
|
private lastMergeCheck;
|
|
981
1011
|
private getStreamFileName;
|
|
982
1012
|
private invalidateOverlay;
|
|
1013
|
+
isKeyWatched(key: string): boolean;
|
|
983
1014
|
private isLiveNow;
|
|
984
1015
|
private invalidateSignal;
|
|
985
1016
|
private flushSignals;
|
|
@@ -993,6 +1024,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
993
1024
|
private clearReaderState;
|
|
994
1025
|
private resetReader;
|
|
995
1026
|
private reloadReader;
|
|
1027
|
+
reloadFromDisk(): void;
|
|
996
1028
|
private readWithReload;
|
|
997
1029
|
private pollFileSet;
|
|
998
1030
|
write(entry: T): Promise<void>;
|
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. */
|
|
@@ -88,6 +88,17 @@ export interface IBulkDatabase2<T extends {
|
|
|
88
88
|
isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
|
|
89
89
|
/** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
|
|
90
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;
|
|
91
102
|
/** The columns present on disk and their byte sizes (no row data read). */
|
|
92
103
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
93
104
|
/** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
|
|
@@ -136,6 +147,15 @@ export interface IBulkDatabase2<T extends {
|
|
|
136
147
|
* most callers want compact() or tryMergeNow(). */
|
|
137
148
|
merge(timeLo: number, timeHi: number): Promise<void>;
|
|
138
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
|
+
}
|
|
139
159
|
export declare class BulkDatabase2<T extends {
|
|
140
160
|
key: string;
|
|
141
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";
|
|
@@ -74,6 +74,19 @@ export interface IBulkDatabase2<T extends { key: string }> {
|
|
|
74
74
|
/** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
|
|
75
75
|
isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
|
|
76
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
|
+
|
|
77
90
|
/** The columns present on disk and their byte sizes (no row data read). */
|
|
78
91
|
getColumnInfo(): Promise<BulkColumnInfo[]>;
|
|
79
92
|
/** A cheap snapshot of the collection's shape (row/key counts, total bytes, columns) — no row data. */
|
|
@@ -121,13 +134,18 @@ export interface IBulkDatabase2<T extends { key: string }> {
|
|
|
121
134
|
// observer/autorun that calls it tracks that box) and invalidate() bumps it (so those reactions re-run).
|
|
122
135
|
// This reproduces the fine-grained per-key + load-version reactivity the class had when it used
|
|
123
136
|
// observable.map/observable.box directly, while keeping all that logic in the mobx-free base.
|
|
124
|
-
class MobxReactiveDeps implements ReactiveDeps {
|
|
137
|
+
export class MobxReactiveDeps implements ReactiveDeps {
|
|
125
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>();
|
|
126
142
|
private box(signal: string) {
|
|
127
143
|
let box = this.boxes.get(signal);
|
|
128
144
|
if (!box) {
|
|
129
145
|
box = observable.box(0);
|
|
130
146
|
this.boxes.set(signal, box);
|
|
147
|
+
onBecomeObserved(box, () => this.observed.add(signal));
|
|
148
|
+
onBecomeUnobserved(box, () => this.observed.delete(signal));
|
|
131
149
|
}
|
|
132
150
|
return box;
|
|
133
151
|
}
|
|
@@ -141,6 +159,9 @@ class MobxReactiveDeps implements ReactiveDeps {
|
|
|
141
159
|
batch(fn: () => void) {
|
|
142
160
|
runInAction(fn);
|
|
143
161
|
}
|
|
162
|
+
isObserved(signal: string) {
|
|
163
|
+
return this.observed.has(signal);
|
|
164
|
+
}
|
|
144
165
|
}
|
|
145
166
|
|
|
146
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>;
|
|
@@ -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 */ } };
|