pixivflow 2.20.1 → 2.20.2
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/commands/SchedulerCommand.js +15 -0
- package/dist/commands/scheduler-runtime.js +37 -14
- package/dist/delivery/DeliveryDispatcher.js +2 -2
- package/dist/delivery/HttpMultipartDelivery.js +5 -0
- package/dist/notification/NotificationPolicy.d.ts +3 -1
- package/dist/notification/NotificationPolicy.js +19 -0
- package/dist/package.json +1 -1
- package/dist/scheduler/ScheduleTriggerServer.d.ts +6 -0
- package/dist/scheduler/ScheduleTriggerServer.js +13 -0
- package/dist/scheduler/SlotCoordinator.js +1 -1
- package/dist/storage/repositories/SlotRepository.d.ts +2 -0
- package/dist/storage/repositories/SlotRepository.js +5 -0
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/package.json +1 -1
|
@@ -166,6 +166,14 @@ class SchedulerCommand extends Command_1.BaseCommand {
|
|
|
166
166
|
throw new Error('ambiguous target');
|
|
167
167
|
const plan = plans[0];
|
|
168
168
|
const target = (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).find((item) => item.id === targetId);
|
|
169
|
+
const deliveryName = target.delivery?.target;
|
|
170
|
+
const delivery = deliveryName ? cfg.delivery?.targets?.[deliveryName] : undefined;
|
|
171
|
+
if (delivery?.type !== 'httpMultipart' || !delivery.refetchOutcomeUrl?.trim()) {
|
|
172
|
+
throw new Error('refetch outcome endpoint not configured');
|
|
173
|
+
}
|
|
174
|
+
if ((target.delivery?.fields?.refetch_request_id ?? delivery.fields?.refetch_request_id) !== '{{refetchRequestId}}') {
|
|
175
|
+
throw new Error('refetch_request_id delivery field not configured');
|
|
176
|
+
}
|
|
169
177
|
const now = new Date();
|
|
170
178
|
const date = new Intl.DateTimeFormat('en-CA', {
|
|
171
179
|
timeZone: plan.timezone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
@@ -193,6 +201,13 @@ class SchedulerCommand extends Command_1.BaseCommand {
|
|
|
193
201
|
const started = manager.triggerSchedule(plan.id, { slot, onlyTarget: targetId, triggerSource: 'manual' });
|
|
194
202
|
return { slotId: slot.slotId, disposition: started ? 'accepted' : 'queued' };
|
|
195
203
|
},
|
|
204
|
+
refetchStatus: (targetId, requestId) => {
|
|
205
|
+
const slot = runtime.database.slots.findManualSlot(requestId, targetId);
|
|
206
|
+
const cell = slot && runtime.database.slots.getCell(slot.id, targetId);
|
|
207
|
+
return slot && cell
|
|
208
|
+
? { requestId, slotId: slot.id, state: cell.status, slotStatus: slot.status }
|
|
209
|
+
: null;
|
|
210
|
+
},
|
|
196
211
|
status: (scheduleId) => {
|
|
197
212
|
const cfg = resolveConfig();
|
|
198
213
|
const plan = findPlan(cfg, scheduleId);
|
|
@@ -239,6 +239,7 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
239
239
|
// Independently-pumped durable outbox (content + notifications). Started in
|
|
240
240
|
// the long-running scheduler daemon; run-once drains explicitly before exit.
|
|
241
241
|
const deliveryDispatcher = new DeliveryDispatcher_1.DeliveryDispatcher(config.delivery, buildProxyUrl(config.network));
|
|
242
|
+
const notificationPolicy = new NotificationPolicy_1.NotificationPolicy(database, config);
|
|
242
243
|
const outboxWorker = new OutboxWorker_1.OutboxWorker(database, deliveryDispatcher, {
|
|
243
244
|
retryBaseMs: config.delivery?.outboxRetryBaseMs,
|
|
244
245
|
retryMaxMs: config.delivery?.outboxRetryMaxMs,
|
|
@@ -246,9 +247,22 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
246
247
|
// failed); see settleDeliveryTerminal for the invariant it enforces.
|
|
247
248
|
onDeliveryTerminal: (deliveryId, ack) => {
|
|
248
249
|
(0, settleDeliveryTerminal_1.settleDeliveryTerminal)(database, deliveryId, ack);
|
|
250
|
+
const delivery = database.deliveries.getById(deliveryId);
|
|
251
|
+
if (delivery?.slotId && delivery.targetId)
|
|
252
|
+
notificationPolicy.noteTerminalRefetchCell(delivery.slotId, delivery.targetId);
|
|
253
|
+
},
|
|
254
|
+
onDead: (row, error) => {
|
|
255
|
+
if (!row.deliveryId)
|
|
256
|
+
return;
|
|
257
|
+
const delivery = database.deliveries.getById(row.deliveryId);
|
|
258
|
+
if (!delivery?.slotId || !delivery.targetId)
|
|
259
|
+
return;
|
|
260
|
+
new SlotCoordinator_1.SlotCoordinator(database).applyOutcome(delivery.slotId, delivery.targetId, {
|
|
261
|
+
kind: 'failed', retryable: false, error,
|
|
262
|
+
});
|
|
263
|
+
notificationPolicy.noteTerminalRefetchCell(delivery.slotId, delivery.targetId);
|
|
249
264
|
},
|
|
250
265
|
});
|
|
251
|
-
const notificationPolicy = new NotificationPolicy_1.NotificationPolicy(database, config);
|
|
252
266
|
const runJob = async (snapshot, schedule, options = {}) => {
|
|
253
267
|
const { onlyTarget, adhoc = false, slot: providedSlot } = options;
|
|
254
268
|
// A scheduled run is cron by default; an HTTP/manual trigger passes its own.
|
|
@@ -363,6 +377,14 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
363
377
|
cancelled = true;
|
|
364
378
|
slotAbandoned = true;
|
|
365
379
|
clearInterval(heartbeat);
|
|
380
|
+
for (const target of targets) {
|
|
381
|
+
if (!target.id)
|
|
382
|
+
continue;
|
|
383
|
+
if (database.slots.getCell(activeSlot.slotId, target.id)?.status === 'delivery_pending')
|
|
384
|
+
continue;
|
|
385
|
+
coordinator.applyOutcome(activeSlot.slotId, target.id, { kind: 'failed', retryable: false, error: reason });
|
|
386
|
+
notificationPolicy.noteTerminalRefetchCell(activeSlot.slotId, target.id);
|
|
387
|
+
}
|
|
366
388
|
database.slots.markSlotStatus(activeSlot.slotId, 'failed', `abandoned after scheduler timeout; no delivery (${reason})`);
|
|
367
389
|
coordinator.releaseRunLease(activeSlot.slotId, runOwner);
|
|
368
390
|
activeLeaseHooks = null;
|
|
@@ -384,6 +406,9 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
384
406
|
if (slotCtx && runTargets.length === 0) {
|
|
385
407
|
logger_1.logger.info('All slot cells already complete', { slot: slotCtx.slotId });
|
|
386
408
|
coordinator.finish(slotCtx, schedule, targets);
|
|
409
|
+
for (const target of targets)
|
|
410
|
+
if (target.id)
|
|
411
|
+
notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
|
|
387
412
|
// Release LAST: the slot must stay owned until its aggregate state has
|
|
388
413
|
// been rolled up, otherwise a concurrent trigger could claim and re-run it
|
|
389
414
|
// against a half-finished ledger.
|
|
@@ -444,19 +469,11 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
444
469
|
}
|
|
445
470
|
coordinator.applyOutcome(scheduleSlot.slotId, target.id, outcome);
|
|
446
471
|
notificationPolicy.noteOutcome(scheduleSlot.slotId, scheduleSlot, schedule, target, outcome);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const manualRequestId = scheduleSlot.manualRequestId;
|
|
453
|
-
if (manualRequestId) {
|
|
454
|
-
const terminal = outcome.kind === 'no_candidate' ||
|
|
455
|
-
outcome.kind === 'duplicate' ||
|
|
456
|
-
(outcome.kind === 'failed' && !outcome.retryable);
|
|
457
|
-
if (terminal) {
|
|
458
|
-
notificationPolicy.noteRefetchOutcome(scheduleSlot, schedule, target, manualRequestId, outcome);
|
|
459
|
-
}
|
|
472
|
+
if (scheduleSlot.manualRequestId && (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate' ||
|
|
473
|
+
(outcome.kind === 'failed' && !outcome.retryable))) {
|
|
474
|
+
// Preserve scan bookkeeping; later durable-cell reporting is the
|
|
475
|
+
// fallback for retry exhaustion, timeout, and outbox dead-letter.
|
|
476
|
+
notificationPolicy.noteRefetchOutcome(scheduleSlot, schedule, target, scheduleSlot.manualRequestId, outcome);
|
|
460
477
|
}
|
|
461
478
|
});
|
|
462
479
|
if (scheduleSlot) {
|
|
@@ -509,6 +526,9 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
509
526
|
// non-terminal so recovery resumes the same occurrence after restart.
|
|
510
527
|
if (slotCtx && shouldTerminaliseAbortedSlot(activeAbortOrigin, slotAbandoned)) {
|
|
511
528
|
coordinator.finish(slotCtx, schedule, targets);
|
|
529
|
+
for (const target of targets)
|
|
530
|
+
if (target.id)
|
|
531
|
+
notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
|
|
512
532
|
}
|
|
513
533
|
// Hand the lease back either way: a terminal Slot cannot be re-dispatched,
|
|
514
534
|
// and a non-terminal one (shutdown) must not wait for its TTL to expire.
|
|
@@ -527,6 +547,9 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
527
547
|
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
528
548
|
if (slotCtx && !slotAbandoned) {
|
|
529
549
|
const summary = coordinator.finish(slotCtx, schedule, targets);
|
|
550
|
+
for (const target of targets)
|
|
551
|
+
if (target.id)
|
|
552
|
+
notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
|
|
530
553
|
notificationPolicy.sendSlotSummary(slotCtx, schedule, summary.cells.map((c) => {
|
|
531
554
|
const t = targets.find((x) => x.id === c.targetId);
|
|
532
555
|
return {
|
|
@@ -53,8 +53,8 @@ class DeliveryDispatcher {
|
|
|
53
53
|
if (target.type !== 'httpMultipart') {
|
|
54
54
|
throw new errors_1.ConfigError(`Unsupported delivery target type: ${target.type}`);
|
|
55
55
|
}
|
|
56
|
-
if (!target.notificationUrl?.trim()) {
|
|
57
|
-
throw new errors_1.ConfigError(`Delivery target does not configure notificationUrl: ${name}`);
|
|
56
|
+
if (!(request.refetchOutcome ? target.refetchOutcomeUrl : target.notificationUrl)?.trim()) {
|
|
57
|
+
throw new errors_1.ConfigError(`Delivery target does not configure ${request.refetchOutcome ? 'refetchOutcomeUrl' : 'notificationUrl'}: ${name}`);
|
|
58
58
|
}
|
|
59
59
|
return new HttpMultipartDelivery_1.HttpMultipartDelivery(target, this.proxyUrl).notifyOnce(request);
|
|
60
60
|
}
|
|
@@ -161,6 +161,7 @@ class HttpMultipartDelivery {
|
|
|
161
161
|
method: 'POST',
|
|
162
162
|
headers,
|
|
163
163
|
body: JSON.stringify(body),
|
|
164
|
+
signal: AbortSignal.timeout(5 * 60_000),
|
|
164
165
|
};
|
|
165
166
|
if (this.dispatcher)
|
|
166
167
|
options.dispatcher = this.dispatcher;
|
|
@@ -195,6 +196,7 @@ class HttpMultipartDelivery {
|
|
|
195
196
|
body: multipart.body,
|
|
196
197
|
headers,
|
|
197
198
|
duplex: 'half',
|
|
199
|
+
signal: AbortSignal.timeout(5 * 60_000),
|
|
198
200
|
};
|
|
199
201
|
if (this.dispatcher)
|
|
200
202
|
options.dispatcher = this.dispatcher;
|
|
@@ -255,6 +257,9 @@ class HttpMultipartDelivery {
|
|
|
255
257
|
return Object.fromEntries(Object.entries(fields).map(([name, value]) => {
|
|
256
258
|
const values = Array.isArray(value) ? value : [value];
|
|
257
259
|
const rendered = values.map((item) => renderDeliveryTemplate(String(item), variables));
|
|
260
|
+
if (name === 'refetch_request_id' && rendered.some((item) => /\{\{[^{}]+\}\}/.test(item))) {
|
|
261
|
+
throw new Error('Unresolved refetch_request_id template');
|
|
262
|
+
}
|
|
258
263
|
switch (this.config.arrayFormat ?? 'comma') {
|
|
259
264
|
case 'repeat':
|
|
260
265
|
return [name, rendered];
|
|
@@ -56,6 +56,8 @@ export declare class NotificationPolicy {
|
|
|
56
56
|
* never enqueue a second verdict for the same logical attempt, and helpers
|
|
57
57
|
* that already returned remain idempotent.
|
|
58
58
|
*/
|
|
59
|
-
noteRefetchOutcome(slot: SlotContext, _schedule: ScheduleConfig, target: TargetConfig, requestId: string, outcome: TargetOutcome): void;
|
|
59
|
+
noteRefetchOutcome(slot: Pick<SlotContext, 'slotId'>, _schedule: ScheduleConfig, target: TargetConfig, requestId: string, outcome: TargetOutcome): void;
|
|
60
|
+
/** Report the durable terminal cell, including failures finalized after retry exhaustion. */
|
|
61
|
+
noteTerminalRefetchCell(slotId: string, targetId: string): void;
|
|
60
62
|
}
|
|
61
63
|
//# sourceMappingURL=NotificationPolicy.d.ts.map
|
|
@@ -172,6 +172,25 @@ class NotificationPolicy {
|
|
|
172
172
|
});
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
+
/** Report the durable terminal cell, including failures finalized after retry exhaustion. */
|
|
176
|
+
noteTerminalRefetchCell(slotId, targetId) {
|
|
177
|
+
const slot = this.database.slots.getSlot(slotId);
|
|
178
|
+
const cell = this.database.slots.getCell(slotId, targetId);
|
|
179
|
+
if (!slot?.manualRequestId || !cell)
|
|
180
|
+
return;
|
|
181
|
+
const target = this.config.targets.find((item) => item.id === targetId);
|
|
182
|
+
if (!target)
|
|
183
|
+
return;
|
|
184
|
+
const outcome = cell.status === 'no_candidate'
|
|
185
|
+
? { kind: 'no_candidate', reason: cell.lastError ?? 'no eligible candidate' }
|
|
186
|
+
: cell.status === 'duplicate'
|
|
187
|
+
? { kind: 'duplicate', workId: cell.workId ?? '', reason: cell.lastError ?? 'historical duplicate' }
|
|
188
|
+
: cell.status === 'failed'
|
|
189
|
+
? { kind: 'failed', retryable: false, error: cell.lastError ?? slot.lastError ?? 'manual refetch failed' }
|
|
190
|
+
: null;
|
|
191
|
+
if (outcome)
|
|
192
|
+
this.noteRefetchOutcome({ slotId }, { id: slot.scheduleId }, target, slot.manualRequestId, outcome);
|
|
193
|
+
}
|
|
175
194
|
}
|
|
176
195
|
exports.NotificationPolicy = NotificationPolicy;
|
|
177
196
|
/** Fold a CandidateScanSummary into the refetch-outcome bookkeeping (bounded). */
|
package/dist/package.json
CHANGED
|
@@ -105,6 +105,12 @@ export interface TriggerHandlers {
|
|
|
105
105
|
slotId: string;
|
|
106
106
|
disposition: string;
|
|
107
107
|
}>;
|
|
108
|
+
refetchStatus?(targetId: string, requestId: string): {
|
|
109
|
+
requestId: string;
|
|
110
|
+
slotId: string;
|
|
111
|
+
state: string;
|
|
112
|
+
slotStatus: string;
|
|
113
|
+
} | null;
|
|
108
114
|
}
|
|
109
115
|
export declare class ScheduleTriggerServer {
|
|
110
116
|
private readonly token;
|
|
@@ -194,6 +194,19 @@ class ScheduleTriggerServer {
|
|
|
194
194
|
res.status(status).json({ status: 'error', error: status === 500 ? 'refetch admission failed' : message });
|
|
195
195
|
}
|
|
196
196
|
});
|
|
197
|
+
app.get('/internal/targets/:targetId/refetch/:requestId', this.refetchAuth, (req, res) => {
|
|
198
|
+
const { targetId, requestId } = req.params;
|
|
199
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestId)) {
|
|
200
|
+
res.status(400).json({ status: 'error', error: 'requestId must be a UUID' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const status = this.handlers.refetchStatus?.(targetId, requestId);
|
|
204
|
+
if (!status) {
|
|
205
|
+
res.status(404).json({ status: 'error', error: 'manual refetch not found' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
res.json(status);
|
|
209
|
+
});
|
|
197
210
|
// Convergence endpoint: after a machine stop/start, an operator or an
|
|
198
211
|
// external watcher can ask the process to flush due deliveries/notifications
|
|
199
212
|
// without running candidate selection. Deployment-agnostic (no platform refs).
|
|
@@ -168,7 +168,7 @@ class SlotCoordinator {
|
|
|
168
168
|
const cell = this.database.slots.getCell(slotId, target.id);
|
|
169
169
|
if (!cell)
|
|
170
170
|
continue;
|
|
171
|
-
if (cell.status === 'submitted' || cell.status === 'no_candidate')
|
|
171
|
+
if (cell.status === 'submitted' || cell.status === 'no_candidate' || cell.status === 'duplicate' || cell.status === 'failed')
|
|
172
172
|
continue;
|
|
173
173
|
// A cell whose work already has a durable delivery intent is NOT the
|
|
174
174
|
// scheduler's to re-run: the OutboxWorker retries the SAME work to a
|
|
@@ -53,6 +53,8 @@ export interface SlotItemRecord {
|
|
|
53
53
|
* the same row instead of emitting a second work for the same slot/target.
|
|
54
54
|
*/
|
|
55
55
|
export declare class SlotRepository extends BaseRepository {
|
|
56
|
+
/** Exact manual request/target lookup for authenticated convergence checks. */
|
|
57
|
+
findManualSlot(requestId: string, targetId: string): SlotRecord | null;
|
|
56
58
|
/**
|
|
57
59
|
* Fetch an existing slot or create it. On creation the schedule's target
|
|
58
60
|
* membership is snapshotted (target_ids); a later config reload never mutates
|
|
@@ -12,6 +12,11 @@ const BaseRepository_1 = require("./BaseRepository");
|
|
|
12
12
|
* the same row instead of emitting a second work for the same slot/target.
|
|
13
13
|
*/
|
|
14
14
|
class SlotRepository extends BaseRepository_1.BaseRepository {
|
|
15
|
+
/** Exact manual request/target lookup for authenticated convergence checks. */
|
|
16
|
+
findManualSlot(requestId, targetId) {
|
|
17
|
+
const rows = this.db.prepare(`SELECT * FROM schedule_slots WHERE manual_request_id = ?`).all(requestId);
|
|
18
|
+
return rows.map((row) => this.toSlot(row)).find((slot) => slot.targetIds.includes(targetId)) ?? null;
|
|
19
|
+
}
|
|
15
20
|
/**
|
|
16
21
|
* Fetch an existing slot or create it. On creation the schedule's target
|
|
17
22
|
* membership is snapshotted (target_ids); a later config reload never mutates
|
package/dist/version.js
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BUILD = void 0;
|
|
4
4
|
// GENERATED by scripts/write-version.js — do not edit manually.
|
|
5
|
-
exports.BUILD = { version: '2.20.
|
|
5
|
+
exports.BUILD = { version: '2.20.2', commit: 'cdaedd018e4d' };
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
package/dist/webui/package.json
CHANGED
package/package.json
CHANGED