doc2vec 2.10.5 → 2.10.6
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/dist/controller/config-registry.js +277 -0
- package/dist/controller/events.js +29 -0
- package/dist/controller/index.js +139 -0
- package/dist/controller/job-runner.js +314 -0
- package/dist/controller/migrations.js +55 -0
- package/dist/controller/notifier.js +106 -0
- package/dist/controller/scheduler.js +54 -0
- package/dist/controller/server.js +288 -0
- package/dist/controller/store.js +186 -0
- package/dist/controller/types.js +25 -0
- package/dist/doc2vec.js +20 -4
- package/dist/ui/assets/index-BVg6n7Si.css +1 -0
- package/dist/ui/assets/index-D-QOw3rk.js +126 -0
- package/dist/ui/index.html +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ConfigRegistry = void 0;
|
|
37
|
+
exports.parseConfigMeta = parseConfigMeta;
|
|
38
|
+
exports.isValidCron = isValidCron;
|
|
39
|
+
exports.hashContent = hashContent;
|
|
40
|
+
const crypto = __importStar(require("crypto"));
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const yaml = __importStar(require("js-yaml"));
|
|
44
|
+
const croner_1 = require("croner");
|
|
45
|
+
const types_1 = require("./types");
|
|
46
|
+
/**
|
|
47
|
+
* Parse raw config YAML WITHOUT ${ENV} substitution (secrets stay as placeholders)
|
|
48
|
+
* and without Doc2Vec.loadConfig()'s process.exit() behavior — invalid files are
|
|
49
|
+
* reported, never fatal.
|
|
50
|
+
*/
|
|
51
|
+
function parseConfigMeta(content, filePath) {
|
|
52
|
+
const fallbackName = path.basename(filePath).replace(/\.ya?ml$/i, '');
|
|
53
|
+
try {
|
|
54
|
+
const parsed = yaml.load(content);
|
|
55
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
56
|
+
return { name: fallbackName, schedule: null, sourceSummary: [], parseError: 'config is empty or not a YAML mapping' };
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(parsed.sources) || parsed.sources.length === 0) {
|
|
59
|
+
return { name: parsed.name || fallbackName, schedule: null, sourceSummary: [], parseError: 'config has no sources' };
|
|
60
|
+
}
|
|
61
|
+
const sourceSummary = parsed.sources.map((s) => ({
|
|
62
|
+
type: String(s?.type ?? 'unknown'),
|
|
63
|
+
product_name: String(s?.product_name ?? 'unknown'),
|
|
64
|
+
...(s?.version !== undefined && { version: String(s.version) }),
|
|
65
|
+
}));
|
|
66
|
+
let schedule = null;
|
|
67
|
+
let parseError = null;
|
|
68
|
+
if (parsed.schedule !== undefined && parsed.schedule !== null) {
|
|
69
|
+
schedule = String(parsed.schedule);
|
|
70
|
+
if (!isValidCron(schedule)) {
|
|
71
|
+
parseError = `invalid cron expression: ${schedule}`;
|
|
72
|
+
schedule = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { name: String(parsed.name || fallbackName), schedule, sourceSummary, parseError };
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return {
|
|
79
|
+
name: fallbackName,
|
|
80
|
+
schedule: null,
|
|
81
|
+
sourceSummary: [],
|
|
82
|
+
parseError: err instanceof Error ? err.message : String(err),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function isValidCron(expr) {
|
|
87
|
+
try {
|
|
88
|
+
new croner_1.Cron(expr, { paused: true }).stop();
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function hashContent(content) {
|
|
96
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Watches the config files/directories passed on the CLI (plus --config-dir in
|
|
100
|
+
* read-write mode), keeps the d2v_configs table in sync, and — in read-write mode —
|
|
101
|
+
* persists UI edits back to YAML files on disk. Disk is the source of truth.
|
|
102
|
+
*
|
|
103
|
+
* Change detection is content-hash polling rather than fs.watch: Kubernetes
|
|
104
|
+
* ConfigMap updates arrive as atomic symlink swaps that fs.watch misses.
|
|
105
|
+
*/
|
|
106
|
+
class ConfigRegistry {
|
|
107
|
+
constructor(opts, store, events, logger) {
|
|
108
|
+
this.opts = opts;
|
|
109
|
+
this.store = store;
|
|
110
|
+
this.events = events;
|
|
111
|
+
this.logger = logger;
|
|
112
|
+
this.byPath = new Map();
|
|
113
|
+
this.timer = null;
|
|
114
|
+
this.syncing = false;
|
|
115
|
+
}
|
|
116
|
+
async start() {
|
|
117
|
+
await this.sync();
|
|
118
|
+
this.timer = setInterval(() => {
|
|
119
|
+
this.sync().catch(err => this.logger.error('Config re-sync failed:', err));
|
|
120
|
+
}, Math.max(this.opts.reloadIntervalSec, 5) * 1000);
|
|
121
|
+
this.timer.unref();
|
|
122
|
+
}
|
|
123
|
+
stop() {
|
|
124
|
+
if (this.timer)
|
|
125
|
+
clearInterval(this.timer);
|
|
126
|
+
this.timer = null;
|
|
127
|
+
}
|
|
128
|
+
list() {
|
|
129
|
+
return [...this.byPath.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
130
|
+
}
|
|
131
|
+
get(id) {
|
|
132
|
+
return [...this.byPath.values()].find(c => c.id === id);
|
|
133
|
+
}
|
|
134
|
+
/** Expand CLI args (files and directories) into the current set of config files. */
|
|
135
|
+
discoverFiles() {
|
|
136
|
+
const files = new Set();
|
|
137
|
+
const addDir = (dir) => {
|
|
138
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
139
|
+
if (/\.ya?ml$/i.test(entry) && !entry.startsWith('.')) {
|
|
140
|
+
const full = path.join(dir, entry);
|
|
141
|
+
if (fs.statSync(full).isFile())
|
|
142
|
+
files.add(full);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
for (const arg of this.opts.configArgs) {
|
|
147
|
+
const abs = path.resolve(arg);
|
|
148
|
+
if (!fs.existsSync(abs)) {
|
|
149
|
+
this.logger.warn(`Config path does not exist: ${abs}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
153
|
+
addDir(abs);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
files.add(abs);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (this.opts.readWrite && this.opts.configDir && fs.existsSync(this.opts.configDir)) {
|
|
160
|
+
addDir(path.resolve(this.opts.configDir));
|
|
161
|
+
}
|
|
162
|
+
return [...files];
|
|
163
|
+
}
|
|
164
|
+
async sync() {
|
|
165
|
+
if (this.syncing)
|
|
166
|
+
return;
|
|
167
|
+
this.syncing = true;
|
|
168
|
+
try {
|
|
169
|
+
const files = this.discoverFiles();
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
let changed = false;
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
seen.add(file);
|
|
174
|
+
let content;
|
|
175
|
+
try {
|
|
176
|
+
content = fs.readFileSync(file, 'utf8');
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
this.logger.warn(`Failed to read config ${file}:`, err);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const contentHash = hashContent(content);
|
|
183
|
+
const existing = this.byPath.get(file);
|
|
184
|
+
if (existing && existing.content_hash === contentHash)
|
|
185
|
+
continue;
|
|
186
|
+
const meta = parseConfigMeta(content, file);
|
|
187
|
+
if (meta.parseError) {
|
|
188
|
+
this.logger.warn(`Config ${file} has a problem: ${meta.parseError}`);
|
|
189
|
+
}
|
|
190
|
+
const record = await this.store.upsertConfig({
|
|
191
|
+
path: file,
|
|
192
|
+
name: meta.name,
|
|
193
|
+
content,
|
|
194
|
+
contentHash,
|
|
195
|
+
schedule: meta.schedule,
|
|
196
|
+
sourceSummary: meta.sourceSummary,
|
|
197
|
+
parseError: meta.parseError,
|
|
198
|
+
});
|
|
199
|
+
this.byPath.set(file, record);
|
|
200
|
+
changed = true;
|
|
201
|
+
this.logger.info(`${existing ? 'Reloaded' : 'Loaded'} config '${record.name}' from ${file}` +
|
|
202
|
+
(record.schedule ? ` (schedule: ${record.schedule})` : ' (manual runs only)'));
|
|
203
|
+
}
|
|
204
|
+
for (const [file] of this.byPath) {
|
|
205
|
+
if (!seen.has(file)) {
|
|
206
|
+
this.logger.info(`Config file removed: ${file}`);
|
|
207
|
+
await this.store.markConfigDeleted(file);
|
|
208
|
+
this.byPath.delete(file);
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (changed)
|
|
213
|
+
this.events.emitConfigUpdate();
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
this.syncing = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// ------------------------------------------------------------- read-write mode
|
|
220
|
+
/** Validate content for create/update: must parse and declare sources; cron must be valid. */
|
|
221
|
+
validateForWrite(content, filePath) {
|
|
222
|
+
const meta = parseConfigMeta(content, filePath);
|
|
223
|
+
if (meta.parseError) {
|
|
224
|
+
throw new types_1.ValidationError(meta.parseError);
|
|
225
|
+
}
|
|
226
|
+
return meta;
|
|
227
|
+
}
|
|
228
|
+
atomicWrite(target, content) {
|
|
229
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
230
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
231
|
+
fs.renameSync(tmp, target);
|
|
232
|
+
}
|
|
233
|
+
async create(filename, content) {
|
|
234
|
+
if (!this.opts.configDir) {
|
|
235
|
+
throw new types_1.ValidationError('no --config-dir configured');
|
|
236
|
+
}
|
|
237
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.ya?ml$/.test(filename)) {
|
|
238
|
+
throw new types_1.ValidationError('filename must be a plain name ending in .yaml or .yml');
|
|
239
|
+
}
|
|
240
|
+
const target = path.join(path.resolve(this.opts.configDir), filename);
|
|
241
|
+
if (fs.existsSync(target)) {
|
|
242
|
+
throw new types_1.ConflictError(`config file ${filename} already exists`);
|
|
243
|
+
}
|
|
244
|
+
this.validateForWrite(content, target);
|
|
245
|
+
this.atomicWrite(target, content);
|
|
246
|
+
await this.sync();
|
|
247
|
+
const record = this.byPath.get(target);
|
|
248
|
+
if (!record)
|
|
249
|
+
throw new Error('config was written but failed to load');
|
|
250
|
+
return record;
|
|
251
|
+
}
|
|
252
|
+
async update(id, content, baseHash) {
|
|
253
|
+
const existing = this.get(id);
|
|
254
|
+
if (!existing)
|
|
255
|
+
throw new types_1.NotFoundError(`config ${id} not found`);
|
|
256
|
+
if (existing.content_hash !== baseHash) {
|
|
257
|
+
throw new types_1.ConflictError('config changed on disk since you loaded it — reload and reapply your edits');
|
|
258
|
+
}
|
|
259
|
+
this.validateForWrite(content, existing.path);
|
|
260
|
+
this.atomicWrite(existing.path, content);
|
|
261
|
+
await this.sync();
|
|
262
|
+
const record = this.byPath.get(existing.path);
|
|
263
|
+
if (!record)
|
|
264
|
+
throw new Error('config was written but failed to load');
|
|
265
|
+
return record;
|
|
266
|
+
}
|
|
267
|
+
async delete(id) {
|
|
268
|
+
const existing = this.get(id);
|
|
269
|
+
if (!existing)
|
|
270
|
+
throw new types_1.NotFoundError(`config ${id} not found`);
|
|
271
|
+
fs.unlinkSync(existing.path);
|
|
272
|
+
await this.store.markConfigDeleted(existing.path);
|
|
273
|
+
this.byPath.delete(existing.path);
|
|
274
|
+
this.events.emitConfigUpdate();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
exports.ConfigRegistry = ConfigRegistry;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ControllerEvents = void 0;
|
|
4
|
+
const events_1 = require("events");
|
|
5
|
+
/**
|
|
6
|
+
* In-process event bus connecting the job runner / config registry to SSE clients.
|
|
7
|
+
*
|
|
8
|
+
* Events:
|
|
9
|
+
* - 'run:update' (run: RunRecord) — a run was created or changed status
|
|
10
|
+
* - 'run:log' ({ runId: number, lines: LogRow[] }) — new log lines for a running job
|
|
11
|
+
* - 'config:update' () — the set of configs changed on disk
|
|
12
|
+
*/
|
|
13
|
+
class ControllerEvents extends events_1.EventEmitter {
|
|
14
|
+
constructor() {
|
|
15
|
+
super();
|
|
16
|
+
// Every SSE client adds a listener per event; don't warn at the default 10
|
|
17
|
+
this.setMaxListeners(1000);
|
|
18
|
+
}
|
|
19
|
+
emitRunUpdate(run) {
|
|
20
|
+
this.emit('run:update', run);
|
|
21
|
+
}
|
|
22
|
+
emitRunLogs(runId, lines) {
|
|
23
|
+
this.emit('run:log', { runId, lines });
|
|
24
|
+
}
|
|
25
|
+
emitConfigUpdate() {
|
|
26
|
+
this.emit('config:update');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.ControllerEvents = ControllerEvents;
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.startController = startController;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const logger_1 = require("../logger");
|
|
40
|
+
const config_registry_1 = require("./config-registry");
|
|
41
|
+
const events_1 = require("./events");
|
|
42
|
+
const job_runner_1 = require("./job-runner");
|
|
43
|
+
const notifier_1 = require("./notifier");
|
|
44
|
+
const scheduler_1 = require("./scheduler");
|
|
45
|
+
const server_1 = require("./server");
|
|
46
|
+
const store_1 = require("./store");
|
|
47
|
+
const LOG_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
48
|
+
async function startController(opts) {
|
|
49
|
+
const logger = new logger_1.Logger('Controller', {
|
|
50
|
+
level: logger_1.LogLevel.INFO,
|
|
51
|
+
useTimestamp: true,
|
|
52
|
+
useColor: true,
|
|
53
|
+
prettyPrint: true,
|
|
54
|
+
});
|
|
55
|
+
if (!opts.databaseUrl) {
|
|
56
|
+
throw new Error('controller mode requires Postgres: pass --database-url or set DATABASE_URL');
|
|
57
|
+
}
|
|
58
|
+
if (opts.readWrite && !opts.configDir) {
|
|
59
|
+
throw new Error('--read-write requires --config-dir (where new configs are written)');
|
|
60
|
+
}
|
|
61
|
+
if (opts.configArgs.length === 0 && !(opts.readWrite && opts.configDir)) {
|
|
62
|
+
throw new Error('provide at least one config file or directory');
|
|
63
|
+
}
|
|
64
|
+
if (opts.configDir) {
|
|
65
|
+
fs.mkdirSync(path.resolve(opts.configDir), { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
logger.section('DOC2VEC CONTROLLER');
|
|
68
|
+
logger.info(`Mode: ${opts.readWrite ? 'read-write' : 'read-only'}, max parallel jobs: ${opts.maxParallel}`);
|
|
69
|
+
const store = new store_1.ControllerStore(opts.databaseUrl, logger.child('store'));
|
|
70
|
+
await store.init();
|
|
71
|
+
const events = new events_1.ControllerEvents();
|
|
72
|
+
const runner = new job_runner_1.JobRunner(store, events, { maxParallel: Math.max(opts.maxParallel, 1) }, logger.child('runner'));
|
|
73
|
+
const registry = new config_registry_1.ConfigRegistry({
|
|
74
|
+
configArgs: opts.configArgs,
|
|
75
|
+
configDir: opts.configDir,
|
|
76
|
+
readWrite: opts.readWrite,
|
|
77
|
+
reloadIntervalSec: opts.reloadIntervalSec,
|
|
78
|
+
}, store, events, logger.child('registry'));
|
|
79
|
+
const scheduler = new scheduler_1.Scheduler(configId => {
|
|
80
|
+
const config = registry.get(configId);
|
|
81
|
+
if (!config)
|
|
82
|
+
return;
|
|
83
|
+
runner.enqueue(config, 'scheduled').catch(err => logger.error(`Failed to enqueue scheduled run for '${config.name}':`, err));
|
|
84
|
+
}, logger.child('scheduler'));
|
|
85
|
+
events.on('config:update', () => scheduler.sync(registry.list()));
|
|
86
|
+
if (opts.slackWebhookUrl) {
|
|
87
|
+
new notifier_1.SlackNotifier({
|
|
88
|
+
webhookUrl: opts.slackWebhookUrl,
|
|
89
|
+
notify: opts.slackNotify ?? 'all',
|
|
90
|
+
publicUrl: opts.publicUrl,
|
|
91
|
+
}, store, logger.child('slack')).attach(events);
|
|
92
|
+
}
|
|
93
|
+
await registry.start();
|
|
94
|
+
scheduler.sync(registry.list());
|
|
95
|
+
const app = (0, server_1.createServer)({
|
|
96
|
+
store,
|
|
97
|
+
registry,
|
|
98
|
+
scheduler,
|
|
99
|
+
runner,
|
|
100
|
+
events,
|
|
101
|
+
readWrite: opts.readWrite,
|
|
102
|
+
logger: logger.child('api'),
|
|
103
|
+
});
|
|
104
|
+
const server = app.listen(opts.port, () => {
|
|
105
|
+
logger.info(`API and UI listening on http://localhost:${opts.port}`);
|
|
106
|
+
});
|
|
107
|
+
const pruneLogs = () => {
|
|
108
|
+
store.pruneOldLogs(opts.logRetentionDays)
|
|
109
|
+
.then(count => { if (count > 0)
|
|
110
|
+
logger.info(`Pruned ${count} log row(s) older than ${opts.logRetentionDays} days`); })
|
|
111
|
+
.catch(err => logger.error('Log retention pruning failed:', err));
|
|
112
|
+
};
|
|
113
|
+
pruneLogs();
|
|
114
|
+
const pruneTimer = setInterval(pruneLogs, LOG_PRUNE_INTERVAL_MS);
|
|
115
|
+
pruneTimer.unref();
|
|
116
|
+
let shuttingDown = false;
|
|
117
|
+
const shutdown = (signal) => {
|
|
118
|
+
if (shuttingDown)
|
|
119
|
+
return;
|
|
120
|
+
shuttingDown = true;
|
|
121
|
+
logger.info(`Received ${signal}, shutting down...`);
|
|
122
|
+
clearInterval(pruneTimer);
|
|
123
|
+
scheduler.stop();
|
|
124
|
+
registry.stop();
|
|
125
|
+
server.close();
|
|
126
|
+
runner.shutdown()
|
|
127
|
+
.then(() => store.close())
|
|
128
|
+
.then(() => {
|
|
129
|
+
logger.info('Shutdown complete');
|
|
130
|
+
process.exit(0);
|
|
131
|
+
})
|
|
132
|
+
.catch(err => {
|
|
133
|
+
logger.error('Error during shutdown:', err);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
138
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
139
|
+
}
|