@futdevpro/nts-dynamo 1.15.75 → 1.15.80
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/.dynamo/logs/cicd-pipeline/output.log +1952 -1883
- package/.dynamo/logs/cicd-pipeline/status.json +38 -38
- package/build/_collections/global-settings.const.d.ts.map +1 -1
- package/build/_collections/global-settings.const.js +11 -0
- package/build/_collections/global-settings.const.js.map +1 -1
- package/build/_models/interfaces/global-settings.interface.d.ts +18 -0
- package/build/_models/interfaces/global-settings.interface.d.ts.map +1 -1
- package/build/_services/base/data.service.d.ts +8 -0
- package/build/_services/base/data.service.d.ts.map +1 -1
- package/build/_services/base/data.service.js +17 -0
- package/build/_services/base/data.service.js.map +1 -1
- package/build/_services/core/collection-growth-monitor.service.d.ts +77 -0
- package/build/_services/core/collection-growth-monitor.service.d.ts.map +1 -0
- package/build/_services/core/collection-growth-monitor.service.js +147 -0
- package/build/_services/core/collection-growth-monitor.service.js.map +1 -0
- package/build/_services/core/global.service.d.ts +7 -0
- package/build/_services/core/global.service.d.ts.map +1 -1
- package/build/_services/core/global.service.js +10 -0
- package/build/_services/core/global.service.js.map +1 -1
- package/build/_services/server/app.server.d.ts +45 -0
- package/build/_services/server/app.server.d.ts.map +1 -1
- package/build/_services/server/app.server.js +97 -173
- package/build/_services/server/app.server.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/package.json +2 -2
- package/src/_collections/global-settings.const.ts +12 -0
- package/src/_models/interfaces/global-settings.interface.ts +19 -0
- package/src/_services/base/data.service.spec.ts +56 -1
- package/src/_services/base/data.service.ts +23 -2
- package/src/_services/core/collection-growth-monitor.service.spec.ts +67 -0
- package/src/_services/core/collection-growth-monitor.service.ts +211 -0
- package/src/_services/core/global.service.ts +14 -2
- package/src/_services/server/app.server-retention.spec.ts +106 -0
- package/src/_services/server/app.server.ts +137 -3
- package/src/index.ts +1 -0
|
@@ -3,7 +3,7 @@ import { DyNTS_DataService } from './data.service';
|
|
|
3
3
|
import { DyNTS_DBService } from './db.service';
|
|
4
4
|
import { DyNTS_GlobalService } from '../core/global.service';
|
|
5
5
|
import { DyNTS_ArchiveDataService } from './archive-data.service';
|
|
6
|
-
import { DyFM_Metadata, DyFM_DataModel_Params, DyFM_Error, DyFM_DBFilter, DyFM_SearchQuery, DyFM_BasicProperty_Type, DyFM_EnvironmentFlag } from '@futdevpro/fsm-dynamo';
|
|
6
|
+
import { DyFM_Metadata, DyFM_DataModel_Params, DyFM_Error, DyFM_DBFilter, DyFM_SearchQuery, DyFM_BasicProperty_Type, DyFM_EnvironmentFlag, DyFM_Log } from '@futdevpro/fsm-dynamo';
|
|
7
7
|
import { DyNTS_global_settings } from '../../_collections/global-settings.const';
|
|
8
8
|
|
|
9
9
|
// Initialize global settings before any test runs
|
|
@@ -273,6 +273,61 @@ describe('| DyNTS_DataService', () => {
|
|
|
273
273
|
expect(result).toEqual(mockDataList);
|
|
274
274
|
expect(service.dataList).toEqual(mockDataList);
|
|
275
275
|
});
|
|
276
|
+
|
|
277
|
+
// FR-258 / SR-3 — over-fetch guard (unbounded find → heap → OOM precursor). Warns, never caps.
|
|
278
|
+
describe('| over-fetch warning (FR-258 / SR-3)', () => {
|
|
279
|
+
let originalThreshold: number;
|
|
280
|
+
|
|
281
|
+
beforeEach(() => {
|
|
282
|
+
originalThreshold = DyNTS_DataService.findDataListWarnThreshold;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
afterEach(() => {
|
|
286
|
+
DyNTS_DataService.findDataListWarnThreshold = originalThreshold;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
function listOf(count: number): TestMetadata[] {
|
|
290
|
+
return Array.from({ length: count }, (_v, i: number): TestMetadata =>
|
|
291
|
+
new TestMetadata({ _id: `id-${i}`, name: `n-${i}` } as TestMetadata));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
it('| warns when the result EXCEEDS the threshold', async () => {
|
|
295
|
+
DyNTS_DataService.findDataListWarnThreshold = 3;
|
|
296
|
+
const warnSpy = spyOn(DyFM_Log, 'warn');
|
|
297
|
+
mockDBService.find.and.returnValue(Promise.resolve(listOf(5)));
|
|
298
|
+
|
|
299
|
+
const result = await service.findDataList({ name: 'Test' });
|
|
300
|
+
|
|
301
|
+
expect(result.length).toBe(5);
|
|
302
|
+
const overFetchCall = warnSpy.calls.allArgs().find((args: any[]): boolean =>
|
|
303
|
+
typeof args[0] === 'string' && args[0].includes('[FR-258 SR-3]'));
|
|
304
|
+
expect(overFetchCall).toBeDefined();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('| does NOT warn at exactly the threshold (strictly greater)', async () => {
|
|
308
|
+
DyNTS_DataService.findDataListWarnThreshold = 5;
|
|
309
|
+
const warnSpy = spyOn(DyFM_Log, 'warn');
|
|
310
|
+
mockDBService.find.and.returnValue(Promise.resolve(listOf(5)));
|
|
311
|
+
|
|
312
|
+
await service.findDataList({ name: 'Test' });
|
|
313
|
+
|
|
314
|
+
const overFetchCall = warnSpy.calls.allArgs().find((args: any[]): boolean =>
|
|
315
|
+
typeof args[0] === 'string' && args[0].includes('[FR-258 SR-3]'));
|
|
316
|
+
expect(overFetchCall).toBeUndefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('| disabled (threshold <= 0) → never warns regardless of size', async () => {
|
|
320
|
+
DyNTS_DataService.findDataListWarnThreshold = 0;
|
|
321
|
+
const warnSpy = spyOn(DyFM_Log, 'warn');
|
|
322
|
+
mockDBService.find.and.returnValue(Promise.resolve(listOf(50000)));
|
|
323
|
+
|
|
324
|
+
await service.findDataList({ name: 'Test' });
|
|
325
|
+
|
|
326
|
+
const overFetchCall = warnSpy.calls.allArgs().find((args: any[]): boolean =>
|
|
327
|
+
typeof args[0] === 'string' && args[0].includes('[FR-258 SR-3]'));
|
|
328
|
+
expect(overFetchCall).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
276
331
|
});
|
|
277
332
|
|
|
278
333
|
describe('| updateData', () => {
|
|
@@ -81,6 +81,15 @@ export class DyNTS_DataService<T extends DyFM_Metadata> {
|
|
|
81
81
|
/** error code base */
|
|
82
82
|
ecBase: string = `${DyNTS_global_settings.systemShortCodeName}|`;
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* FR-258 / SR-3 — over-fetch küszöb. Ennél több dokumentumot visszaadó {@link findDataList} hívás
|
|
86
|
+
* az unbounded-growth → GC-thrash/OOM vektor (2026-06-23 incidens): a teljes (akár több-100k-s)
|
|
87
|
+
* collection a heap-be töltődik. NEM capeljük a query-t (az törné a hívókat) — csak WARNINGot
|
|
88
|
+
* logolunk, hogy az over-fetch LÁTHATÓ legyen és paginálható/szűkíthető. Runtime felülírható
|
|
89
|
+
* (`DyNTS_DataService.findDataListWarnThreshold = N`). Default: 10000.
|
|
90
|
+
*/
|
|
91
|
+
static findDataListWarnThreshold: number = 10000;
|
|
92
|
+
|
|
84
93
|
dataDBService: DyNTS_DBService<T>;
|
|
85
94
|
haveArchiveDataService: boolean;
|
|
86
95
|
|
|
@@ -676,11 +685,23 @@ export class DyNTS_DataService<T extends DyFM_Metadata> {
|
|
|
676
685
|
this.dataList = dataListExists;
|
|
677
686
|
}
|
|
678
687
|
|
|
688
|
+
// FR-258 / SR-3 — over-fetch guard. Egy szűretlen find, ami egy nagy collection EGÉSZÉT a heap-be
|
|
689
|
+
// tölti, a GC-thrash/OOM vektor (2026-06-23). NEM capeljük a query-t (az törné a hívókat), csak
|
|
690
|
+
// WARNINGot logolunk, hogy az over-fetch LÁTHATÓ legyen → paginate/szűkebb filter a megoldás.
|
|
691
|
+
const overFetchThreshold: number = DyNTS_DataService.findDataListWarnThreshold;
|
|
692
|
+
if (overFetchThreshold > 0 && dataListExists.length > overFetchThreshold) {
|
|
693
|
+
DyFM_Log.warn(
|
|
694
|
+
`[FR-258 SR-3] findDataList "${this.dataParams.dataName}" returned ${dataListExists.length} documents ` +
|
|
695
|
+
`(> ${overFetchThreshold}) — the whole result is held in heap. Consider a tighter filter or ` +
|
|
696
|
+
`pagination (skip/limit) to avoid unbounded memory growth.`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
679
700
|
return dataListExists;
|
|
680
701
|
} catch (error) {
|
|
681
|
-
throw new DyFM_Error({
|
|
702
|
+
throw new DyFM_Error({
|
|
682
703
|
...this._getDefaultErrorSettings('findDataList', error),
|
|
683
|
-
|
|
704
|
+
|
|
684
705
|
errorCode: `${this.ecBase}DyNTS-DS0-FDS0`,
|
|
685
706
|
});
|
|
686
707
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { DyNTS_CollectionGrowthAlert, DyNTS_CollectionGrowthMonitor } from './collection-growth-monitor.service';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FR-258 / SR-4 — unit tests for the pure growth-evaluation core
|
|
5
|
+
* (`DyNTS_CollectionGrowthMonitor.evaluateGrowth`). No I/O, no timers — covers size-threshold,
|
|
6
|
+
* growth-rate, both/neither, and the first-poll (no prior snapshot) edge cases.
|
|
7
|
+
*/
|
|
8
|
+
describe('| DyNTS_CollectionGrowthMonitor.evaluateGrowth (FR-258 / SR-4)', () => {
|
|
9
|
+
|
|
10
|
+
const SIZE_THRESHOLD: number = 250000;
|
|
11
|
+
const GROWTH_PCT: number = 50;
|
|
12
|
+
|
|
13
|
+
function kinds(alerts: DyNTS_CollectionGrowthAlert[]): string[] {
|
|
14
|
+
return alerts.map((a: DyNTS_CollectionGrowthAlert): string => a.kind).sort();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
it('| under threshold + no prior snapshot → no alerts', () => {
|
|
18
|
+
const alerts: DyNTS_CollectionGrowthAlert[] =
|
|
19
|
+
DyNTS_CollectionGrowthMonitor.evaluateGrowth('logs', 100, undefined, SIZE_THRESHOLD, GROWTH_PCT);
|
|
20
|
+
expect(alerts).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('| over size threshold → a "size" alert with the right detail', () => {
|
|
24
|
+
const alerts: DyNTS_CollectionGrowthAlert[] =
|
|
25
|
+
DyNTS_CollectionGrowthMonitor.evaluateGrowth('cdp_report_details_archiveds', 300000, undefined, SIZE_THRESHOLD, GROWTH_PCT);
|
|
26
|
+
expect(kinds(alerts)).toEqual(['size']);
|
|
27
|
+
expect(alerts[0].dataName).toBe('cdp_report_details_archiveds');
|
|
28
|
+
expect(alerts[0].detail).toEqual({ collection: 'cdp_report_details_archiveds', docCount: 300000, threshold: SIZE_THRESHOLD });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('| exactly AT the threshold → no size alert (strictly greater)', () => {
|
|
32
|
+
const alerts: DyNTS_CollectionGrowthAlert[] =
|
|
33
|
+
DyNTS_CollectionGrowthMonitor.evaluateGrowth('logs', SIZE_THRESHOLD, undefined, SIZE_THRESHOLD, GROWTH_PCT);
|
|
34
|
+
expect(alerts).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('| grew more than the growth-rate % since last poll → a "growth" alert', () => {
|
|
38
|
+
const alerts: DyNTS_CollectionGrowthAlert[] =
|
|
39
|
+
DyNTS_CollectionGrowthMonitor.evaluateGrowth('pipeline_jobs', 200, { docCount: 100, atMs: 0 }, SIZE_THRESHOLD, GROWTH_PCT);
|
|
40
|
+
expect(kinds(alerts)).toEqual(['growth']);
|
|
41
|
+
expect(alerts[0].detail).toEqual({ collection: 'pipeline_jobs', oldCount: 100, newCount: 200, growthPct: 100 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('| grew LESS than the growth-rate % → no growth alert', () => {
|
|
45
|
+
const alerts: DyNTS_CollectionGrowthAlert[] =
|
|
46
|
+
DyNTS_CollectionGrowthMonitor.evaluateGrowth('logs', 110, { docCount: 100, atMs: 0 }, SIZE_THRESHOLD, GROWTH_PCT);
|
|
47
|
+
expect(alerts).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('| both over-threshold AND fast-growth → BOTH alerts', () => {
|
|
51
|
+
const alerts: DyNTS_CollectionGrowthAlert[] =
|
|
52
|
+
DyNTS_CollectionGrowthMonitor.evaluateGrowth('big', 400000, { docCount: 100000, atMs: 0 }, SIZE_THRESHOLD, GROWTH_PCT);
|
|
53
|
+
expect(kinds(alerts)).toEqual(['growth', 'size']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('| prior snapshot with zero docCount → no growth alert (avoid divide-by-zero)', () => {
|
|
57
|
+
const alerts: DyNTS_CollectionGrowthAlert[] =
|
|
58
|
+
DyNTS_CollectionGrowthMonitor.evaluateGrowth('fresh', 5000, { docCount: 0, atMs: 0 }, SIZE_THRESHOLD, GROWTH_PCT);
|
|
59
|
+
expect(alerts).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('| shrinking collection (negative growth) → no growth alert', () => {
|
|
63
|
+
const alerts: DyNTS_CollectionGrowthAlert[] =
|
|
64
|
+
DyNTS_CollectionGrowthMonitor.evaluateGrowth('pruned', 50, { docCount: 100, atMs: 0 }, SIZE_THRESHOLD, GROWTH_PCT);
|
|
65
|
+
expect(alerts).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { DyFM_Error, DyFM_ErrorLevel, DyFM_Log } from '@futdevpro/fsm-dynamo';
|
|
2
|
+
|
|
3
|
+
import { DyNTS_global_settings } from '../../_collections/global-settings.const';
|
|
4
|
+
import { DyNTS_SingletonServiceBase } from '../base/singleton.service-base';
|
|
5
|
+
import { DyNTS_GlobalService } from './global.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* FR-258 / SR-4 — Collection Growth Monitor (proactive companion to DyNTS_MemoryGuard).
|
|
9
|
+
*
|
|
10
|
+
* The MemoryGuard is REACTIVE: it fires when the heap is already near OOM. This monitor is
|
|
11
|
+
* PROACTIVE: it periodically samples each registered collection's document count (via the cheap,
|
|
12
|
+
* scan-free `estimatedDocumentCount()`) and raises a warning into the Errors-sink BEFORE a bloated
|
|
13
|
+
* collection can be loaded into heap and GC-thrash the process (the 2026-06-23 overseer incident:
|
|
14
|
+
* `cdp_report_details_archiveds` reached 3 GB / `pipeline_jobs` 2 GB, unbounded).
|
|
15
|
+
*
|
|
16
|
+
* It pairs with SR-1/SR-2 (declarative retention TTL): TTL keeps collections bounded; this monitor
|
|
17
|
+
* surfaces the ones that AREN'T (no retention declared, or growing faster than retention prunes) so
|
|
18
|
+
* an operator can add `retention` before it becomes an OOM. Pure observation — never deletes.
|
|
19
|
+
*
|
|
20
|
+
* Like the MemoryGuard it is a singleton, idempotent, fully non-fatal (a monitoring layer must never
|
|
21
|
+
* crash a server), and its timer is `unref()`-ed so it can't keep the process alive on its own.
|
|
22
|
+
*/
|
|
23
|
+
export interface DyNTS_CollectionGrowthMonitor_Config {
|
|
24
|
+
/** Master switch. Default: `DyNTS_global_settings.collectionGrowthMonitor.enabled` (true). */
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
/** Poll cadence in ms. Default: 300000 (5 min) — collection growth is slow, no need to poll often. */
|
|
27
|
+
pollIntervalMs?: number;
|
|
28
|
+
/** Warn when a collection's document count exceeds this. Default: 250000. */
|
|
29
|
+
sizeThresholdDocCount?: number;
|
|
30
|
+
/** Warn when a collection grew by more than this percent since the previous poll. Default: 50. */
|
|
31
|
+
growthRateThresholdPct?: number;
|
|
32
|
+
/** Re-alert cooldown per collection per alert-kind, in ms (anti-flood). Default: 3600000 (1 h). */
|
|
33
|
+
reAlertCooldownMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One growth finding to surface to the Errors-sink. */
|
|
37
|
+
export interface DyNTS_CollectionGrowthAlert {
|
|
38
|
+
kind: 'size' | 'growth';
|
|
39
|
+
dataName: string;
|
|
40
|
+
message: string;
|
|
41
|
+
detail: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface CollectionSnapshot {
|
|
45
|
+
docCount: number;
|
|
46
|
+
atMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class DyNTS_CollectionGrowthMonitor extends DyNTS_SingletonServiceBase {
|
|
50
|
+
|
|
51
|
+
static getInstance(): DyNTS_CollectionGrowthMonitor {
|
|
52
|
+
return DyNTS_CollectionGrowthMonitor.getSingletonInstance() as DyNTS_CollectionGrowthMonitor;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
56
|
+
private installed: boolean = false;
|
|
57
|
+
|
|
58
|
+
// Resolved (effective) config — filled at install().
|
|
59
|
+
private pollIntervalMs: number = 300000;
|
|
60
|
+
private sizeThresholdDocCount: number = 250000;
|
|
61
|
+
private growthRateThresholdPct: number = 50;
|
|
62
|
+
private reAlertCooldownMs: number = 3600000;
|
|
63
|
+
|
|
64
|
+
private readonly lastSnapshotByCollection: Map<string, CollectionSnapshot> = new Map();
|
|
65
|
+
/** Last time we alerted, keyed by `${dataName}:${kind}` — for the anti-flood cooldown. */
|
|
66
|
+
private readonly lastAlertAtByKey: Map<string, number> = new Map();
|
|
67
|
+
|
|
68
|
+
protected constructor() {
|
|
69
|
+
super();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Start the monitor. Idempotent (a second call is a no-op). `config` overrides
|
|
74
|
+
* `DyNTS_global_settings.collectionGrowthMonitor`, which overrides the built-in defaults. NEVER
|
|
75
|
+
* throws — every error is try/catch-ed and descriptively logged.
|
|
76
|
+
*/
|
|
77
|
+
install(config?: DyNTS_CollectionGrowthMonitor_Config): void {
|
|
78
|
+
if (this.installed) { return; }
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const g: NonNullable<typeof DyNTS_global_settings.collectionGrowthMonitor> =
|
|
82
|
+
DyNTS_global_settings.collectionGrowthMonitor ?? { enabled: true };
|
|
83
|
+
|
|
84
|
+
if (!(config?.enabled ?? g.enabled ?? true)) { return; }
|
|
85
|
+
|
|
86
|
+
this.pollIntervalMs = config?.pollIntervalMs ?? g.pollIntervalMs ?? 300000;
|
|
87
|
+
this.sizeThresholdDocCount = config?.sizeThresholdDocCount ?? g.sizeThresholdDocCount ?? 250000;
|
|
88
|
+
this.growthRateThresholdPct = config?.growthRateThresholdPct ?? g.growthRateThresholdPct ?? 50;
|
|
89
|
+
this.reAlertCooldownMs = config?.reAlertCooldownMs ?? g.reAlertCooldownMs ?? 3600000;
|
|
90
|
+
|
|
91
|
+
this.installed = true;
|
|
92
|
+
this.timer = setInterval((): void => { void this.poll(); }, this.pollIntervalMs);
|
|
93
|
+
if (typeof this.timer.unref === 'function') { this.timer.unref(); }
|
|
94
|
+
|
|
95
|
+
DyFM_Log.info(
|
|
96
|
+
`[DyNTS_CollectionGrowthMonitor] installed — poll ${Math.round(this.pollIntervalMs / 1000)}s, ` +
|
|
97
|
+
`size-threshold ${this.sizeThresholdDocCount} docs, growth-rate ${this.growthRateThresholdPct}%/poll`,
|
|
98
|
+
);
|
|
99
|
+
} catch (err: unknown) {
|
|
100
|
+
DyFM_Log.warn('[DyNTS_CollectionGrowthMonitor] install failed (non-fatal):', err);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pure decision core (unit-tested): given a fresh count + the previous snapshot, return the alerts
|
|
106
|
+
* to raise. NEVER does I/O. A `size` alert fires when `count > sizeThreshold`; a `growth` alert
|
|
107
|
+
* fires when the collection grew by more than `growthRatePct` since the last snapshot (needs a
|
|
108
|
+
* non-zero previous count).
|
|
109
|
+
*/
|
|
110
|
+
static evaluateGrowth(
|
|
111
|
+
dataName: string,
|
|
112
|
+
count: number,
|
|
113
|
+
last: CollectionSnapshot | undefined,
|
|
114
|
+
sizeThreshold: number,
|
|
115
|
+
growthRatePct: number,
|
|
116
|
+
): DyNTS_CollectionGrowthAlert[] {
|
|
117
|
+
const alerts: DyNTS_CollectionGrowthAlert[] = [];
|
|
118
|
+
|
|
119
|
+
if (count > sizeThreshold) {
|
|
120
|
+
alerts.push({
|
|
121
|
+
kind: 'size',
|
|
122
|
+
dataName: dataName,
|
|
123
|
+
message:
|
|
124
|
+
`Collection "${dataName}" has ${count} documents (> ${sizeThreshold} threshold) — ` +
|
|
125
|
+
`unbounded-growth risk. Consider declaring \`retention\` on this model (FR-258 / SR-1).`,
|
|
126
|
+
detail: { collection: dataName, docCount: count, threshold: sizeThreshold },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (last && last.docCount > 0) {
|
|
131
|
+
const growthPct: number = ((count - last.docCount) / last.docCount) * 100;
|
|
132
|
+
if (growthPct > growthRatePct) {
|
|
133
|
+
alerts.push({
|
|
134
|
+
kind: 'growth',
|
|
135
|
+
dataName: dataName,
|
|
136
|
+
message:
|
|
137
|
+
`Collection "${dataName}" grew ${Math.round(growthPct)}% since the last poll ` +
|
|
138
|
+
`(${last.docCount} → ${count} docs) — fast unbounded growth.`,
|
|
139
|
+
detail: { collection: dataName, oldCount: last.docCount, newCount: count, growthPct: Math.round(growthPct) },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return alerts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One poll cycle: sample every registered collection's `estimatedDocumentCount()` (scan-free) and
|
|
149
|
+
* raise cooldown-gated warnings. Fully non-fatal — a failure on one collection never aborts the rest.
|
|
150
|
+
*/
|
|
151
|
+
private async poll(): Promise<void> {
|
|
152
|
+
try {
|
|
153
|
+
const services: ReturnType<typeof DyNTS_GlobalService.getAllDBServices> = DyNTS_GlobalService.getAllDBServices();
|
|
154
|
+
const nowMs: number = Date.now();
|
|
155
|
+
|
|
156
|
+
for (const service of services) {
|
|
157
|
+
const dataName: string | undefined = service?.dataParams?.dataName;
|
|
158
|
+
const model: { estimatedDocumentCount?: () => Promise<number> } | undefined =
|
|
159
|
+
service?.dataModel as unknown as { estimatedDocumentCount?: () => Promise<number> };
|
|
160
|
+
if (!dataName || !model || typeof model.estimatedDocumentCount !== 'function') { continue; }
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const count: number = await model.estimatedDocumentCount();
|
|
164
|
+
const last: CollectionSnapshot | undefined = this.lastSnapshotByCollection.get(dataName);
|
|
165
|
+
|
|
166
|
+
const alerts: DyNTS_CollectionGrowthAlert[] = DyNTS_CollectionGrowthMonitor.evaluateGrowth(
|
|
167
|
+
dataName, count, last, this.sizeThresholdDocCount, this.growthRateThresholdPct,
|
|
168
|
+
);
|
|
169
|
+
for (const alert of alerts) {
|
|
170
|
+
this.emitAlert(alert, nowMs);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this.lastSnapshotByCollection.set(dataName, { docCount: count, atMs: nowMs });
|
|
174
|
+
} catch (collErr: unknown) {
|
|
175
|
+
DyFM_Log.warn(
|
|
176
|
+
`[DyNTS_CollectionGrowthMonitor] poll for "${dataName}" failed (non-fatal): ` +
|
|
177
|
+
`${collErr instanceof Error ? collErr.message : String(collErr)}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch (err: unknown) {
|
|
182
|
+
DyFM_Log.warn('[DyNTS_CollectionGrowthMonitor] poll failed (non-fatal):', err);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Raise one alert to the Errors-sink, gated by the per-(collection,kind) cooldown to avoid flooding. */
|
|
187
|
+
private emitAlert(alert: DyNTS_CollectionGrowthAlert, nowMs: number): void {
|
|
188
|
+
const key: string = `${alert.dataName}:${alert.kind}`;
|
|
189
|
+
const lastAt: number | undefined = this.lastAlertAtByKey.get(key);
|
|
190
|
+
if (lastAt !== undefined && nowMs - lastAt < this.reAlertCooldownMs) { return; }
|
|
191
|
+
this.lastAlertAtByKey.set(key, nowMs);
|
|
192
|
+
|
|
193
|
+
DyFM_Log.warn(`[DyNTS_CollectionGrowthMonitor] ${alert.message}`);
|
|
194
|
+
DyNTS_GlobalService.globalErrorHandler?.(
|
|
195
|
+
new DyFM_Error({
|
|
196
|
+
errorCode: `${DyNTS_global_settings.systemShortCodeName ?? 'DyNTS'}|DyNTS-CGM-${alert.kind === 'size' ? 'SIZE' : 'GROWTH'}`,
|
|
197
|
+
message: alert.message,
|
|
198
|
+
level: DyFM_ErrorLevel.warning,
|
|
199
|
+
additionalContent: alert.detail,
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Test-only teardown: stop the timer + clear state so specs don't leak between runs. */
|
|
205
|
+
_teardownForTesting(): void {
|
|
206
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
207
|
+
this.installed = false;
|
|
208
|
+
this.lastSnapshotByCollection.clear();
|
|
209
|
+
this.lastAlertAtByKey.clear();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -49,8 +49,20 @@ export class DyNTS_GlobalService extends DyNTS_SingletonService {
|
|
|
49
49
|
static get upSince(): Date {
|
|
50
50
|
return this._upSince;
|
|
51
51
|
}
|
|
52
|
-
static get upTime(): number {
|
|
53
|
-
return +new Date() - +this._upSince;
|
|
52
|
+
static get upTime(): number {
|
|
53
|
+
return +new Date() - +this._upSince;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* FR-258 / SR-2 — enumerate every registered DB service (one per data-model, including the
|
|
58
|
+
* auto-created `_archived` siblings). Used by the DyNTS_App startup TTL-installer to find the
|
|
59
|
+
* models that declared `retention` and create their MongoDB TTL index. Returns an empty array
|
|
60
|
+
* if services aren't set up yet (safe to call early).
|
|
61
|
+
*/
|
|
62
|
+
static getAllDBServices(): DyNTS_DBService<any>[] {
|
|
63
|
+
const collection: DyNTS_Service_Collection<DyNTS_DBService<any>> | undefined =
|
|
64
|
+
this.instance?.dbServiceCollection;
|
|
65
|
+
return collection ? Object.values(collection) : [];
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
authService: DyNTS_AuthService;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { DyNTS_App, DyNTS_TtlIndexAction, DyNTS_TtlIndexCollection, DyNTS_TtlIndexInfo } from './app.server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FR-258 / SR-2 — unit tests for the retention TTL-index reconciliation logic
|
|
5
|
+
* (`DyNTS_App.ensureRetentionTtlIndex`). Pure with respect to a mockable native-driver collection;
|
|
6
|
+
* no live MongoDB required. Covers create / no-op / update(drop+recreate) and edge cases.
|
|
7
|
+
*/
|
|
8
|
+
describe('| DyNTS_App.ensureRetentionTtlIndex (FR-258 / SR-2)', () => {
|
|
9
|
+
|
|
10
|
+
interface MockColl {
|
|
11
|
+
coll: DyNTS_TtlIndexCollection;
|
|
12
|
+
created: { keys: Record<string, number>; options: { expireAfterSeconds: number } }[];
|
|
13
|
+
dropped: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeMockCollection(existing: DyNTS_TtlIndexInfo[], opts?: { noIndexesFn?: boolean }): MockColl {
|
|
17
|
+
const created: { keys: Record<string, number>; options: { expireAfterSeconds: number } }[] = [];
|
|
18
|
+
const dropped: string[] = [];
|
|
19
|
+
const coll: DyNTS_TtlIndexCollection = {
|
|
20
|
+
createIndex: async (keys: Record<string, number>, options: { expireAfterSeconds: number }): Promise<string> => {
|
|
21
|
+
created.push({ keys: keys, options: options });
|
|
22
|
+
return '__created_1';
|
|
23
|
+
},
|
|
24
|
+
dropIndex: async (name: string): Promise<void> => { dropped.push(name); },
|
|
25
|
+
};
|
|
26
|
+
if (!opts?.noIndexesFn) {
|
|
27
|
+
coll.indexes = async (): Promise<DyNTS_TtlIndexInfo[]> => existing;
|
|
28
|
+
}
|
|
29
|
+
return { coll: coll, created: created, dropped: dropped };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TTL_14D: number = 14 * 86400;
|
|
33
|
+
|
|
34
|
+
it('| no existing __created index → creates a TTL index (action "created")', async () => {
|
|
35
|
+
const m: MockColl = makeMockCollection([]);
|
|
36
|
+
const action: DyNTS_TtlIndexAction = await DyNTS_App.ensureRetentionTtlIndex(m.coll, TTL_14D);
|
|
37
|
+
expect(action).toBe('created');
|
|
38
|
+
expect(m.created.length).toBe(1);
|
|
39
|
+
expect(m.created[0].keys).toEqual({ __created: 1 });
|
|
40
|
+
expect(m.created[0].options).toEqual({ expireAfterSeconds: TTL_14D });
|
|
41
|
+
expect(m.dropped.length).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('| existing index with the SAME ttl → no-op (action "noop", no create, no drop)', async () => {
|
|
45
|
+
const m: MockColl = makeMockCollection([
|
|
46
|
+
{ name: '__created_1', key: { __created: 1 }, expireAfterSeconds: TTL_14D },
|
|
47
|
+
]);
|
|
48
|
+
const action: DyNTS_TtlIndexAction = await DyNTS_App.ensureRetentionTtlIndex(m.coll, TTL_14D);
|
|
49
|
+
expect(action).toBe('noop');
|
|
50
|
+
expect(m.created.length).toBe(0);
|
|
51
|
+
expect(m.dropped.length).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('| existing index with a DIFFERENT ttl → drop + recreate (action "updated")', async () => {
|
|
55
|
+
const m: MockColl = makeMockCollection([
|
|
56
|
+
{ name: '__created_1', key: { __created: 1 }, expireAfterSeconds: 999 },
|
|
57
|
+
]);
|
|
58
|
+
const action: DyNTS_TtlIndexAction = await DyNTS_App.ensureRetentionTtlIndex(m.coll, TTL_14D);
|
|
59
|
+
expect(action).toBe('updated');
|
|
60
|
+
expect(m.dropped).toEqual(['__created_1']);
|
|
61
|
+
expect(m.created.length).toBe(1);
|
|
62
|
+
expect(m.created[0].options).toEqual({ expireAfterSeconds: TTL_14D });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('| existing NON-TTL __created index (no expireAfterSeconds) → drop + recreate as TTL', async () => {
|
|
66
|
+
const m: MockColl = makeMockCollection([
|
|
67
|
+
{ name: '__created_1', key: { __created: 1 } },
|
|
68
|
+
]);
|
|
69
|
+
const action: DyNTS_TtlIndexAction = await DyNTS_App.ensureRetentionTtlIndex(m.coll, 604800);
|
|
70
|
+
expect(action).toBe('updated');
|
|
71
|
+
expect(m.dropped).toEqual(['__created_1']);
|
|
72
|
+
expect(m.created.length).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('| ignores indexes on OTHER fields — only reconciles { __created: 1 }', async () => {
|
|
76
|
+
const m: MockColl = makeMockCollection([
|
|
77
|
+
{ name: 'project_1', key: { project: 1 }, expireAfterSeconds: 50 },
|
|
78
|
+
{ name: '_id_', key: { _id: 1 } },
|
|
79
|
+
]);
|
|
80
|
+
const action: DyNTS_TtlIndexAction = await DyNTS_App.ensureRetentionTtlIndex(m.coll, 86400);
|
|
81
|
+
expect(action).toBe('created');
|
|
82
|
+
expect(m.created.length).toBe(1);
|
|
83
|
+
expect(m.dropped.length).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('| no indexes() method available → treats as none and creates', async () => {
|
|
87
|
+
const m: MockColl = makeMockCollection([], { noIndexesFn: true });
|
|
88
|
+
const action: DyNTS_TtlIndexAction = await DyNTS_App.ensureRetentionTtlIndex(m.coll, 86400);
|
|
89
|
+
expect(action).toBe('created');
|
|
90
|
+
expect(m.created.length).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('| indexes() rejecting → treated as empty (resilient), still creates', async () => {
|
|
94
|
+
const created: { keys: Record<string, number>; options: { expireAfterSeconds: number } }[] = [];
|
|
95
|
+
const coll: DyNTS_TtlIndexCollection = {
|
|
96
|
+
indexes: async (): Promise<DyNTS_TtlIndexInfo[]> => { throw new Error('boom'); },
|
|
97
|
+
createIndex: async (keys: Record<string, number>, options: { expireAfterSeconds: number }): Promise<string> => {
|
|
98
|
+
created.push({ keys: keys, options: options });
|
|
99
|
+
return 'x';
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const action: DyNTS_TtlIndexAction = await DyNTS_App.ensureRetentionTtlIndex(coll, 86400);
|
|
103
|
+
expect(action).toBe('created');
|
|
104
|
+
expect(created.length).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
});
|