doc2vec 2.10.4 → 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.
@@ -0,0 +1,288 @@
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
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.createServer = createServer;
40
+ const express_1 = __importDefault(require("express"));
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ const config_registry_1 = require("./config-registry");
44
+ const types_1 = require("./types");
45
+ const SSE_HEARTBEAT_MS = 15000;
46
+ function getVersion() {
47
+ try {
48
+ return require('../../package.json').version;
49
+ }
50
+ catch {
51
+ return 'unknown';
52
+ }
53
+ }
54
+ function sseInit(res) {
55
+ res.writeHead(200, {
56
+ 'Content-Type': 'text/event-stream',
57
+ 'Cache-Control': 'no-cache',
58
+ 'Connection': 'keep-alive',
59
+ 'X-Accel-Buffering': 'no',
60
+ });
61
+ res.write(':ok\n\n');
62
+ }
63
+ function sseSend(res, event, data) {
64
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
65
+ }
66
+ function createServer(deps) {
67
+ const { store, registry, scheduler, runner, events, readWrite, logger } = deps;
68
+ const app = (0, express_1.default)();
69
+ app.use(express_1.default.json({ limit: '2mb' }));
70
+ const requireReadWrite = (_req, res, next) => {
71
+ if (!readWrite) {
72
+ res.status(403).json({ error: 'controller is running in read-only mode' });
73
+ return;
74
+ }
75
+ next();
76
+ };
77
+ const parseId = (raw) => {
78
+ const id = Number(raw);
79
+ if (!Number.isInteger(id) || id <= 0)
80
+ throw new types_1.ValidationError('invalid id');
81
+ return id;
82
+ };
83
+ const enrichConfig = (config, lastRuns) => ({
84
+ ...config,
85
+ next_run: scheduler.nextRun(config.id),
86
+ last_run: lastRuns.get(config.id) ?? null,
87
+ busy: runner.isBusy(config.id),
88
+ });
89
+ // ------------------------------------------------------------------ meta
90
+ app.get('/api/health', (_req, res) => {
91
+ res.json({ status: 'ok', mode: readWrite ? 'rw' : 'ro', version: getVersion() });
92
+ });
93
+ // ------------------------------------------------------------------ configs
94
+ app.get('/api/configs', async (_req, res) => {
95
+ const lastRuns = await store.getLastRuns();
96
+ res.json(registry.list().map(c => enrichConfig(c, lastRuns)));
97
+ });
98
+ app.get('/api/configs/:id', async (req, res) => {
99
+ const config = registry.get(parseId(String(req.params.id)));
100
+ if (!config)
101
+ throw new types_1.NotFoundError('config not found');
102
+ const lastRuns = await store.getLastRuns();
103
+ res.json(enrichConfig(config, lastRuns));
104
+ });
105
+ app.post('/api/configs', requireReadWrite, async (req, res) => {
106
+ const { filename, content } = req.body ?? {};
107
+ if (typeof filename !== 'string' || typeof content !== 'string') {
108
+ throw new types_1.ValidationError('body must contain filename and content strings');
109
+ }
110
+ const record = await registry.create(filename, content);
111
+ res.status(201).json(record);
112
+ });
113
+ app.put('/api/configs/:id', requireReadWrite, async (req, res) => {
114
+ const { content, baseHash } = req.body ?? {};
115
+ if (typeof content !== 'string' || typeof baseHash !== 'string') {
116
+ throw new types_1.ValidationError('body must contain content and baseHash strings');
117
+ }
118
+ const record = await registry.update(parseId(String(req.params.id)), content, baseHash);
119
+ res.json(record);
120
+ });
121
+ app.delete('/api/configs/:id', requireReadWrite, async (req, res) => {
122
+ await registry.delete(parseId(String(req.params.id)));
123
+ res.status(204).end();
124
+ });
125
+ app.post('/api/configs/validate', (req, res) => {
126
+ const { content } = req.body ?? {};
127
+ if (typeof content !== 'string') {
128
+ throw new types_1.ValidationError('body must contain a content string');
129
+ }
130
+ const meta = (0, config_registry_1.parseConfigMeta)(content, 'config.yaml');
131
+ res.json({
132
+ valid: !meta.parseError,
133
+ error: meta.parseError,
134
+ name: meta.name,
135
+ schedule: meta.schedule,
136
+ schedule_valid: meta.schedule ? (0, config_registry_1.isValidCron)(meta.schedule) : null,
137
+ sources: meta.sourceSummary,
138
+ });
139
+ });
140
+ app.post('/api/configs/:id/run', async (req, res) => {
141
+ const config = registry.get(parseId(String(req.params.id)));
142
+ if (!config)
143
+ throw new types_1.NotFoundError('config not found');
144
+ const run = await runner.enqueue(config, 'manual');
145
+ res.status(202).json(run);
146
+ });
147
+ app.get('/api/configs/:id/stats', async (req, res) => {
148
+ const id = parseId(String(req.params.id));
149
+ if (!registry.get(id))
150
+ throw new types_1.NotFoundError('config not found');
151
+ const days = Number(req.query.days) || 30;
152
+ res.json(await store.getConfigStats(id, days));
153
+ });
154
+ // ------------------------------------------------------------------ runs
155
+ app.get('/api/runs', async (req, res) => {
156
+ res.json(await store.listRuns({
157
+ configId: req.query.configId !== undefined ? parseId(String(req.query.configId)) : undefined,
158
+ status: req.query.status !== undefined ? String(req.query.status) : undefined,
159
+ limit: req.query.limit !== undefined ? Number(req.query.limit) : undefined,
160
+ before: req.query.before !== undefined ? Number(req.query.before) : undefined,
161
+ }));
162
+ });
163
+ app.get('/api/runs/:id', async (req, res) => {
164
+ const run = await store.getRun(parseId(String(req.params.id)));
165
+ if (!run)
166
+ throw new types_1.NotFoundError('run not found');
167
+ const config = registry.get(run.config_id) ?? await store.getConfig(run.config_id);
168
+ res.json({ ...run, config_name: config?.name ?? null });
169
+ });
170
+ app.get('/api/runs/:id/logs', async (req, res) => {
171
+ const id = parseId(String(req.params.id));
172
+ if (!await store.getRun(id))
173
+ throw new types_1.NotFoundError('run not found');
174
+ const afterSeq = Number(req.query.afterSeq) || 0;
175
+ const limit = Number(req.query.limit) || 1000;
176
+ res.json(await store.getLogs(id, afterSeq, limit));
177
+ });
178
+ app.post('/api/runs/:id/cancel', async (req, res) => {
179
+ res.json(await runner.cancel(parseId(String(req.params.id))));
180
+ });
181
+ // ------------------------------------------------------------------ SSE
182
+ app.get('/api/events', (req, res) => {
183
+ sseInit(res);
184
+ const onRunUpdate = (run) => sseSend(res, 'run:update', run);
185
+ const onConfigUpdate = () => sseSend(res, 'config:update', {});
186
+ events.on('run:update', onRunUpdate);
187
+ events.on('config:update', onConfigUpdate);
188
+ const heartbeat = setInterval(() => res.write(':hb\n\n'), SSE_HEARTBEAT_MS);
189
+ req.on('close', () => {
190
+ clearInterval(heartbeat);
191
+ events.off('run:update', onRunUpdate);
192
+ events.off('config:update', onConfigUpdate);
193
+ });
194
+ });
195
+ app.get('/api/runs/:id/logs/stream', async (req, res) => {
196
+ const id = parseId(String(req.params.id));
197
+ const run = await store.getRun(id);
198
+ if (!run)
199
+ throw new types_1.NotFoundError('run not found');
200
+ sseInit(res);
201
+ let lastSeq = Number(req.query.afterSeq) || 0;
202
+ let replayDone = false;
203
+ const pending = [];
204
+ const sendRow = (row) => {
205
+ if (row.seq > lastSeq) {
206
+ lastSeq = row.seq;
207
+ sseSend(res, 'log', row);
208
+ }
209
+ };
210
+ const onLog = (payload) => {
211
+ if (payload.runId !== id)
212
+ return;
213
+ if (!replayDone) {
214
+ pending.push(...payload.lines);
215
+ return;
216
+ }
217
+ payload.lines.forEach(sendRow);
218
+ };
219
+ const onRunUpdate = (updated) => {
220
+ if (updated.id !== id)
221
+ return;
222
+ sseSend(res, 'run:update', updated);
223
+ if (['succeeded', 'failed', 'canceled', 'skipped'].includes(updated.status)) {
224
+ sseSend(res, 'end', { status: updated.status });
225
+ }
226
+ };
227
+ events.on('run:log', onLog);
228
+ events.on('run:update', onRunUpdate);
229
+ // Replay history from the DB, then flush anything that streamed in meanwhile
230
+ try {
231
+ let batch;
232
+ do {
233
+ batch = await store.getLogs(id, lastSeq, 5000);
234
+ batch.forEach(sendRow);
235
+ } while (batch.length === 5000);
236
+ }
237
+ finally {
238
+ replayDone = true;
239
+ pending.forEach(sendRow);
240
+ pending.length = 0;
241
+ }
242
+ const current = await store.getRun(id);
243
+ if (current && ['succeeded', 'failed', 'canceled', 'skipped'].includes(current.status)) {
244
+ sseSend(res, 'end', { status: current.status });
245
+ }
246
+ const heartbeat = setInterval(() => res.write(':hb\n\n'), SSE_HEARTBEAT_MS);
247
+ req.on('close', () => {
248
+ clearInterval(heartbeat);
249
+ events.off('run:log', onLog);
250
+ events.off('run:update', onRunUpdate);
251
+ });
252
+ });
253
+ // ------------------------------------------------------------------ static UI
254
+ const uiDir = path.resolve(__dirname, '..', 'ui');
255
+ if (fs.existsSync(path.join(uiDir, 'index.html'))) {
256
+ app.use(express_1.default.static(uiDir));
257
+ // SPA fallback for client-side routes (anything that's not /api)
258
+ app.use((req, res, next) => {
259
+ if (req.method === 'GET' && !req.path.startsWith('/api')) {
260
+ res.sendFile(path.join(uiDir, 'index.html'));
261
+ return;
262
+ }
263
+ next();
264
+ });
265
+ }
266
+ else {
267
+ logger.warn(`UI assets not found at ${uiDir} — API only (build them with 'npm run build:ui')`);
268
+ }
269
+ // ------------------------------------------------------------------ errors
270
+ app.use((err, _req, res, _next) => {
271
+ if (res.headersSent)
272
+ return;
273
+ if (err instanceof types_1.ValidationError) {
274
+ res.status(400).json({ error: err.message });
275
+ }
276
+ else if (err instanceof types_1.NotFoundError) {
277
+ res.status(404).json({ error: err.message });
278
+ }
279
+ else if (err instanceof types_1.ConflictError) {
280
+ res.status(409).json({ error: err.message });
281
+ }
282
+ else {
283
+ logger.error('Unhandled API error:', err);
284
+ res.status(500).json({ error: 'internal error' });
285
+ }
286
+ });
287
+ return app;
288
+ }
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ControllerStore = void 0;
4
+ const pg_1 = require("pg");
5
+ const migrations_1 = require("./migrations");
6
+ /**
7
+ * All Postgres access for controller mode: configs, runs, and run logs.
8
+ */
9
+ class ControllerStore {
10
+ constructor(databaseUrl, logger) {
11
+ this.pool = new pg_1.Pool({ connectionString: databaseUrl, max: 10 });
12
+ this.logger = logger;
13
+ }
14
+ async init() {
15
+ await this.migrate();
16
+ await this.markOrphanedRuns();
17
+ }
18
+ async close() {
19
+ await this.pool.end();
20
+ }
21
+ async migrate() {
22
+ const client = await this.pool.connect();
23
+ try {
24
+ await client.query(`CREATE TABLE IF NOT EXISTS d2v_schema_migrations (
25
+ version INT PRIMARY KEY,
26
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
27
+ )`);
28
+ const { rows } = await client.query('SELECT COALESCE(MAX(version), 0) AS version FROM d2v_schema_migrations');
29
+ const current = Number(rows[0].version);
30
+ for (let i = current; i < migrations_1.MIGRATIONS.length; i++) {
31
+ const version = i + 1;
32
+ this.logger.info(`Applying migration ${version}`);
33
+ await client.query('BEGIN');
34
+ try {
35
+ await client.query(migrations_1.MIGRATIONS[i]);
36
+ await client.query('INSERT INTO d2v_schema_migrations (version) VALUES ($1)', [version]);
37
+ await client.query('COMMIT');
38
+ }
39
+ catch (err) {
40
+ await client.query('ROLLBACK');
41
+ throw err;
42
+ }
43
+ }
44
+ }
45
+ finally {
46
+ client.release();
47
+ }
48
+ }
49
+ /** Runs left 'queued'/'running' by a previous controller process can never finish — fail them. */
50
+ async markOrphanedRuns() {
51
+ const { rowCount } = await this.pool.query(`UPDATE d2v_runs
52
+ SET status = 'failed', error = 'controller restarted while run was in progress', finished_at = NOW()
53
+ WHERE status IN ('queued', 'running')`);
54
+ if (rowCount) {
55
+ this.logger.warn(`Marked ${rowCount} orphaned run(s) from a previous controller process as failed`);
56
+ }
57
+ }
58
+ // ------------------------------------------------------------------ configs
59
+ async upsertConfig(input) {
60
+ const { rows } = await this.pool.query(`INSERT INTO d2v_configs (path, name, content, content_hash, schedule, source_summary, parse_error)
61
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
62
+ ON CONFLICT (path) DO UPDATE SET
63
+ name = EXCLUDED.name,
64
+ content = EXCLUDED.content,
65
+ content_hash = EXCLUDED.content_hash,
66
+ schedule = EXCLUDED.schedule,
67
+ source_summary = EXCLUDED.source_summary,
68
+ parse_error = EXCLUDED.parse_error,
69
+ deleted_at = NULL,
70
+ updated_at = NOW()
71
+ RETURNING *`, [input.path, input.name, input.content, input.contentHash, input.schedule,
72
+ JSON.stringify(input.sourceSummary), input.parseError]);
73
+ return rows[0];
74
+ }
75
+ async markConfigDeleted(path) {
76
+ await this.pool.query('UPDATE d2v_configs SET deleted_at = NOW(), updated_at = NOW() WHERE path = $1', [path]);
77
+ }
78
+ async listConfigs(includeDeleted = false) {
79
+ const { rows } = await this.pool.query(`SELECT * FROM d2v_configs ${includeDeleted ? '' : 'WHERE deleted_at IS NULL'} ORDER BY name`);
80
+ return rows;
81
+ }
82
+ async getConfig(id) {
83
+ const { rows } = await this.pool.query('SELECT * FROM d2v_configs WHERE id = $1', [id]);
84
+ return rows[0] ?? null;
85
+ }
86
+ // ------------------------------------------------------------------ runs
87
+ async createRun(configId, configHash, trigger, status, error) {
88
+ const terminal = status === 'skipped';
89
+ const { rows } = await this.pool.query(`INSERT INTO d2v_runs (config_id, config_hash, trigger, status, error, finished_at)
90
+ VALUES ($1, $2, $3, $4, $5, ${terminal ? 'NOW()' : 'NULL'})
91
+ RETURNING *`, [configId, configHash, trigger, status, error ?? null]);
92
+ return rows[0];
93
+ }
94
+ async markRunStarted(runId, pid) {
95
+ const { rows } = await this.pool.query(`UPDATE d2v_runs SET status = 'running', pid = $2, started_at = NOW() WHERE id = $1 RETURNING *`, [runId, pid ?? null]);
96
+ return rows[0];
97
+ }
98
+ async finishRun(runId, outcome) {
99
+ const { rows } = await this.pool.query(`UPDATE d2v_runs
100
+ SET status = $2, exit_code = $3, error = $4, stats = $5, finished_at = NOW()
101
+ WHERE id = $1 RETURNING *`, [runId, outcome.status, outcome.exitCode ?? null, outcome.error ?? null, JSON.stringify(outcome.stats ?? {})]);
102
+ return rows[0];
103
+ }
104
+ async getRun(runId) {
105
+ const { rows } = await this.pool.query('SELECT * FROM d2v_runs WHERE id = $1', [runId]);
106
+ return rows[0] ?? null;
107
+ }
108
+ async listRuns(filter = {}) {
109
+ const conditions = [];
110
+ const params = [];
111
+ if (filter.configId !== undefined) {
112
+ params.push(filter.configId);
113
+ conditions.push(`config_id = $${params.length}`);
114
+ }
115
+ if (filter.status !== undefined) {
116
+ params.push(filter.status);
117
+ conditions.push(`status = $${params.length}`);
118
+ }
119
+ if (filter.before !== undefined) {
120
+ params.push(filter.before);
121
+ conditions.push(`id < $${params.length}`);
122
+ }
123
+ params.push(Math.min(filter.limit ?? 50, 500));
124
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
125
+ const { rows } = await this.pool.query(`SELECT * FROM d2v_runs ${where} ORDER BY id DESC LIMIT $${params.length}`, params);
126
+ return rows;
127
+ }
128
+ /** Latest run per config, for the dashboard list. */
129
+ async getLastRuns() {
130
+ const { rows } = await this.pool.query(`SELECT DISTINCT ON (config_id) * FROM d2v_runs ORDER BY config_id, id DESC`);
131
+ return new Map(rows.map((r) => [r.config_id, r]));
132
+ }
133
+ // ------------------------------------------------------------------ logs
134
+ async insertLogs(runId, rows) {
135
+ if (rows.length === 0)
136
+ return;
137
+ const values = [];
138
+ const placeholders = rows.map((row, i) => {
139
+ const base = i * 6;
140
+ values.push(runId, row.seq, row.ts, row.level, row.module, row.message);
141
+ return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6})`;
142
+ });
143
+ await this.pool.query(`INSERT INTO d2v_run_logs (run_id, seq, ts, level, module, message) VALUES ${placeholders.join(', ')}
144
+ ON CONFLICT DO NOTHING`, values);
145
+ }
146
+ async getLogs(runId, afterSeq = 0, limit = 1000) {
147
+ const { rows } = await this.pool.query(`SELECT seq, ts, level, module, message FROM d2v_run_logs
148
+ WHERE run_id = $1 AND seq > $2 ORDER BY seq LIMIT $3`, [runId, afterSeq, Math.min(limit, 5000)]);
149
+ return rows.map((r) => ({ ...r, seq: Number(r.seq) }));
150
+ }
151
+ async pruneOldLogs(retentionDays) {
152
+ const { rowCount } = await this.pool.query(`DELETE FROM d2v_run_logs USING d2v_runs
153
+ WHERE d2v_run_logs.run_id = d2v_runs.id
154
+ AND d2v_runs.finished_at < NOW() - make_interval(days => $1)`, [retentionDays]);
155
+ return rowCount ?? 0;
156
+ }
157
+ // ------------------------------------------------------------------ stats
158
+ /** Daily run counts by status + duration stats, for the config stats page. */
159
+ async getConfigStats(configId, days) {
160
+ const boundedDays = Math.min(Math.max(days, 1), 365);
161
+ const [daily, recent, totals] = await Promise.all([
162
+ this.pool.query(`SELECT date_trunc('day', queued_at)::date::text AS day, status, COUNT(*)::int AS count,
163
+ AVG(EXTRACT(EPOCH FROM (finished_at - started_at)))::float AS avg_duration_s
164
+ FROM d2v_runs
165
+ WHERE config_id = $1 AND queued_at > NOW() - make_interval(days => $2)
166
+ GROUP BY 1, 2 ORDER BY 1`, [configId, boundedDays]),
167
+ this.pool.query(`SELECT id, status, started_at, EXTRACT(EPOCH FROM (finished_at - started_at))::float AS duration_s
168
+ FROM d2v_runs
169
+ WHERE config_id = $1 AND status IN ('succeeded', 'failed') AND started_at IS NOT NULL AND finished_at IS NOT NULL
170
+ ORDER BY id DESC LIMIT 50`, [configId]),
171
+ this.pool.query(`SELECT COUNT(*)::int AS total,
172
+ COUNT(*) FILTER (WHERE status = 'succeeded')::int AS succeeded,
173
+ COUNT(*) FILTER (WHERE status = 'failed')::int AS failed,
174
+ COUNT(*) FILTER (WHERE status = 'skipped')::int AS skipped,
175
+ COUNT(*) FILTER (WHERE status = 'canceled')::int AS canceled
176
+ FROM d2v_runs
177
+ WHERE config_id = $1 AND queued_at > NOW() - make_interval(days => $2)`, [configId, boundedDays]),
178
+ ]);
179
+ return {
180
+ daily: daily.rows,
181
+ recentDurations: recent.rows.reverse(),
182
+ totals: totals.rows[0],
183
+ };
184
+ }
185
+ }
186
+ exports.ControllerStore = ControllerStore;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ // Shared types for controller mode
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ValidationError = exports.NotFoundError = exports.ConflictError = void 0;
5
+ class ConflictError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = 'ConflictError';
9
+ }
10
+ }
11
+ exports.ConflictError = ConflictError;
12
+ class NotFoundError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'NotFoundError';
16
+ }
17
+ }
18
+ exports.NotFoundError = NotFoundError;
19
+ class ValidationError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = 'ValidationError';
23
+ }
24
+ }
25
+ exports.ValidationError = ValidationError;
package/dist/database.js CHANGED
@@ -215,7 +215,13 @@ class DatabaseManager {
215
215
  }
216
216
  }
217
217
  catch (error) {
218
- logger.error(`Failed to update metadata value for ${key}:`, error);
218
+ // Best-effort by design: a failed metadata write errs toward the
219
+ // safe direction (redundant reprocessing next run — etag/lastmod/
220
+ // watermark stay behind, never skip ahead), so we warn rather than
221
+ // fail the job. Genuine disk/connection failures surface earlier at
222
+ // the chunk-write stage (storeChunkInQdrant), which shares this
223
+ // collection and throws.
224
+ logger.warn(`Failed to update metadata value for ${key} (continuing; will retry next run):`, error);
219
225
  }
220
226
  }
221
227
  static async getLastRunDate(dbConnection, repo, defaultDate, logger) {
@@ -436,7 +442,11 @@ class DatabaseManager {
436
442
  });
437
443
  }
438
444
  catch (error) {
439
- console.error("Error storing chunk in Qdrant:", error);
445
+ // Rethrow so the failure propagates to the top-level handler and the
446
+ // job exits non-zero. Swallowing here previously let a sync "succeed"
447
+ // while chunks silently failed to store (e.g. Qdrant disk full).
448
+ console.error(`Error storing chunk in Qdrant (collection ${collectionName}, url ${chunk.metadata.url}):`, error);
449
+ throw error;
440
450
  }
441
451
  }
442
452
  static removeObsoleteChunksSQLite(db, visitedUrls, urlPrefix, logger) {
@@ -518,7 +528,11 @@ class DatabaseManager {
518
528
  }
519
529
  }
520
530
  catch (error) {
531
+ // Rethrow so a failed cleanup fails the job instead of silently
532
+ // leaving orphans behind. This is idempotent and recomputed each
533
+ // run, so a rerun retries it cleanly.
521
534
  logger.error(`Error removing obsolete chunks from Qdrant:`, error);
535
+ throw error;
522
536
  }
523
537
  }
524
538
  static hasColumn(db, columnName) {
@@ -561,7 +575,12 @@ class DatabaseManager {
561
575
  logger.info(`Deleted chunks from Qdrant for URL ${url}`);
562
576
  }
563
577
  catch (error) {
578
+ // Rethrow so the failure propagates and the job exits non-zero.
579
+ // A swallowed delete leaves stale chunks behind while the sync
580
+ // reports success. Callers that treat deletion as best-effort
581
+ // (e.g. 404 cleanup) wrap this call in their own try/catch.
564
582
  logger.error(`Error deleting chunks from Qdrant for URL ${url}:`, error);
583
+ throw error;
565
584
  }
566
585
  }
567
586
  /**
@@ -726,53 +745,64 @@ class DatabaseManager {
726
745
  urlPrefix = `file://${cleanedDirPrefix}`;
727
746
  }
728
747
  logger.debug(`Checking for obsolete chunks with URL prefix: ${urlPrefix}`);
729
- const response = await client.scroll(collectionName, {
730
- limit: 10000,
731
- with_payload: true,
732
- with_vector: false,
733
- filter: {
734
- must: [
735
- {
736
- key: "url",
737
- match: {
738
- text: urlPrefix
739
- }
748
+ // Scroll through ALL matching points via next_page_offset. A single
749
+ // capped scroll would only examine the first page, so obsolete
750
+ // chunks beyond it (in collections larger than the page limit) would
751
+ // never be removed.
752
+ const filter = {
753
+ must: [
754
+ {
755
+ key: "url",
756
+ match: {
757
+ text: urlPrefix
740
758
  }
741
- ],
742
- must_not: [
743
- {
744
- key: "is_metadata",
745
- match: {
746
- value: true
747
- }
759
+ }
760
+ ],
761
+ must_not: [
762
+ {
763
+ key: "is_metadata",
764
+ match: {
765
+ value: true
748
766
  }
749
- ]
750
- }
751
- });
752
- const obsoletePointIds = response.points
753
- .filter((point) => {
754
- const url = point.payload?.url;
755
- // Double check it's not a metadata record
756
- if (point.payload?.is_metadata === true) {
757
- return false;
758
- }
759
- if (!url || !url.startsWith(urlPrefix)) {
760
- return false;
761
- }
762
- let filePath;
763
- if (isRewriteMode) {
764
- // URL rewrite mode: extract relative path and construct full file path
765
- const config = pathConfig;
766
- const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
767
- filePath = path.join(config.path, relativePath);
768
- }
769
- else {
770
- // Direct file path mode: remove file:// prefix to match with processedFiles
771
- filePath = url.startsWith('file://') ? url.substring(7) : '';
767
+ }
768
+ ]
769
+ };
770
+ const obsoletePointIds = [];
771
+ let offset = undefined;
772
+ do {
773
+ const response = await client.scroll(collectionName, {
774
+ limit: 1000,
775
+ offset,
776
+ with_payload: true,
777
+ with_vector: false,
778
+ filter
779
+ });
780
+ for (const point of response.points) {
781
+ const url = point.payload?.url;
782
+ // Double check it's not a metadata record
783
+ if (point.payload?.is_metadata === true) {
784
+ continue;
785
+ }
786
+ if (!url || !url.startsWith(urlPrefix)) {
787
+ continue;
788
+ }
789
+ let filePath;
790
+ if (isRewriteMode) {
791
+ // URL rewrite mode: extract relative path and construct full file path
792
+ const config = pathConfig;
793
+ const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
794
+ filePath = path.join(config.path, relativePath);
795
+ }
796
+ else {
797
+ // Direct file path mode: remove file:// prefix to match with processedFiles
798
+ filePath = url.startsWith('file://') ? url.substring(7) : '';
799
+ }
800
+ if (filePath && !processedFiles.has(filePath)) {
801
+ obsoletePointIds.push(point.id);
802
+ }
772
803
  }
773
- return filePath && !processedFiles.has(filePath);
774
- })
775
- .map((point) => point.id);
804
+ offset = response.next_page_offset;
805
+ } while (offset !== null && offset !== undefined);
776
806
  if (obsoletePointIds.length > 0) {
777
807
  await client.delete(collectionName, {
778
808
  points: obsoletePointIds,
@@ -784,7 +814,10 @@ class DatabaseManager {
784
814
  }
785
815
  }
786
816
  catch (error) {
817
+ // Rethrow so a failed cleanup fails the job instead of silently
818
+ // leaving orphans behind (idempotent — retried cleanly next run).
787
819
  logger.error(`Error removing obsolete chunks from Qdrant:`, error);
820
+ throw error;
788
821
  }
789
822
  }
790
823
  }