@mxtommy/kip 4.5.2 → 4.6.0
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/CHANGELOG.md +12 -3
- package/package.json +13 -15
- package/plugin/history-series.service.js +14 -24
- package/plugin/index.js +139 -95
- package/plugin/{duckdb-parquet-storage.service.js → sqlite-history-storage.service.js} +330 -503
- package/public/assets/help-docs/widget-historical-series.md +5 -5
- package/public/{chunk-TVNXBPFF.js → chunk-356CW47X.js} +1 -1
- package/public/{chunk-NFJ4RQSE.js → chunk-3JA4CQ7T.js} +1 -1
- package/public/{chunk-VXTTEFRP.js → chunk-5SAXWR6Z.js} +8 -8
- package/public/{chunk-67V4XHCY.js → chunk-6A4NRSCL.js} +1 -1
- package/public/{chunk-P7JKENHI.js → chunk-AC6VD2FN.js} +1 -1
- package/public/{chunk-TBNKOU7M.js → chunk-B4NYOD6L.js} +1 -1
- package/public/{chunk-WH5CIUSB.js → chunk-BGGO4PGD.js} +1 -1
- package/public/{chunk-KQEEYPK3.js → chunk-BMHMHQFO.js} +1 -1
- package/public/{chunk-RCYOZLZB.js → chunk-CSIELI2Z.js} +2 -2
- package/public/{chunk-R36UY4Q4.js → chunk-CYTLQDGF.js} +1 -1
- package/public/{chunk-YI3MZWRZ.js → chunk-HSKVTFFQ.js} +1 -1
- package/public/{chunk-IH4CEW4C.js → chunk-MDNGWQNG.js} +8 -8
- package/public/{chunk-VPF5756E.js → chunk-MGLD6QDJ.js} +1 -1
- package/public/{chunk-P4CRTB7N.js → chunk-NJISHUGY.js} +1 -1
- package/public/{chunk-ISF5E3CX.js → chunk-P3M6SJQT.js} +11 -11
- package/public/{chunk-WQSJFJLW.js → chunk-POMIQBAL.js} +2 -2
- package/public/{chunk-SJFJEOSG.js → chunk-PPF5S5CV.js} +1 -1
- package/public/{chunk-OPTBDYBL.js → chunk-PUPM3HUQ.js} +1 -1
- package/public/chunk-PZ6I6W3H.js +16 -0
- package/public/{chunk-VXCYPAWR.js → chunk-QU3JR4YV.js} +1 -1
- package/public/{chunk-Q2ANAJAD.js → chunk-SUWMN3AE.js} +1 -1
- package/public/{chunk-CD5TQSCS.js → chunk-UYHRT3PR.js} +1 -1
- package/public/{chunk-FZFDGAQO.js → chunk-WJFXI5PQ.js} +1 -1
- package/public/{chunk-I4SJ5UNN.js → chunk-ZXO4VMEH.js} +1 -1
- package/public/{chunk-XBSU7OGT.js → chunk-ZY3U4H4Z.js} +1 -1
- package/public/index.html +1 -1
- package/public/{main-B6TXB3EB.js → main-I33LH3HC.js} +1 -1
- package/plugin/plugin-auth.service.js +0 -75
- package/public/chunk-BTFZS2TW.js +0 -16
|
@@ -1,46 +1,71 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
36
|
+
exports.SqliteHistoryStorageService = void 0;
|
|
4
37
|
const fs_1 = require("fs");
|
|
5
38
|
const path_1 = require("path");
|
|
6
|
-
const node_api_1 = require("@duckdb/node-api");
|
|
7
|
-
const parquetjs_1 = require("@dsnp/parquetjs");
|
|
8
|
-
/**
|
|
9
|
-
* Provides DuckDB storage and Parquet flush support for captured history samples.
|
|
10
|
-
*/
|
|
11
39
|
const DEFAULT_STORAGE_CONFIG = {
|
|
12
|
-
engine: '
|
|
13
|
-
databaseFile: 'plugin-config-data/kip/historicalData/kip-history.
|
|
14
|
-
|
|
15
|
-
flushIntervalMs: 30_000,
|
|
16
|
-
parquetWindowMs: 60 * 60_000,
|
|
17
|
-
parquetCompression: 'snappy'
|
|
40
|
+
engine: 'node:sqlite',
|
|
41
|
+
databaseFile: 'plugin-config-data/kip/historicalData/kip-history.sqlite',
|
|
42
|
+
flushIntervalMs: 30_000
|
|
18
43
|
};
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Provides node:sqlite storage for captured history samples.
|
|
46
|
+
*/
|
|
47
|
+
class SqliteHistoryStorageService {
|
|
22
48
|
static EIGHT_HOURS_INTERVAL = 8 * 60 * 60 * 1000;
|
|
23
|
-
vacuumJob = null;
|
|
24
|
-
// 4 hour job interval (4 hours)
|
|
25
49
|
static FOUR_HOURS_INTERVAL = 4 * 60 * 60 * 1000;
|
|
26
|
-
|
|
27
|
-
// Stale series cleanup interval (6 months)
|
|
28
|
-
static STALE_SERIES_AGE_MS = 180 * 24 * 60 * 60 * 1000; // 6 months
|
|
29
|
-
staleSeriesCleanupJob = null;
|
|
50
|
+
static STALE_SERIES_AGE_MS = 180 * 24 * 60 * 60 * 1000;
|
|
30
51
|
static PRUNE_BATCH_SIZE = 10_000;
|
|
52
|
+
config = { ...DEFAULT_STORAGE_CONFIG };
|
|
53
|
+
dataDirPath = null;
|
|
31
54
|
logger = {
|
|
32
55
|
debug: () => undefined,
|
|
33
56
|
error: () => undefined
|
|
34
57
|
};
|
|
35
58
|
db = null;
|
|
36
|
-
connection = null;
|
|
37
59
|
pendingRows = [];
|
|
38
|
-
pendingRangesBySeriesId = new Map();
|
|
39
60
|
lastInitError = null;
|
|
40
61
|
lifecycleToken = 0;
|
|
41
62
|
initialized = false;
|
|
63
|
+
runtimeAvailable = true;
|
|
42
64
|
maintenanceInProgress = false;
|
|
43
65
|
flushInProgress = false;
|
|
66
|
+
vacuumJob = null;
|
|
67
|
+
pruneJob = null;
|
|
68
|
+
staleSeriesCleanupJob = null;
|
|
44
69
|
/**
|
|
45
70
|
* Sets logger callbacks used by the storage service.
|
|
46
71
|
*
|
|
@@ -56,7 +81,7 @@ class DuckDbParquetStorageService {
|
|
|
56
81
|
/**
|
|
57
82
|
* Applies the fixed storage backend configuration.
|
|
58
83
|
*
|
|
59
|
-
* @returns {
|
|
84
|
+
* @returns {ISqliteHistoryStorageConfig} Fixed storage configuration.
|
|
60
85
|
*
|
|
61
86
|
* @example
|
|
62
87
|
* const cfg = storage.configure();
|
|
@@ -64,229 +89,100 @@ class DuckDbParquetStorageService {
|
|
|
64
89
|
*/
|
|
65
90
|
configure() {
|
|
66
91
|
this.initialized = false;
|
|
67
|
-
|
|
92
|
+
const databaseFile = this.dataDirPath
|
|
93
|
+
? (0, path_1.join)(this.dataDirPath, 'historicalData', 'kip-history.sqlite')
|
|
94
|
+
: DEFAULT_STORAGE_CONFIG.databaseFile;
|
|
95
|
+
this.config = {
|
|
96
|
+
...DEFAULT_STORAGE_CONFIG,
|
|
97
|
+
databaseFile
|
|
98
|
+
};
|
|
68
99
|
return this.config;
|
|
69
100
|
}
|
|
70
101
|
/**
|
|
71
|
-
*
|
|
102
|
+
* Sets the base directory for persisted history data.
|
|
103
|
+
*
|
|
104
|
+
* @param {string | null} baseDir Absolute directory path for plugin data.
|
|
105
|
+
* @returns {void}
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* storage.setDataDirPath('/var/lib/signalk');
|
|
109
|
+
*/
|
|
110
|
+
setDataDirPath(baseDir) {
|
|
111
|
+
this.dataDirPath = typeof baseDir === 'string' && baseDir.trim() ? baseDir.trim() : null;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Updates runtime availability of node:sqlite, clearing stored errors when enabled.
|
|
115
|
+
*
|
|
116
|
+
* @param {boolean} available Whether node:sqlite is available at runtime.
|
|
117
|
+
* @param {string | undefined} errorMessage Optional runtime error message.
|
|
118
|
+
* @returns {void}
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* storage.setRuntimeAvailability(false, 'node:sqlite unavailable');
|
|
122
|
+
*/
|
|
123
|
+
setRuntimeAvailability(available, errorMessage) {
|
|
124
|
+
this.runtimeAvailable = available;
|
|
125
|
+
this.lastInitError = available ? null : (errorMessage ?? 'node:sqlite unavailable');
|
|
126
|
+
if (!available) {
|
|
127
|
+
this.initialized = false;
|
|
128
|
+
this.db = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Initializes node:sqlite storage.
|
|
72
133
|
*
|
|
73
|
-
* @returns {Promise<boolean>} True when
|
|
134
|
+
* @returns {Promise<boolean>} True when node:sqlite is initialized and ready.
|
|
74
135
|
*
|
|
75
136
|
* @example
|
|
76
137
|
* const ready = await storage.initialize();
|
|
77
138
|
*/
|
|
78
139
|
async initialize() {
|
|
79
|
-
if (!this.
|
|
140
|
+
if (!this.isSqliteEnabled() || !this.runtimeAvailable) {
|
|
80
141
|
return false;
|
|
81
142
|
}
|
|
82
143
|
this.initialized = false;
|
|
83
144
|
this.lifecycleToken += 1;
|
|
84
145
|
try {
|
|
146
|
+
const sqlite = await this.loadSqliteModule();
|
|
147
|
+
if (!sqlite?.DatabaseSync) {
|
|
148
|
+
throw new Error('node:sqlite DatabaseSync is unavailable');
|
|
149
|
+
}
|
|
85
150
|
const dbPath = (0, path_1.resolve)(this.config.databaseFile);
|
|
86
151
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(dbPath), { recursive: true });
|
|
87
|
-
|
|
88
|
-
this.db =
|
|
89
|
-
this.
|
|
152
|
+
this.db = new sqlite.DatabaseSync(dbPath, { timeout: 5000 });
|
|
153
|
+
this.db.exec('PRAGMA journal_mode=WAL;');
|
|
154
|
+
this.db.exec('PRAGMA synchronous=NORMAL;');
|
|
155
|
+
this.db.exec('PRAGMA temp_store=MEMORY;');
|
|
156
|
+
this.db.exec('PRAGMA foreign_keys=ON;');
|
|
90
157
|
await this.createCoreTables();
|
|
91
158
|
await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_series_scope_ts ON history_samples(series_id, ts_ms)');
|
|
92
159
|
await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_series_scope_id ON history_series(series_id)');
|
|
93
160
|
await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_context_path_ts ON history_samples(context, path, ts_ms)');
|
|
94
161
|
await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_ts_path ON history_samples(ts_ms, path)');
|
|
95
162
|
await this.runSql('CREATE INDEX IF NOT EXISTS idx_history_samples_scope_ts_context ON history_samples(ts_ms, context)');
|
|
96
|
-
this.logger.debug(`[SERIES STORAGE]
|
|
163
|
+
this.logger.debug(`[SERIES STORAGE] node:sqlite initialized at ${dbPath}`);
|
|
97
164
|
this.lastInitError = null;
|
|
98
165
|
this.initialized = true;
|
|
99
|
-
// Start VACUUM job
|
|
100
166
|
this.startVacuumJob();
|
|
101
|
-
// Start prune job
|
|
102
167
|
this.startPruneJob();
|
|
103
|
-
// Start stale series cleanup job
|
|
104
168
|
this.startStaleSeriesCleanupJob();
|
|
105
169
|
return true;
|
|
106
170
|
}
|
|
107
171
|
catch (error) {
|
|
108
172
|
const message = error?.message ?? String(error);
|
|
109
173
|
this.lastInitError = message;
|
|
110
|
-
this.logger.error(`[SERIES STORAGE]
|
|
111
|
-
this.logger.error('[SERIES STORAGE] DuckDB Node API is required. Install runtime dependency with: npm i @duckdb/node-api in the installed plugin directory, then restart Signal K.');
|
|
112
|
-
this.connection = null;
|
|
174
|
+
this.logger.error(`[SERIES STORAGE] node:sqlite initialization failed: ${message}`);
|
|
113
175
|
this.db = null;
|
|
114
176
|
this.pendingRows = [];
|
|
115
|
-
this.pendingRangesBySeriesId.clear();
|
|
116
177
|
this.initialized = false;
|
|
117
178
|
this.stopVacuumJob();
|
|
179
|
+
this.stopPruneJob();
|
|
180
|
+
this.stopStaleSeriesCleanupJob();
|
|
118
181
|
return false;
|
|
119
182
|
}
|
|
120
183
|
}
|
|
121
184
|
/**
|
|
122
|
-
*
|
|
123
|
-
*/
|
|
124
|
-
startVacuumJob() {
|
|
125
|
-
this.stopVacuumJob();
|
|
126
|
-
if (!this.isDuckDbParquetReady() || !this.connection)
|
|
127
|
-
return;
|
|
128
|
-
this.vacuumJob = setInterval(() => {
|
|
129
|
-
if (this.shouldSkipMaintenance()) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
void this.runWithMaintenanceLock('vacuum', async () => {
|
|
133
|
-
this.logger.debug('[SERIES STORAGE] Running scheduled DuckDB VACUUM');
|
|
134
|
-
await this.runSql('VACUUM;');
|
|
135
|
-
}).catch(err => {
|
|
136
|
-
this.logger.error(`[SERIES STORAGE] VACUUM failed: ${err?.message ?? err}`);
|
|
137
|
-
});
|
|
138
|
-
}, DuckDbParquetStorageService.EIGHT_HOURS_INTERVAL);
|
|
139
|
-
this.vacuumJob.unref?.();
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Stops the scheduled VACUUM job if running.
|
|
143
|
-
*/
|
|
144
|
-
stopVacuumJob() {
|
|
145
|
-
if (this.vacuumJob) {
|
|
146
|
-
clearInterval(this.vacuumJob);
|
|
147
|
-
this.vacuumJob = null;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Starts the prune job for expired and orphaned samples.
|
|
152
|
-
*/
|
|
153
|
-
startPruneJob() {
|
|
154
|
-
this.stopPruneJob();
|
|
155
|
-
if (!this.isDuckDbParquetReady() || !this.connection)
|
|
156
|
-
return;
|
|
157
|
-
this.pruneJob = setInterval(async () => {
|
|
158
|
-
if (this.shouldSkipMaintenance()) {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
try {
|
|
162
|
-
await this.runWithMaintenanceLock('prune', async () => {
|
|
163
|
-
this.logger.debug('[SERIES STORAGE] Running scheduled prune of expired and orphaned samples');
|
|
164
|
-
const expired = await this.pruneExpiredSamples(Date.now(), this.lifecycleToken);
|
|
165
|
-
const orphaned = await this.pruneOrphanedSamples(this.lifecycleToken);
|
|
166
|
-
const parquetRemoved = await this.pruneParquetFilesByRetention(Date.now());
|
|
167
|
-
this.logger.debug(`[SERIES STORAGE] Pruned ${expired} expired and ${orphaned} orphaned samples`);
|
|
168
|
-
if (parquetRemoved > 0) {
|
|
169
|
-
this.logger.debug(`[SERIES STORAGE] Pruned ${parquetRemoved} parquet files/directories`);
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
this.logger.error(`[SERIES STORAGE] Prune failed: ${err?.message ?? err}`);
|
|
175
|
-
}
|
|
176
|
-
}, DuckDbParquetStorageService.FOUR_HOURS_INTERVAL);
|
|
177
|
-
this.pruneJob.unref?.();
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Stops the scheduled prune job if running.
|
|
181
|
-
*/
|
|
182
|
-
stopPruneJob() {
|
|
183
|
-
if (this.pruneJob) {
|
|
184
|
-
clearInterval(this.pruneJob);
|
|
185
|
-
this.pruneJob = null;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* Starts the scheduled job to delete series not reconciled in the last 6 months.
|
|
190
|
-
*/
|
|
191
|
-
startStaleSeriesCleanupJob() {
|
|
192
|
-
this.stopStaleSeriesCleanupJob();
|
|
193
|
-
if (!this.isDuckDbParquetReady() || !this.connection)
|
|
194
|
-
return;
|
|
195
|
-
this.staleSeriesCleanupJob = setInterval(async () => {
|
|
196
|
-
if (this.shouldSkipMaintenance()) {
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
try {
|
|
200
|
-
await this.runWithMaintenanceLock('stale-cleanup', async () => {
|
|
201
|
-
const cutoff = Date.now() - DuckDbParquetStorageService.STALE_SERIES_AGE_MS;
|
|
202
|
-
this.logger.debug(`[SERIES STORAGE] Running scheduled stale series cleanup (cutoff: ${new Date(cutoff).toISOString()})`);
|
|
203
|
-
const deleted = await this.deleteStaleSeries(cutoff);
|
|
204
|
-
if (deleted > 0) {
|
|
205
|
-
this.logger.debug(`[SERIES STORAGE] Deleted ${deleted} series not reconciled in the last 6 months`);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
catch (err) {
|
|
210
|
-
this.logger.error(`[SERIES STORAGE] Stale series cleanup failed: ${err?.message ?? err}`);
|
|
211
|
-
}
|
|
212
|
-
}, DuckDbParquetStorageService.EIGHT_HOURS_INTERVAL);
|
|
213
|
-
this.staleSeriesCleanupJob.unref?.();
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Deletes series not reconciled since the given cutoff timestamp.
|
|
217
|
-
* @param {number} cutoffMs - Milliseconds since epoch; series with reconcile_ts < cutoffMs will be deleted.
|
|
218
|
-
* @returns {Promise<number>} Number of deleted series.
|
|
219
|
-
*
|
|
220
|
-
* @example
|
|
221
|
-
* const deleted = await storage.deleteStaleSeries(Date.now() - 180 * 24 * 60 * 60 * 1000);
|
|
222
|
-
*/
|
|
223
|
-
async deleteStaleSeries(cutoffMs) {
|
|
224
|
-
if (!this.isDuckDbParquetEnabled() || !this.connection) {
|
|
225
|
-
return 0;
|
|
226
|
-
}
|
|
227
|
-
// Find series to delete
|
|
228
|
-
const rows = await this.querySql(`
|
|
229
|
-
SELECT series_id FROM history_series
|
|
230
|
-
WHERE reconcile_ts IS NULL OR reconcile_ts < ${Math.trunc(cutoffMs)}
|
|
231
|
-
`);
|
|
232
|
-
const ids = rows.map(r => r.series_id);
|
|
233
|
-
if (ids.length === 0)
|
|
234
|
-
return 0;
|
|
235
|
-
for (const id of ids) {
|
|
236
|
-
await this.deleteSeriesDefinition(id);
|
|
237
|
-
}
|
|
238
|
-
return ids.length;
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* Stops the scheduled stale series cleanup job if running.
|
|
242
|
-
*/
|
|
243
|
-
stopStaleSeriesCleanupJob() {
|
|
244
|
-
if (this.staleSeriesCleanupJob) {
|
|
245
|
-
clearInterval(this.staleSeriesCleanupJob);
|
|
246
|
-
this.staleSeriesCleanupJob = null;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
shouldSkipMaintenance() {
|
|
250
|
-
if (!this.isDuckDbParquetReady() || !this.connection) {
|
|
251
|
-
return true;
|
|
252
|
-
}
|
|
253
|
-
if (this.maintenanceInProgress || this.flushInProgress) {
|
|
254
|
-
return true;
|
|
255
|
-
}
|
|
256
|
-
if (this.pendingRows.length > 0) {
|
|
257
|
-
return true;
|
|
258
|
-
}
|
|
259
|
-
return false;
|
|
260
|
-
}
|
|
261
|
-
async runWithMaintenanceLock(label, task) {
|
|
262
|
-
if (this.maintenanceInProgress) {
|
|
263
|
-
this.logger.debug(`[SERIES STORAGE] Skipping ${label} (maintenance already running)`);
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
this.maintenanceInProgress = true;
|
|
267
|
-
const startedAt = Date.now();
|
|
268
|
-
try {
|
|
269
|
-
await task();
|
|
270
|
-
const elapsedMs = Date.now() - startedAt;
|
|
271
|
-
this.logger.debug(`[SERIES STORAGE] ${label} completed in ${elapsedMs}ms`);
|
|
272
|
-
}
|
|
273
|
-
finally {
|
|
274
|
-
this.maintenanceInProgress = false;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Returns the active storage configuration.
|
|
279
|
-
*
|
|
280
|
-
* @returns {IDuckDbParquetStorageConfig} Current storage configuration.
|
|
281
|
-
*
|
|
282
|
-
* @example
|
|
283
|
-
* const cfg = storage.getConfig();
|
|
284
|
-
*/
|
|
285
|
-
getConfig() {
|
|
286
|
-
return this.config;
|
|
287
|
-
}
|
|
288
|
-
/**
|
|
289
|
-
* Returns last DuckDB initialization error when initialization failed.
|
|
185
|
+
* Returns last node:sqlite initialization error when initialization failed.
|
|
290
186
|
*
|
|
291
187
|
* @returns {string | null} Initialization error text or null.
|
|
292
188
|
*
|
|
@@ -297,38 +193,34 @@ class DuckDbParquetStorageService {
|
|
|
297
193
|
return this.lastInitError;
|
|
298
194
|
}
|
|
299
195
|
/**
|
|
300
|
-
* Indicates whether
|
|
196
|
+
* Indicates whether node:sqlite mode is selected.
|
|
301
197
|
*
|
|
302
|
-
* @returns {boolean} True when the selected engine is `
|
|
198
|
+
* @returns {boolean} True when the selected engine is `node:sqlite`.
|
|
303
199
|
*
|
|
304
200
|
* @example
|
|
305
|
-
* if (storage.
|
|
306
|
-
* console.log('
|
|
201
|
+
* if (storage.isSqliteEnabled()) {
|
|
202
|
+
* console.log('node:sqlite mode enabled');
|
|
307
203
|
* }
|
|
308
204
|
*/
|
|
309
|
-
|
|
310
|
-
return this.config.engine === '
|
|
205
|
+
isSqliteEnabled() {
|
|
206
|
+
return this.config.engine === 'node:sqlite';
|
|
311
207
|
}
|
|
312
208
|
/**
|
|
313
|
-
* Indicates whether
|
|
209
|
+
* Indicates whether node:sqlite mode is initialized and ready.
|
|
314
210
|
*
|
|
315
|
-
* @returns {boolean} True when
|
|
211
|
+
* @returns {boolean} True when node:sqlite mode is selected and an active connection exists.
|
|
316
212
|
*
|
|
317
213
|
* @example
|
|
318
|
-
* if (storage.
|
|
319
|
-
* console.log('
|
|
214
|
+
* if (storage.isSqliteReady()) {
|
|
215
|
+
* console.log('node:sqlite ready');
|
|
320
216
|
* }
|
|
321
217
|
*/
|
|
322
|
-
|
|
323
|
-
return this.
|
|
218
|
+
isSqliteReady() {
|
|
219
|
+
return this.isSqliteEnabled() && this.initialized && this.db !== null && this.runtimeAvailable;
|
|
324
220
|
}
|
|
325
221
|
/**
|
|
326
222
|
* Returns the current storage lifecycle token.
|
|
327
223
|
*
|
|
328
|
-
* The token changes whenever a new initialization attempt starts and can be
|
|
329
|
-
* used by callers to scope async stop operations (flush/close) so stale work
|
|
330
|
-
* does not affect a newer startup session.
|
|
331
|
-
*
|
|
332
224
|
* @returns {number} Current lifecycle token.
|
|
333
225
|
*
|
|
334
226
|
* @example
|
|
@@ -347,30 +239,16 @@ class DuckDbParquetStorageService {
|
|
|
347
239
|
* storage.enqueueSample(sample);
|
|
348
240
|
*/
|
|
349
241
|
enqueueSample(sample) {
|
|
350
|
-
if (!this.
|
|
242
|
+
if (!this.isSqliteReady()) {
|
|
351
243
|
return;
|
|
352
244
|
}
|
|
353
245
|
this.pendingRows.push(sample);
|
|
354
|
-
const rangeKey = sample.seriesId;
|
|
355
|
-
const existing = this.pendingRangesBySeriesId.get(rangeKey);
|
|
356
|
-
if (!existing) {
|
|
357
|
-
this.pendingRangesBySeriesId.set(rangeKey, {
|
|
358
|
-
seriesId: sample.seriesId,
|
|
359
|
-
minTs: sample.timestamp,
|
|
360
|
-
maxTs: sample.timestamp
|
|
361
|
-
});
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
this.pendingRangesBySeriesId.set(rangeKey, {
|
|
365
|
-
seriesId: existing.seriesId,
|
|
366
|
-
minTs: Math.min(existing.minTs, sample.timestamp),
|
|
367
|
-
maxTs: Math.max(existing.maxTs, sample.timestamp)
|
|
368
|
-
});
|
|
369
246
|
}
|
|
370
247
|
/**
|
|
371
|
-
* Flushes queued samples into
|
|
248
|
+
* Flushes queued samples into node:sqlite.
|
|
372
249
|
*
|
|
373
|
-
* @
|
|
250
|
+
* @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale flushes.
|
|
251
|
+
* @returns {Promise<{ inserted: number; exported: number }>} Number of inserted rows (exported is always 0).
|
|
374
252
|
*
|
|
375
253
|
* @example
|
|
376
254
|
* const result = await storage.flush();
|
|
@@ -379,7 +257,7 @@ class DuckDbParquetStorageService {
|
|
|
379
257
|
if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
|
|
380
258
|
return { inserted: 0, exported: 0 };
|
|
381
259
|
}
|
|
382
|
-
if (!this.
|
|
260
|
+
if (!this.isSqliteEnabled() || !this.db || this.pendingRows.length === 0) {
|
|
383
261
|
return { inserted: 0, exported: 0 };
|
|
384
262
|
}
|
|
385
263
|
if (this.flushInProgress) {
|
|
@@ -387,35 +265,16 @@ class DuckDbParquetStorageService {
|
|
|
387
265
|
}
|
|
388
266
|
this.flushInProgress = true;
|
|
389
267
|
const rows = this.pendingRows;
|
|
390
|
-
const ranges = new Map(this.pendingRangesBySeriesId);
|
|
391
268
|
this.pendingRows = [];
|
|
392
|
-
this.pendingRangesBySeriesId.clear();
|
|
393
269
|
const startedAt = Date.now();
|
|
394
270
|
try {
|
|
395
271
|
await this.insertRows(rows);
|
|
396
|
-
let exported = 0;
|
|
397
|
-
for (const range of ranges.values()) {
|
|
398
|
-
await this.exportSeriesRange(range.seriesId, range.minTs, range.maxTs);
|
|
399
|
-
exported += 1;
|
|
400
|
-
}
|
|
401
272
|
const elapsedMs = Date.now() - startedAt;
|
|
402
|
-
this.logger.debug(`[SERIES STORAGE] flush inserted=${rows.length}
|
|
403
|
-
return { inserted: rows.length, exported };
|
|
273
|
+
this.logger.debug(`[SERIES STORAGE] flush inserted=${rows.length} durationMs=${elapsedMs}`);
|
|
274
|
+
return { inserted: rows.length, exported: 0 };
|
|
404
275
|
}
|
|
405
276
|
catch (error) {
|
|
406
277
|
this.pendingRows = [...rows, ...this.pendingRows];
|
|
407
|
-
ranges.forEach((range, rangeKey) => {
|
|
408
|
-
const current = this.pendingRangesBySeriesId.get(rangeKey);
|
|
409
|
-
if (!current) {
|
|
410
|
-
this.pendingRangesBySeriesId.set(rangeKey, range);
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
this.pendingRangesBySeriesId.set(rangeKey, {
|
|
414
|
-
seriesId: current.seriesId,
|
|
415
|
-
minTs: Math.min(current.minTs, range.minTs),
|
|
416
|
-
maxTs: Math.max(current.maxTs, range.maxTs)
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
278
|
throw error;
|
|
420
279
|
}
|
|
421
280
|
finally {
|
|
@@ -423,7 +282,7 @@ class DuckDbParquetStorageService {
|
|
|
423
282
|
}
|
|
424
283
|
}
|
|
425
284
|
/**
|
|
426
|
-
* Returns persisted series definitions from
|
|
285
|
+
* Returns persisted series definitions from node:sqlite.
|
|
427
286
|
*
|
|
428
287
|
* @returns {Promise<ISeriesDefinition[]>} Stored series definitions.
|
|
429
288
|
*
|
|
@@ -431,7 +290,7 @@ class DuckDbParquetStorageService {
|
|
|
431
290
|
* const series = await storage.getSeriesDefinitions();
|
|
432
291
|
*/
|
|
433
292
|
async getSeriesDefinitions() {
|
|
434
|
-
if (!this.
|
|
293
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
435
294
|
return [];
|
|
436
295
|
}
|
|
437
296
|
const rows = await this.querySql(`
|
|
@@ -448,7 +307,8 @@ class DuckDbParquetStorageService {
|
|
|
448
307
|
retention_duration_ms,
|
|
449
308
|
sample_time,
|
|
450
309
|
enabled,
|
|
451
|
-
methods_json
|
|
310
|
+
methods_json,
|
|
311
|
+
reconcile_ts
|
|
452
312
|
FROM history_series
|
|
453
313
|
ORDER BY series_id ASC
|
|
454
314
|
`);
|
|
@@ -465,11 +325,12 @@ class DuckDbParquetStorageService {
|
|
|
465
325
|
retentionDurationMs: this.toNumberOrUndefined(row.retention_duration_ms),
|
|
466
326
|
sampleTime: this.toNumberOrUndefined(row.sample_time),
|
|
467
327
|
enabled: this.toBoolean(row.enabled),
|
|
468
|
-
methods: this.parseMethods(row.methods_json)
|
|
328
|
+
methods: this.parseMethods(row.methods_json),
|
|
329
|
+
reconcileTs: this.toNumberOrUndefined(row.reconcile_ts)
|
|
469
330
|
}));
|
|
470
331
|
}
|
|
471
332
|
/**
|
|
472
|
-
* Persists one series definition in
|
|
333
|
+
* Persists one series definition in node:sqlite.
|
|
473
334
|
*
|
|
474
335
|
* @param {ISeriesDefinition} series Series definition to persist.
|
|
475
336
|
* @returns {Promise<void>}
|
|
@@ -478,7 +339,7 @@ class DuckDbParquetStorageService {
|
|
|
478
339
|
* await storage.upsertSeriesDefinition(series);
|
|
479
340
|
*/
|
|
480
341
|
async upsertSeriesDefinition(series) {
|
|
481
|
-
if (!this.
|
|
342
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
482
343
|
return;
|
|
483
344
|
}
|
|
484
345
|
await this.runSql(`DELETE FROM history_series WHERE series_id = ${this.escape(series.seriesId)}`);
|
|
@@ -496,7 +357,8 @@ class DuckDbParquetStorageService {
|
|
|
496
357
|
retention_duration_ms,
|
|
497
358
|
sample_time,
|
|
498
359
|
enabled,
|
|
499
|
-
methods_json
|
|
360
|
+
methods_json,
|
|
361
|
+
reconcile_ts
|
|
500
362
|
) VALUES (
|
|
501
363
|
${this.escape(series.seriesId)},
|
|
502
364
|
${this.escape(series.datasetUuid)},
|
|
@@ -509,13 +371,14 @@ class DuckDbParquetStorageService {
|
|
|
509
371
|
${this.nullableNumber(series.period)},
|
|
510
372
|
${this.nullableNumber(series.retentionDurationMs)},
|
|
511
373
|
${this.nullableNumber(series.sampleTime)},
|
|
512
|
-
${series.enabled === false ? '
|
|
513
|
-
${this.nullableString(series.methods ? JSON.stringify(series.methods) : null)}
|
|
374
|
+
${series.enabled === false ? '0' : '1'},
|
|
375
|
+
${this.nullableString(series.methods ? JSON.stringify(series.methods) : null)},
|
|
376
|
+
${this.nullableNumber(series.reconcileTs)}
|
|
514
377
|
)
|
|
515
378
|
`);
|
|
516
379
|
}
|
|
517
380
|
/**
|
|
518
|
-
* Deletes one persisted series definition in
|
|
381
|
+
* Deletes one persisted series definition in node:sqlite.
|
|
519
382
|
*
|
|
520
383
|
* @param {string} seriesId Series identifier.
|
|
521
384
|
* @returns {Promise<void>}
|
|
@@ -524,11 +387,10 @@ class DuckDbParquetStorageService {
|
|
|
524
387
|
* await storage.deleteSeriesDefinition('series-1');
|
|
525
388
|
*/
|
|
526
389
|
async deleteSeriesDefinition(seriesId) {
|
|
527
|
-
if (!this.
|
|
390
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
528
391
|
return;
|
|
529
392
|
}
|
|
530
393
|
await this.runSql(`DELETE FROM history_series WHERE series_id = ${this.escape(seriesId)}`);
|
|
531
|
-
this.deleteParquetSeriesDir(seriesId);
|
|
532
394
|
}
|
|
533
395
|
/**
|
|
534
396
|
* Replaces persisted series definitions with desired set.
|
|
@@ -540,7 +402,7 @@ class DuckDbParquetStorageService {
|
|
|
540
402
|
* await storage.replaceSeriesDefinitions(series);
|
|
541
403
|
*/
|
|
542
404
|
async replaceSeriesDefinitions(series) {
|
|
543
|
-
if (!this.
|
|
405
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
544
406
|
return;
|
|
545
407
|
}
|
|
546
408
|
await this.runSql('DELETE FROM history_series');
|
|
@@ -548,6 +410,29 @@ class DuckDbParquetStorageService {
|
|
|
548
410
|
await this.upsertSeriesDefinition(item);
|
|
549
411
|
}
|
|
550
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Deletes series not reconciled since the given cutoff timestamp.
|
|
415
|
+
*
|
|
416
|
+
* @param {number} cutoffMs Milliseconds since epoch; series with reconcile_ts < cutoffMs will be deleted.
|
|
417
|
+
* @returns {Promise<number>} Number of deleted series.
|
|
418
|
+
*
|
|
419
|
+
* @example
|
|
420
|
+
* const deleted = await storage.deleteStaleSeries(Date.now() - 180 * 24 * 60 * 60 * 1000);
|
|
421
|
+
*/
|
|
422
|
+
async deleteStaleSeries(cutoffMs) {
|
|
423
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
const rows = await this.querySql(`
|
|
427
|
+
SELECT series_id FROM history_series
|
|
428
|
+
WHERE reconcile_ts IS NULL OR reconcile_ts < ${Math.trunc(cutoffMs)}
|
|
429
|
+
`);
|
|
430
|
+
const ids = rows.map(row => row.series_id).filter(Boolean);
|
|
431
|
+
for (const id of ids) {
|
|
432
|
+
await this.deleteSeriesDefinition(id);
|
|
433
|
+
}
|
|
434
|
+
return ids.length;
|
|
435
|
+
}
|
|
551
436
|
/**
|
|
552
437
|
* Removes persisted samples that are older than each series retention window.
|
|
553
438
|
*
|
|
@@ -562,7 +447,7 @@ class DuckDbParquetStorageService {
|
|
|
562
447
|
if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
|
|
563
448
|
return 0;
|
|
564
449
|
}
|
|
565
|
-
if (!this.
|
|
450
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
566
451
|
return 0;
|
|
567
452
|
}
|
|
568
453
|
const anchorMs = Math.trunc(Number.isFinite(nowMs) ? nowMs : Date.now());
|
|
@@ -582,7 +467,7 @@ class DuckDbParquetStorageService {
|
|
|
582
467
|
SELECT rowid
|
|
583
468
|
FROM history_samples
|
|
584
469
|
WHERE ${whereClause}
|
|
585
|
-
LIMIT ${
|
|
470
|
+
LIMIT ${SqliteHistoryStorageService.PRUNE_BATCH_SIZE}
|
|
586
471
|
`);
|
|
587
472
|
if (batch.length === 0) {
|
|
588
473
|
break;
|
|
@@ -596,7 +481,7 @@ class DuckDbParquetStorageService {
|
|
|
596
481
|
WHERE rowid IN (${rowIds.join(', ')})
|
|
597
482
|
`);
|
|
598
483
|
removedRows += rowIds.length;
|
|
599
|
-
if (rowIds.length <
|
|
484
|
+
if (rowIds.length < SqliteHistoryStorageService.PRUNE_BATCH_SIZE) {
|
|
600
485
|
break;
|
|
601
486
|
}
|
|
602
487
|
}
|
|
@@ -615,7 +500,7 @@ class DuckDbParquetStorageService {
|
|
|
615
500
|
if (expectedLifecycleToken !== undefined && expectedLifecycleToken !== this.lifecycleToken) {
|
|
616
501
|
return 0;
|
|
617
502
|
}
|
|
618
|
-
if (!this.
|
|
503
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
619
504
|
return 0;
|
|
620
505
|
}
|
|
621
506
|
const whereClause = `
|
|
@@ -631,7 +516,7 @@ class DuckDbParquetStorageService {
|
|
|
631
516
|
SELECT rowid
|
|
632
517
|
FROM history_samples
|
|
633
518
|
WHERE ${whereClause}
|
|
634
|
-
LIMIT ${
|
|
519
|
+
LIMIT ${SqliteHistoryStorageService.PRUNE_BATCH_SIZE}
|
|
635
520
|
`);
|
|
636
521
|
if (batch.length === 0) {
|
|
637
522
|
break;
|
|
@@ -645,7 +530,7 @@ class DuckDbParquetStorageService {
|
|
|
645
530
|
WHERE rowid IN (${rowIds.join(', ')})
|
|
646
531
|
`);
|
|
647
532
|
removedRows += rowIds.length;
|
|
648
|
-
if (rowIds.length <
|
|
533
|
+
if (rowIds.length < SqliteHistoryStorageService.PRUNE_BATCH_SIZE) {
|
|
649
534
|
break;
|
|
650
535
|
}
|
|
651
536
|
}
|
|
@@ -654,13 +539,14 @@ class DuckDbParquetStorageService {
|
|
|
654
539
|
/**
|
|
655
540
|
* Lists known history paths from persisted samples.
|
|
656
541
|
*
|
|
542
|
+
* @param {IHistoryRangeQuery} [query] Optional range filter.
|
|
657
543
|
* @returns {Promise<string[]>} Ordered path names.
|
|
658
544
|
*
|
|
659
545
|
* @example
|
|
660
546
|
* const paths = await storage.getStoredPaths();
|
|
661
547
|
*/
|
|
662
548
|
async getStoredPaths(query) {
|
|
663
|
-
if (!this.
|
|
549
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
664
550
|
return [];
|
|
665
551
|
}
|
|
666
552
|
const nowMs = Date.now();
|
|
@@ -678,13 +564,14 @@ class DuckDbParquetStorageService {
|
|
|
678
564
|
/**
|
|
679
565
|
* Lists known history contexts from persisted samples.
|
|
680
566
|
*
|
|
567
|
+
* @param {IHistoryRangeQuery} [query] Optional range filter.
|
|
681
568
|
* @returns {Promise<string[]>} Ordered context names.
|
|
682
569
|
*
|
|
683
570
|
* @example
|
|
684
571
|
* const contexts = await storage.getStoredContexts();
|
|
685
572
|
*/
|
|
686
573
|
async getStoredContexts(query) {
|
|
687
|
-
if (!this.
|
|
574
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
688
575
|
return [];
|
|
689
576
|
}
|
|
690
577
|
const nowMs = Date.now();
|
|
@@ -700,16 +587,16 @@ class DuckDbParquetStorageService {
|
|
|
700
587
|
return rows.map(row => row.value).filter(Boolean);
|
|
701
588
|
}
|
|
702
589
|
/**
|
|
703
|
-
* Queries history values directly from
|
|
590
|
+
* Queries history values directly from node:sqlite in History API-compatible shape.
|
|
704
591
|
*
|
|
705
592
|
* @param {IHistoryQueryParams} query Incoming history values query parameters.
|
|
706
|
-
* @returns {Promise<IHistoryValuesResponse | null>} History payload when
|
|
593
|
+
* @returns {Promise<IHistoryValuesResponse | null>} History payload when node:sqlite is ready, otherwise null.
|
|
707
594
|
*
|
|
708
595
|
* @example
|
|
709
596
|
* const result = await storage.getValues({ paths: 'navigation.speedOverGround:avg', duration: 'PT1H' });
|
|
710
597
|
*/
|
|
711
598
|
async getValues(query) {
|
|
712
|
-
if (!this.
|
|
599
|
+
if (!this.isSqliteEnabled() || !this.db) {
|
|
713
600
|
return null;
|
|
714
601
|
}
|
|
715
602
|
const nowMs = Date.now();
|
|
@@ -753,6 +640,7 @@ class DuckDbParquetStorageService {
|
|
|
753
640
|
/**
|
|
754
641
|
* Closes open storage resources.
|
|
755
642
|
*
|
|
643
|
+
* @param {number} [expectedLifecycleToken] Optional lifecycle token guard to skip stale closes.
|
|
756
644
|
* @returns {Promise<void>}
|
|
757
645
|
*
|
|
758
646
|
* @example
|
|
@@ -763,64 +651,174 @@ class DuckDbParquetStorageService {
|
|
|
763
651
|
return;
|
|
764
652
|
}
|
|
765
653
|
this.initialized = false;
|
|
654
|
+
this.stopVacuumJob();
|
|
766
655
|
this.stopPruneJob();
|
|
767
656
|
this.stopStaleSeriesCleanupJob();
|
|
768
|
-
if (!this.
|
|
769
|
-
this.db = null;
|
|
657
|
+
if (!this.db) {
|
|
770
658
|
return;
|
|
771
659
|
}
|
|
772
|
-
const connection = this.connection;
|
|
773
660
|
const db = this.db;
|
|
774
|
-
this.
|
|
661
|
+
this.db = null;
|
|
775
662
|
try {
|
|
776
|
-
|
|
663
|
+
db.close();
|
|
777
664
|
}
|
|
778
665
|
catch {
|
|
779
|
-
// ignore
|
|
666
|
+
// ignore close failures during shutdown
|
|
780
667
|
}
|
|
668
|
+
}
|
|
669
|
+
async loadSqliteModule() {
|
|
781
670
|
try {
|
|
782
|
-
|
|
671
|
+
return await Promise.resolve().then(() => __importStar(require('node:sqlite')));
|
|
783
672
|
}
|
|
784
673
|
catch {
|
|
785
|
-
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
startVacuumJob() {
|
|
678
|
+
this.stopVacuumJob();
|
|
679
|
+
if (!this.isSqliteReady())
|
|
680
|
+
return;
|
|
681
|
+
this.vacuumJob = setInterval(() => {
|
|
682
|
+
if (this.shouldSkipMaintenance()) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
void this.runWithMaintenanceLock('vacuum', async () => {
|
|
686
|
+
this.logger.debug('[SERIES STORAGE] Running scheduled node:sqlite VACUUM');
|
|
687
|
+
await this.runSql('VACUUM;');
|
|
688
|
+
await this.runSql('PRAGMA optimize;');
|
|
689
|
+
}).catch(err => {
|
|
690
|
+
this.logger.error(`[SERIES STORAGE] VACUUM failed: ${err?.message ?? err}`);
|
|
691
|
+
});
|
|
692
|
+
}, SqliteHistoryStorageService.EIGHT_HOURS_INTERVAL);
|
|
693
|
+
this.vacuumJob.unref?.();
|
|
694
|
+
}
|
|
695
|
+
stopVacuumJob() {
|
|
696
|
+
if (this.vacuumJob) {
|
|
697
|
+
clearInterval(this.vacuumJob);
|
|
698
|
+
this.vacuumJob = null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
startPruneJob() {
|
|
702
|
+
this.stopPruneJob();
|
|
703
|
+
if (!this.isSqliteReady())
|
|
704
|
+
return;
|
|
705
|
+
this.pruneJob = setInterval(async () => {
|
|
706
|
+
if (this.shouldSkipMaintenance()) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
await this.runWithMaintenanceLock('prune', async () => {
|
|
711
|
+
this.logger.debug('[SERIES STORAGE] Running scheduled prune of expired and orphaned samples');
|
|
712
|
+
const expired = await this.pruneExpiredSamples(Date.now(), this.lifecycleToken);
|
|
713
|
+
const orphaned = await this.pruneOrphanedSamples(this.lifecycleToken);
|
|
714
|
+
this.logger.debug(`[SERIES STORAGE] Pruned ${expired} expired and ${orphaned} orphaned samples`);
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
this.logger.error(`[SERIES STORAGE] Prune failed: ${err?.message ?? err}`);
|
|
719
|
+
}
|
|
720
|
+
}, SqliteHistoryStorageService.FOUR_HOURS_INTERVAL);
|
|
721
|
+
this.pruneJob.unref?.();
|
|
722
|
+
}
|
|
723
|
+
stopPruneJob() {
|
|
724
|
+
if (this.pruneJob) {
|
|
725
|
+
clearInterval(this.pruneJob);
|
|
726
|
+
this.pruneJob = null;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
startStaleSeriesCleanupJob() {
|
|
730
|
+
this.stopStaleSeriesCleanupJob();
|
|
731
|
+
if (!this.isSqliteReady())
|
|
732
|
+
return;
|
|
733
|
+
this.staleSeriesCleanupJob = setInterval(async () => {
|
|
734
|
+
if (this.shouldSkipMaintenance()) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
await this.runWithMaintenanceLock('stale-cleanup', async () => {
|
|
739
|
+
const cutoff = Date.now() - SqliteHistoryStorageService.STALE_SERIES_AGE_MS;
|
|
740
|
+
this.logger.debug(`[SERIES STORAGE] Running scheduled stale series cleanup (cutoff: ${new Date(cutoff).toISOString()})`);
|
|
741
|
+
const deleted = await this.deleteStaleSeries(cutoff);
|
|
742
|
+
if (deleted > 0) {
|
|
743
|
+
this.logger.debug(`[SERIES STORAGE] Deleted ${deleted} series not reconciled in the last 6 months`);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
this.logger.error(`[SERIES STORAGE] Stale series cleanup failed: ${err?.message ?? err}`);
|
|
749
|
+
}
|
|
750
|
+
}, SqliteHistoryStorageService.EIGHT_HOURS_INTERVAL);
|
|
751
|
+
this.staleSeriesCleanupJob.unref?.();
|
|
752
|
+
}
|
|
753
|
+
stopStaleSeriesCleanupJob() {
|
|
754
|
+
if (this.staleSeriesCleanupJob) {
|
|
755
|
+
clearInterval(this.staleSeriesCleanupJob);
|
|
756
|
+
this.staleSeriesCleanupJob = null;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
shouldSkipMaintenance() {
|
|
760
|
+
if (!this.isSqliteReady() || !this.db) {
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
if (this.maintenanceInProgress || this.flushInProgress) {
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
if (this.pendingRows.length > 0) {
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
async runWithMaintenanceLock(label, task) {
|
|
772
|
+
if (this.maintenanceInProgress) {
|
|
773
|
+
this.logger.debug(`[SERIES STORAGE] Skipping ${label} (maintenance already running)`);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
this.maintenanceInProgress = true;
|
|
777
|
+
const startedAt = Date.now();
|
|
778
|
+
try {
|
|
779
|
+
await task();
|
|
780
|
+
const elapsedMs = Date.now() - startedAt;
|
|
781
|
+
this.logger.debug(`[SERIES STORAGE] ${label} completed in ${elapsedMs}ms`);
|
|
782
|
+
}
|
|
783
|
+
finally {
|
|
784
|
+
this.maintenanceInProgress = false;
|
|
786
785
|
}
|
|
787
|
-
this.db = null;
|
|
788
786
|
}
|
|
789
787
|
async createCoreTables() {
|
|
790
788
|
await this.runSql(`
|
|
791
789
|
CREATE TABLE IF NOT EXISTS history_samples (
|
|
792
|
-
series_id
|
|
793
|
-
dataset_uuid
|
|
794
|
-
owner_widget_uuid
|
|
795
|
-
path
|
|
796
|
-
context
|
|
797
|
-
source
|
|
798
|
-
ts_ms
|
|
799
|
-
value
|
|
790
|
+
series_id TEXT,
|
|
791
|
+
dataset_uuid TEXT,
|
|
792
|
+
owner_widget_uuid TEXT,
|
|
793
|
+
path TEXT,
|
|
794
|
+
context TEXT,
|
|
795
|
+
source TEXT,
|
|
796
|
+
ts_ms INTEGER,
|
|
797
|
+
value REAL
|
|
800
798
|
)
|
|
801
799
|
`);
|
|
802
800
|
await this.runSql(`
|
|
803
801
|
CREATE TABLE IF NOT EXISTS history_series (
|
|
804
|
-
series_id
|
|
805
|
-
dataset_uuid
|
|
806
|
-
owner_widget_uuid
|
|
807
|
-
owner_widget_selector
|
|
808
|
-
path
|
|
809
|
-
source
|
|
810
|
-
context
|
|
811
|
-
time_scale
|
|
802
|
+
series_id TEXT NOT NULL,
|
|
803
|
+
dataset_uuid TEXT NOT NULL,
|
|
804
|
+
owner_widget_uuid TEXT NOT NULL,
|
|
805
|
+
owner_widget_selector TEXT,
|
|
806
|
+
path TEXT NOT NULL,
|
|
807
|
+
source TEXT,
|
|
808
|
+
context TEXT,
|
|
809
|
+
time_scale TEXT,
|
|
812
810
|
period INTEGER,
|
|
813
|
-
retention_duration_ms
|
|
811
|
+
retention_duration_ms INTEGER,
|
|
814
812
|
sample_time INTEGER,
|
|
815
|
-
enabled
|
|
816
|
-
methods_json
|
|
817
|
-
reconcile_ts
|
|
813
|
+
enabled INTEGER,
|
|
814
|
+
methods_json TEXT,
|
|
815
|
+
reconcile_ts INTEGER,
|
|
818
816
|
PRIMARY KEY (series_id)
|
|
819
817
|
)
|
|
820
818
|
`);
|
|
821
819
|
}
|
|
822
820
|
async insertRows(rows) {
|
|
823
|
-
if (rows.length === 0) {
|
|
821
|
+
if (!this.db || rows.length === 0) {
|
|
824
822
|
return;
|
|
825
823
|
}
|
|
826
824
|
const valuesSql = rows
|
|
@@ -838,196 +836,28 @@ class DuckDbParquetStorageService {
|
|
|
838
836
|
value
|
|
839
837
|
) VALUES ${valuesSql}
|
|
840
838
|
`;
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
const baseDir = (0, path_1.resolve)(this.config.parquetDirectory);
|
|
846
|
-
const seriesDir = (0, path_1.join)(baseDir, this.safePath(seriesId));
|
|
847
|
-
(0, fs_1.mkdirSync)(seriesDir, { recursive: true });
|
|
848
|
-
const startWindow = Math.floor(fromMs / windowMs) * windowMs;
|
|
849
|
-
const endWindow = Math.floor(toMs / windowMs) * windowMs;
|
|
850
|
-
for (let windowStart = startWindow; windowStart <= endWindow; windowStart += windowMs) {
|
|
851
|
-
const windowEnd = windowStart + windowMs - 1;
|
|
852
|
-
const filePath = (0, path_1.join)(seriesDir, `${windowStart}-${windowEnd}.parquet`);
|
|
853
|
-
const rows = await this.querySql(`
|
|
854
|
-
SELECT
|
|
855
|
-
series_id,
|
|
856
|
-
dataset_uuid,
|
|
857
|
-
owner_widget_uuid,
|
|
858
|
-
path,
|
|
859
|
-
context,
|
|
860
|
-
source,
|
|
861
|
-
ts_ms,
|
|
862
|
-
value
|
|
863
|
-
FROM history_samples
|
|
864
|
-
WHERE series_id = ${this.escape(seriesId)}
|
|
865
|
-
AND ts_ms >= ${Math.trunc(windowStart)}
|
|
866
|
-
AND ts_ms <= ${Math.trunc(windowEnd)}
|
|
867
|
-
ORDER BY ts_ms
|
|
868
|
-
`);
|
|
869
|
-
if (rows.length === 0) {
|
|
870
|
-
(0, fs_1.rmSync)(filePath, { force: true });
|
|
871
|
-
continue;
|
|
872
|
-
}
|
|
873
|
-
const compression = this.resolveParquetCompression();
|
|
874
|
-
const schema = new parquetjs_1.ParquetSchema({
|
|
875
|
-
series_id: { type: 'UTF8', compression },
|
|
876
|
-
dataset_uuid: { type: 'UTF8', compression },
|
|
877
|
-
owner_widget_uuid: { type: 'UTF8', compression },
|
|
878
|
-
path: { type: 'UTF8', compression },
|
|
879
|
-
context: { type: 'UTF8', compression },
|
|
880
|
-
source: { type: 'UTF8', optional: true, compression },
|
|
881
|
-
ts_ms: { type: 'INT64', compression },
|
|
882
|
-
ts: { type: 'TIMESTAMP_MILLIS', compression },
|
|
883
|
-
value: { type: 'DOUBLE', compression }
|
|
884
|
-
});
|
|
885
|
-
const tempPath = `${filePath}.tmp-${Date.now()}`;
|
|
886
|
-
const writer = await parquetjs_1.ParquetWriter.openFile(schema, tempPath);
|
|
887
|
-
try {
|
|
888
|
-
for (const row of rows) {
|
|
889
|
-
const timestampMs = this.toNumberOrUndefined(row.ts_ms);
|
|
890
|
-
const numericValue = this.toNumberOrUndefined(row.value);
|
|
891
|
-
if (timestampMs === undefined || numericValue === undefined) {
|
|
892
|
-
continue;
|
|
893
|
-
}
|
|
894
|
-
await writer.appendRow({
|
|
895
|
-
series_id: row.series_id,
|
|
896
|
-
dataset_uuid: row.dataset_uuid,
|
|
897
|
-
owner_widget_uuid: row.owner_widget_uuid,
|
|
898
|
-
path: row.path,
|
|
899
|
-
context: row.context,
|
|
900
|
-
source: row.source ?? undefined,
|
|
901
|
-
ts_ms: Math.trunc(timestampMs),
|
|
902
|
-
ts: new Date(timestampMs),
|
|
903
|
-
value: numericValue
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
finally {
|
|
908
|
-
await writer.close();
|
|
909
|
-
}
|
|
910
|
-
(0, fs_1.rmSync)(filePath, { force: true });
|
|
911
|
-
(0, fs_1.renameSync)(tempPath, filePath);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
resolveParquetWindowMs() {
|
|
915
|
-
const parsed = Number(this.config.parquetWindowMs);
|
|
916
|
-
if (Number.isFinite(parsed) && parsed > 0) {
|
|
917
|
-
return Math.max(1, Math.trunc(parsed));
|
|
918
|
-
}
|
|
919
|
-
return 60 * 60_000;
|
|
920
|
-
}
|
|
921
|
-
resolveParquetCompression() {
|
|
922
|
-
const raw = String(this.config.parquetCompression ?? 'none').trim().toLowerCase();
|
|
923
|
-
if (raw === 'snappy') {
|
|
924
|
-
return 'SNAPPY';
|
|
925
|
-
}
|
|
926
|
-
if (raw === 'gzip') {
|
|
927
|
-
return 'GZIP';
|
|
928
|
-
}
|
|
929
|
-
if (raw === 'brotli') {
|
|
930
|
-
return 'BROTLI';
|
|
931
|
-
}
|
|
932
|
-
if (raw === 'lz4') {
|
|
933
|
-
return 'LZ4';
|
|
934
|
-
}
|
|
935
|
-
if (raw === 'lzo') {
|
|
936
|
-
return 'LZO';
|
|
937
|
-
}
|
|
938
|
-
return undefined;
|
|
939
|
-
}
|
|
940
|
-
pruneParquetFilesByRetention(nowMs) {
|
|
941
|
-
if (!this.isDuckDbParquetEnabled() || !this.connection) {
|
|
942
|
-
return Promise.resolve(0);
|
|
943
|
-
}
|
|
944
|
-
return this.querySql(`
|
|
945
|
-
SELECT series_id, retention_duration_ms
|
|
946
|
-
FROM history_series
|
|
947
|
-
`).then(rows => {
|
|
948
|
-
const baseDir = (0, path_1.resolve)(this.config.parquetDirectory);
|
|
949
|
-
const seriesConfig = new Map(rows.map(row => [this.safePath(row.series_id), row.retention_duration_ms]));
|
|
950
|
-
let removed = 0;
|
|
951
|
-
let seriesDirs = [];
|
|
952
|
-
try {
|
|
953
|
-
seriesDirs = (0, fs_1.readdirSync)(baseDir);
|
|
954
|
-
}
|
|
955
|
-
catch {
|
|
956
|
-
return 0;
|
|
957
|
-
}
|
|
958
|
-
for (const dirName of seriesDirs) {
|
|
959
|
-
const fullDir = (0, path_1.join)(baseDir, dirName);
|
|
960
|
-
let dirStat;
|
|
961
|
-
try {
|
|
962
|
-
dirStat = (0, fs_1.statSync)(fullDir);
|
|
963
|
-
}
|
|
964
|
-
catch {
|
|
965
|
-
continue;
|
|
966
|
-
}
|
|
967
|
-
if (!dirStat.isDirectory()) {
|
|
968
|
-
continue;
|
|
969
|
-
}
|
|
970
|
-
const seriesKey = dirName;
|
|
971
|
-
if (!seriesConfig.has(seriesKey)) {
|
|
972
|
-
(0, fs_1.rmSync)(fullDir, { recursive: true, force: true });
|
|
973
|
-
removed += 1;
|
|
974
|
-
continue;
|
|
975
|
-
}
|
|
976
|
-
const retentionMs = seriesConfig.get(seriesKey);
|
|
977
|
-
if (!Number.isFinite(retentionMs) || (retentionMs ?? 0) <= 0) {
|
|
978
|
-
continue;
|
|
979
|
-
}
|
|
980
|
-
const cutoff = nowMs - Number(retentionMs);
|
|
981
|
-
let files = [];
|
|
982
|
-
try {
|
|
983
|
-
files = (0, fs_1.readdirSync)(fullDir);
|
|
984
|
-
}
|
|
985
|
-
catch {
|
|
986
|
-
continue;
|
|
987
|
-
}
|
|
988
|
-
files.forEach(fileName => {
|
|
989
|
-
const range = this.parseParquetRangeFromFileName(fileName);
|
|
990
|
-
if (!range) {
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
if (range.toMs < cutoff) {
|
|
994
|
-
(0, fs_1.rmSync)((0, path_1.join)(fullDir, fileName), { force: true });
|
|
995
|
-
removed += 1;
|
|
996
|
-
}
|
|
997
|
-
});
|
|
998
|
-
}
|
|
999
|
-
return removed;
|
|
1000
|
-
});
|
|
1001
|
-
}
|
|
1002
|
-
parseParquetRangeFromFileName(fileName) {
|
|
1003
|
-
const match = /^([0-9]+)-([0-9]+)\.parquet$/.exec(fileName);
|
|
1004
|
-
if (!match) {
|
|
1005
|
-
return null;
|
|
839
|
+
this.db.exec('BEGIN');
|
|
840
|
+
try {
|
|
841
|
+
await this.runSql(sql);
|
|
842
|
+
this.db.exec('COMMIT');
|
|
1006
843
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
return null;
|
|
844
|
+
catch (error) {
|
|
845
|
+
this.db.exec('ROLLBACK');
|
|
846
|
+
throw error;
|
|
1011
847
|
}
|
|
1012
|
-
return { fromMs, toMs };
|
|
1013
|
-
}
|
|
1014
|
-
deleteParquetSeriesDir(seriesId) {
|
|
1015
|
-
const baseDir = (0, path_1.resolve)(this.config.parquetDirectory);
|
|
1016
|
-
const seriesDir = (0, path_1.join)(baseDir, this.safePath(seriesId));
|
|
1017
|
-
(0, fs_1.rmSync)(seriesDir, { recursive: true, force: true });
|
|
1018
848
|
}
|
|
1019
849
|
async runSql(sql) {
|
|
1020
|
-
if (!this.
|
|
1021
|
-
throw new Error('
|
|
850
|
+
if (!this.db) {
|
|
851
|
+
throw new Error('node:sqlite database is not initialized');
|
|
1022
852
|
}
|
|
1023
|
-
|
|
853
|
+
this.db.exec(sql);
|
|
1024
854
|
}
|
|
1025
855
|
async querySql(sql) {
|
|
1026
|
-
if (!this.
|
|
1027
|
-
throw new Error('
|
|
856
|
+
if (!this.db) {
|
|
857
|
+
throw new Error('node:sqlite database is not initialized');
|
|
1028
858
|
}
|
|
1029
|
-
const
|
|
1030
|
-
return
|
|
859
|
+
const statement = this.db.prepare(sql);
|
|
860
|
+
return statement.all();
|
|
1031
861
|
}
|
|
1032
862
|
async selectRowsForPaths(paths, context, fromMs, toMs) {
|
|
1033
863
|
const rowsByPath = new Map();
|
|
@@ -1234,9 +1064,6 @@ class DuckDbParquetStorageService {
|
|
|
1234
1064
|
const sum = values.reduce((acc, value) => acc + value, 0);
|
|
1235
1065
|
return sum / values.length;
|
|
1236
1066
|
}
|
|
1237
|
-
safePath(value) {
|
|
1238
|
-
return value.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1239
|
-
}
|
|
1240
1067
|
escape(value) {
|
|
1241
1068
|
return `'${String(value).replace(/'/g, "''")}'`;
|
|
1242
1069
|
}
|
|
@@ -1298,4 +1125,4 @@ class DuckDbParquetStorageService {
|
|
|
1298
1125
|
return Boolean(value);
|
|
1299
1126
|
}
|
|
1300
1127
|
}
|
|
1301
|
-
exports.
|
|
1128
|
+
exports.SqliteHistoryStorageService = SqliteHistoryStorageService;
|