pixivflow 3.1.0 → 3.3.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/README.md +26 -1
- package/dist/commands/SchedulerCommand.js +31 -51
- package/dist/commands/scheduler-runtime.js +47 -7
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +6 -0
- package/dist/config/types.d.ts +20 -0
- package/dist/config/validation.js +11 -0
- package/dist/delivery/DeliveryDispatcher.d.ts +14 -0
- package/dist/delivery/DeliveryDispatcher.js +14 -0
- package/dist/delivery/EventCallbackDelivery.d.ts +54 -0
- package/dist/delivery/EventCallbackDelivery.js +89 -0
- package/dist/delivery/OutboxWorker.d.ts +11 -0
- package/dist/delivery/OutboxWorker.js +51 -1
- package/dist/package.json +1 -1
- package/dist/scheduler/CandidateSearchParams.d.ts +76 -0
- package/dist/scheduler/CandidateSearchParams.js +94 -0
- package/dist/scheduler/JobCancellation.d.ts +36 -0
- package/dist/scheduler/JobCancellation.js +73 -0
- package/dist/scheduler/JobEventStream.d.ts +94 -0
- package/dist/scheduler/JobEventStream.js +251 -0
- package/dist/scheduler/JobFacade.d.ts +298 -0
- package/dist/scheduler/JobFacade.js +622 -0
- package/dist/scheduler/JobProjection.d.ts +67 -0
- package/dist/scheduler/JobProjection.js +32 -0
- package/dist/scheduler/JobView.d.ts +34 -0
- package/dist/scheduler/JobView.js +82 -0
- package/dist/scheduler/ManualJobAdmission.d.ts +126 -0
- package/dist/scheduler/ManualJobAdmission.js +310 -0
- package/dist/scheduler/ManualJobService.d.ts +57 -0
- package/dist/scheduler/ManualJobService.js +91 -0
- package/dist/scheduler/ManualRefetchAdapter.d.ts +26 -0
- package/dist/scheduler/ManualRefetchAdapter.js +41 -0
- package/dist/scheduler/MultiScheduleManager.d.ts +17 -0
- package/dist/scheduler/MultiScheduleManager.js +68 -6
- package/dist/scheduler/ProtocolErrors.d.ts +68 -0
- package/dist/scheduler/ProtocolErrors.js +94 -0
- package/dist/scheduler/ScheduleTriggerServer.d.ts +56 -7
- package/dist/scheduler/ScheduleTriggerServer.js +166 -1
- package/dist/scheduler/SlotBusinessStatus.d.ts +5 -1
- package/dist/scheduler/SlotBusinessStatus.js +6 -0
- package/dist/scheduler/SlotCoordinator.d.ts +49 -5
- package/dist/scheduler/SlotCoordinator.js +89 -22
- package/dist/scheduler/StallSweep.d.ts +65 -0
- package/dist/scheduler/StallSweep.js +105 -0
- package/dist/scheduler/TargetOutcome.d.ts +39 -1
- package/dist/scheduler/TargetOutcome.js +51 -1
- package/dist/scheduler/ledger-time.d.ts +23 -0
- package/dist/scheduler/ledger-time.js +37 -0
- package/dist/storage/DatabaseMigration.js +48 -0
- package/dist/storage/repositories/DeliveryRepository.d.ts +6 -0
- package/dist/storage/repositories/DeliveryRepository.js +13 -0
- package/dist/storage/repositories/OutboxRepository.d.ts +73 -1
- package/dist/storage/repositories/OutboxRepository.js +142 -0
- package/dist/storage/repositories/SlotRepository.d.ts +34 -0
- package/dist/storage/repositories/SlotRepository.js +61 -2
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/examples/gateway/README.md +4 -0
- package/examples/onebot-adapter/README.md +139 -0
- package/examples/onebot-adapter/server.mjs +612 -0
- package/package.json +2 -1
|
@@ -9,6 +9,8 @@ exports.scheduleProvider = scheduleProvider;
|
|
|
9
9
|
const express_1 = __importDefault(require("express"));
|
|
10
10
|
const node_crypto_1 = require("node:crypto");
|
|
11
11
|
const logger_1 = require("../logger");
|
|
12
|
+
const JobFacade_1 = require("./JobFacade");
|
|
13
|
+
const ProtocolErrors_1 = require("./ProtocolErrors");
|
|
12
14
|
const version_1 = require("../version");
|
|
13
15
|
const TRIGGER_OUTCOMES = {
|
|
14
16
|
accepted: { event: 'schedule.trigger_accepted', httpStatus: 202, status: 'accepted', note: 'queued' },
|
|
@@ -93,6 +95,7 @@ class ScheduleTriggerServer {
|
|
|
93
95
|
static resolveToken(configured) {
|
|
94
96
|
return (configured ?? process.env.SCHEDULER_TRIGGER_TOKEN ?? '').trim() || undefined;
|
|
95
97
|
}
|
|
98
|
+
/** `start` returns the listening server so tests/callers can read the port. */
|
|
96
99
|
start(host, port) {
|
|
97
100
|
const app = (0, express_1.default)();
|
|
98
101
|
app.use(express_1.default.json());
|
|
@@ -108,6 +111,139 @@ class ScheduleTriggerServer {
|
|
|
108
111
|
commit: version_1.BUILD.commit,
|
|
109
112
|
});
|
|
110
113
|
});
|
|
114
|
+
// ---------------------------------------------------------------------
|
|
115
|
+
// Generic job facade — Workflow Protocol v1 (Task -> Job).
|
|
116
|
+
//
|
|
117
|
+
// Same server, same bearer token as the manual-work family (`refetchAuth`):
|
|
118
|
+
// this is not a second HTTP system and not a second execution path. Both
|
|
119
|
+
// this surface and the legacy `/internal/targets/:targetId/refetch` shim
|
|
120
|
+
// funnel into ONE shared admission function, so identity, idempotency and
|
|
121
|
+
// the terminal state can never diverge between entry points.
|
|
122
|
+
//
|
|
123
|
+
// No `refetch*` vocabulary appears on this surface, and nothing here reads
|
|
124
|
+
// `slot_name` or any consumer/business label: it is a projection adapter.
|
|
125
|
+
// ---------------------------------------------------------------------
|
|
126
|
+
app.get('/capabilities', this.refetchAuth, (req, res) => {
|
|
127
|
+
const jobs = this.handlers.jobs;
|
|
128
|
+
if (!jobs) {
|
|
129
|
+
res.status(503).json(this.jobUnavailableResponse());
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
res.json(jobs.capabilities());
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
this.jobFailure(req, res, error, 'capabilities failed');
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
app.post('/jobs', this.refetchAuth, (req, res) => {
|
|
140
|
+
const jobs = this.handlers.jobs;
|
|
141
|
+
if (!jobs) {
|
|
142
|
+
res.status(503).json(this.jobUnavailableResponse());
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const { job, replayed } = jobs.submitJob(req.body);
|
|
147
|
+
this.triggerOutcome('schedule.job_admitted', replayed ? 200 : 202, req, res, {
|
|
148
|
+
job_id: job.job_id,
|
|
149
|
+
job_type: job.job_type,
|
|
150
|
+
status: job.status,
|
|
151
|
+
disposition: replayed ? 'replayed' : 'accepted',
|
|
152
|
+
});
|
|
153
|
+
// 202 on first admission, 200 when the idempotency key resolved to an
|
|
154
|
+
// already-existing Job. Both carry the identical `job` body.
|
|
155
|
+
res.status(replayed ? 200 : 202).json({ job });
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
this.jobFailure(req, res, error, 'job admission failed');
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
app.get('/jobs', this.refetchAuth, (req, res) => {
|
|
162
|
+
const jobs = this.handlers.jobs;
|
|
163
|
+
if (!jobs) {
|
|
164
|
+
res.status(503).json(this.jobUnavailableResponse());
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const idempotencyKey = req.query.idempotency_key;
|
|
169
|
+
if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
|
|
170
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 400, {
|
|
171
|
+
message: 'idempotency_key is required',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
res.json({ jobs: jobs.jobsByIdempotencyKey(idempotencyKey), server_time: Date.now() });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
this.jobFailure(req, res, error, 'job lookup failed');
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
app.get('/jobs/:jobId', this.refetchAuth, (req, res) => {
|
|
181
|
+
const jobs = this.handlers.jobs;
|
|
182
|
+
if (!jobs) {
|
|
183
|
+
res.status(503).json(this.jobUnavailableResponse());
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const job = jobs.jobStatus(req.params.jobId);
|
|
188
|
+
if (!job) {
|
|
189
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, { message: 'unknown job' });
|
|
190
|
+
}
|
|
191
|
+
res.json(job);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
this.jobFailure(req, res, error, 'job lookup failed');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
app.post('/jobs/:jobId/cancel', this.refetchAuth, (req, res) => {
|
|
198
|
+
const jobs = this.handlers.jobs;
|
|
199
|
+
if (!jobs) {
|
|
200
|
+
res.status(503).json(this.jobUnavailableResponse());
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const job = jobs.cancelJob(req.params.jobId);
|
|
205
|
+
this.triggerOutcome('schedule.job_cancelled', 200, req, res, {
|
|
206
|
+
job_id: job.job_id,
|
|
207
|
+
status: job.status,
|
|
208
|
+
});
|
|
209
|
+
res.json(job);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
this.jobFailure(req, res, error, 'job cancel failed');
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
// The durable event stream (§events). Read-only projection of the ledger the
|
|
216
|
+
// cancel/status routes already read, so a consumer can follow a job to its
|
|
217
|
+
// terminal event instead of polling status.
|
|
218
|
+
app.get('/jobs/:jobId/events', this.refetchAuth, (req, res) => {
|
|
219
|
+
const jobs = this.handlers.jobs;
|
|
220
|
+
if (!jobs) {
|
|
221
|
+
res.status(503).json(this.jobUnavailableResponse());
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const query = (0, JobFacade_1.parseEventQuery)(req.query);
|
|
226
|
+
res.json(jobs.jobEvents(req.params.jobId, query));
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
this.jobFailure(req, res, error, 'job events failed');
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
// Ack is a cursor write, never a job mutation: the body carries only
|
|
233
|
+
// `ack_through`, and an unknown or older cursor is accepted as a no-op.
|
|
234
|
+
app.post('/jobs/:jobId/events/ack', this.refetchAuth, (req, res) => {
|
|
235
|
+
const jobs = this.handlers.jobs;
|
|
236
|
+
if (!jobs) {
|
|
237
|
+
res.status(503).json(this.jobUnavailableResponse());
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
res.json(jobs.ackJobEvents(req.params.jobId, req.body));
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
this.jobFailure(req, res, error, 'job ack failed');
|
|
245
|
+
}
|
|
246
|
+
});
|
|
111
247
|
// Read-only: list enabled schedules + their current occurrence status.
|
|
112
248
|
app.get('/internal/schedules', this.auth, (_req, res) => {
|
|
113
249
|
const schedules = this.handlers.listSchedules().map((id) => this.handlers.status(id));
|
|
@@ -291,9 +427,11 @@ class ScheduleTriggerServer {
|
|
|
291
427
|
res.status(500).json({ status: 'error', error: 'outbox drain failed; rows remain durable and retry' });
|
|
292
428
|
}
|
|
293
429
|
});
|
|
294
|
-
|
|
430
|
+
const server = app.listen(port, host, () => {
|
|
295
431
|
logger_1.logger.info('Schedule trigger server listening', { host, port, auth: this.token ? 'bearer' : 'DISABLED (no token)' });
|
|
296
432
|
});
|
|
433
|
+
this.server = server;
|
|
434
|
+
return server;
|
|
297
435
|
}
|
|
298
436
|
/**
|
|
299
437
|
* Per-request correlation, installed before every route. It records only the
|
|
@@ -345,6 +483,33 @@ class ScheduleTriggerServer {
|
|
|
345
483
|
elapsed_ms: Date.now() - startedAt,
|
|
346
484
|
};
|
|
347
485
|
}
|
|
486
|
+
/**
|
|
487
|
+
* Every generic-surface failure is written as a protocol `Error`: a closed-enum
|
|
488
|
+
* `code`, a `retryable` boolean from the published defaults, and a `detail`
|
|
489
|
+
* object. Nothing here judges a failure by its message text.
|
|
490
|
+
*/
|
|
491
|
+
jobFailure(req, res, error, stage) {
|
|
492
|
+
const { status, body } = (0, ProtocolErrors_1.protocolErrorResponse)(error);
|
|
493
|
+
if (status >= 500) {
|
|
494
|
+
logger_1.logger.error('Job API request failed', {
|
|
495
|
+
...this.attemptMeta(req, res),
|
|
496
|
+
stage,
|
|
497
|
+
error: error instanceof Error ? error.message : String(error),
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
this.triggerOutcome('schedule.job_rejected', status, req, res, { code: body.code, stage });
|
|
501
|
+
// §3: protocol failures are reported as `{ error: <$defs/Error> }`.
|
|
502
|
+
res.status(status).json({ error: body });
|
|
503
|
+
}
|
|
504
|
+
/** Fail closed when the generic surface is not mounted in this runtime. */
|
|
505
|
+
jobUnavailableResponse() {
|
|
506
|
+
return {
|
|
507
|
+
error: (0, ProtocolErrors_1.protocolErrorBody)('internal_error', {
|
|
508
|
+
message: 'job API is unavailable',
|
|
509
|
+
detail: { reason: 'job_api_disabled' },
|
|
510
|
+
}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
348
513
|
auth = (req, res, next) => {
|
|
349
514
|
this.authenticate(this.token, req, res, next);
|
|
350
515
|
};
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
* - System failures (pixiv/network/download/processing/delivery) must be kept
|
|
7
7
|
* disjoint from business no-content states so monitoring and Mini App never
|
|
8
8
|
* show "internal_error" for an empty but healthy run.
|
|
9
|
+
* - A CONSUMER CANCEL is an intentional stop, not a system failure: it must
|
|
10
|
+
* never be alertable, or every cancel would page an operator.
|
|
9
11
|
*
|
|
10
12
|
* This is a DERIVED verdict over the existing terminal cell ledger; it does not
|
|
11
13
|
* change the persisted slot phase machine (pending/running/success/partial/
|
|
12
14
|
* failed remain untouched for ledger compatibility).
|
|
13
15
|
*/
|
|
14
|
-
export type SlotBusinessStatus = 'success' | 'partial_success' | 'no_candidate' | 'duplicate_only' | 'failed';
|
|
16
|
+
export type SlotBusinessStatus = 'success' | 'partial_success' | 'no_candidate' | 'duplicate_only' | 'cancelled' | 'failed';
|
|
15
17
|
export interface SlotBusinessCounts {
|
|
16
18
|
total: number;
|
|
17
19
|
submitted: number;
|
|
@@ -21,6 +23,8 @@ export interface SlotBusinessCounts {
|
|
|
21
23
|
duplicate_exhausted?: number;
|
|
22
24
|
executor_failed: number;
|
|
23
25
|
delivery_failed: number;
|
|
26
|
+
/** Cells terminalised by a consumer/operator cancel (`cancelled_by_consumer`). */
|
|
27
|
+
cancelled?: number;
|
|
24
28
|
}
|
|
25
29
|
/** Pure projection: durable cell counts -> one business verdict. No I/O. */
|
|
26
30
|
export declare function classifySlotBusinessStatus(counts: SlotBusinessCounts): SlotBusinessStatus;
|
|
@@ -13,6 +13,10 @@ function classifySlotBusinessStatus(counts) {
|
|
|
13
13
|
return 'partial_success';
|
|
14
14
|
if (nonSubmitted === 0)
|
|
15
15
|
return 'success'; // defensive: 0 targets / all submitted
|
|
16
|
+
// A cancel is deliberate. It is only reported as such while nothing else in
|
|
17
|
+
// the occurrence actually failed — a real failure must stay visible.
|
|
18
|
+
if ((counts.cancelled ?? 0) > 0 && !systemFailed)
|
|
19
|
+
return 'cancelled';
|
|
16
20
|
if (!systemFailed) {
|
|
17
21
|
// Every non-submitted cell was a BUSINESS no-content verdict; all of them
|
|
18
22
|
// came from already-delivered candidates => duplicate_only.
|
|
@@ -36,6 +40,8 @@ function userMessageForSlotBusinessStatus(status) {
|
|
|
36
40
|
return '本轮没有发现新的可发布作品。任务已正常完成。';
|
|
37
41
|
case 'duplicate_only':
|
|
38
42
|
return '本轮没有发现新的可发布作品。任务已正常完成。';
|
|
43
|
+
case 'cancelled':
|
|
44
|
+
return '本轮任务已被取消,不会继续发布。';
|
|
39
45
|
case 'failed':
|
|
40
46
|
return '本轮任务遇到系统异常,请稍后重试或检查日志。';
|
|
41
47
|
}
|
|
@@ -57,6 +57,13 @@ export interface SlotContext {
|
|
|
57
57
|
recoveryRequestId?: string;
|
|
58
58
|
/** Recovery policy preset ('normal' | 'relaxed'); null for non-recovery slots. */
|
|
59
59
|
recoveryMode?: 'normal' | 'relaxed';
|
|
60
|
+
/**
|
|
61
|
+
* Occurrence-scoped retrieval view of a generic `candidate_search` job (§6),
|
|
62
|
+
* serialized as JSON. Persisted with the slot so a worker that recovers the
|
|
63
|
+
* occurrence re-applies the requester's retrieval instead of quietly falling
|
|
64
|
+
* back to the plan defaults. Absent for scheduled occurrences.
|
|
65
|
+
*/
|
|
66
|
+
paramsJson?: string;
|
|
60
67
|
}
|
|
61
68
|
export interface SlotCellSummary {
|
|
62
69
|
targetId: string;
|
|
@@ -94,10 +101,13 @@ export interface ScheduleOutcomeTarget {
|
|
|
94
101
|
* healthy run look broken, and a broken one look routine.
|
|
95
102
|
*
|
|
96
103
|
* Each cell lands in exactly ONE category, in this precedence:
|
|
97
|
-
* submitted -> no_match -> duplicate -> delivery_failed -> executor_failed.
|
|
104
|
+
* submitted -> no_match -> duplicate -> delivery_failed -> cancelled -> executor_failed.
|
|
98
105
|
* `delivery_failed` is checked before `executor_failed` because a terminally
|
|
99
106
|
* lost delivery ALSO leaves the cell in the `failed` state — counting it twice
|
|
100
|
-
* would invent a second failure that does not exist.
|
|
107
|
+
* would invent a second failure that does not exist. `cancelled` comes before
|
|
108
|
+
* `executor_failed` for the same reason: a consumer cancel also ends as a
|
|
109
|
+
* `failed` cell, and counting it as an executor failure would page an operator
|
|
110
|
+
* for a deliberate stop.
|
|
101
111
|
*/
|
|
102
112
|
export interface ScheduleOutcomeCells {
|
|
103
113
|
total: number;
|
|
@@ -108,6 +118,8 @@ export interface ScheduleOutcomeCells {
|
|
|
108
118
|
all_duplicates: boolean;
|
|
109
119
|
executor_failed: number;
|
|
110
120
|
delivery_failed: number;
|
|
121
|
+
/** Cells stopped by a consumer/operator cancel — deliberate, never alertable. */
|
|
122
|
+
cancelled: number;
|
|
111
123
|
targets: ScheduleOutcomeTarget[];
|
|
112
124
|
}
|
|
113
125
|
/**
|
|
@@ -119,13 +131,13 @@ export interface ScheduleOutcomeCells {
|
|
|
119
131
|
export interface ScheduleOutcomeRecord {
|
|
120
132
|
event: 'schedule.outcome';
|
|
121
133
|
/** Outcome taxonomy version; bump when business_status/reason gains codes. */
|
|
122
|
-
outcome_version:
|
|
134
|
+
outcome_version: 2;
|
|
123
135
|
schedule_id: string;
|
|
124
136
|
slot_id: string;
|
|
125
137
|
occurrence_at: string | undefined;
|
|
126
138
|
occurrence_date: string;
|
|
127
139
|
status: ScheduleOutcomeStatus;
|
|
128
|
-
/** Derived business verdict: success / partial_success / no_candidate / duplicate_only / failed. */
|
|
140
|
+
/** Derived business verdict: success / partial_success / no_candidate / duplicate_only / cancelled / failed. */
|
|
129
141
|
business_status: SlotBusinessStatus;
|
|
130
142
|
/** Monitoring gate: true only for system failures (business_status === 'failed'). */
|
|
131
143
|
alertable: boolean;
|
|
@@ -305,7 +317,35 @@ export declare class SlotCoordinator {
|
|
|
305
317
|
* terminal outcome apply).
|
|
306
318
|
*/
|
|
307
319
|
advanceFallback(slotId: string, targetId: string, reason: string, maxStages: number): number;
|
|
308
|
-
|
|
320
|
+
/**
|
|
321
|
+
* True when the cell's durable delivery intent can no longer be completed by
|
|
322
|
+
* any worker: at least one intent row exists (so this IS a delivery that was
|
|
323
|
+
* handed off, not an unstarted cell), none is confirmed, and none still has an
|
|
324
|
+
* actionable outbox row (dead / cancelled / lost with the process).
|
|
325
|
+
*
|
|
326
|
+
* Answered from the LEDGER rather than from the injected delivery port on
|
|
327
|
+
* purpose: `finish` is also called by the schedule-summary reconciler, which
|
|
328
|
+
* has no port, and "will anything still deliver this?" is a durable fact.
|
|
329
|
+
* `unknown` (no intent at all) must never converge a cell — an unstarted or
|
|
330
|
+
* port-less rollup keeps today's behaviour.
|
|
331
|
+
*/
|
|
332
|
+
private abandonedDelivery;
|
|
333
|
+
/**
|
|
334
|
+
* A LIVE lease owned by someone other than the finishing run. Blocking on it
|
|
335
|
+
* is what stops a rollup (or the reconciler, which passes no owner) from
|
|
336
|
+
* converging cells that a concurrently running worker still owns.
|
|
337
|
+
*/
|
|
338
|
+
private foreignLiveLease;
|
|
339
|
+
/**
|
|
340
|
+
* @param options.leaseOwner the lease owner of the run that is finishing.
|
|
341
|
+
* A LIVE lease held by a DIFFERENT owner means another worker owns this slot
|
|
342
|
+
* right now, so an abandoned-looking cell must be left to that worker
|
|
343
|
+
* instead of being converged from here (the reconciler path passes nothing,
|
|
344
|
+
* so any live lease blocks convergence for it).
|
|
345
|
+
*/
|
|
346
|
+
finish(slot: SlotContext, schedule: ScheduleConfig, targets: TargetConfig[], options?: {
|
|
347
|
+
leaseOwner?: string;
|
|
348
|
+
}): SlotRunSummary;
|
|
309
349
|
/**
|
|
310
350
|
* Project the terminal slot row into the one structured outcome record. Every
|
|
311
351
|
* field comes from durable state — the slot row, its cells, and the delivery
|
|
@@ -317,6 +357,10 @@ export declare class SlotCoordinator {
|
|
|
317
357
|
* durable delivery ledger through the SAME port the FSM already uses, never
|
|
318
358
|
* from an error string, and it is decided BEFORE `executor_failed` because a
|
|
319
359
|
* terminally lost delivery also leaves the cell `failed`: one cause, one count.
|
|
360
|
+
*
|
|
361
|
+
* A consumer cancel is decided before both: it also ends as a `failed` cell,
|
|
362
|
+
* but it is a deliberate stop rather than a system failure, so it must not be
|
|
363
|
+
* counted as an executor failure (which would make it alertable).
|
|
320
364
|
*/
|
|
321
365
|
private classifyCell;
|
|
322
366
|
/**
|
|
@@ -5,7 +5,9 @@ exports.timezoneForSchedule = timezoneForSchedule;
|
|
|
5
5
|
const logger_1 = require("../logger");
|
|
6
6
|
const OccurrenceResolver_1 = require("./OccurrenceResolver");
|
|
7
7
|
const TargetOutcome_1 = require("./TargetOutcome");
|
|
8
|
+
const ledger_time_1 = require("./ledger-time");
|
|
8
9
|
const SlotBusinessStatus_1 = require("./SlotBusinessStatus");
|
|
10
|
+
const ProtocolErrors_1 = require("./ProtocolErrors");
|
|
9
11
|
const WorkIdentity_1 = require("./WorkIdentity");
|
|
10
12
|
const targetRoutes_1 = require("../delivery/targetRoutes");
|
|
11
13
|
/**
|
|
@@ -31,20 +33,6 @@ function deliveryTargetsOf(target) {
|
|
|
31
33
|
return [];
|
|
32
34
|
return (0, targetRoutes_1.targetDeliveryNames)(target);
|
|
33
35
|
}
|
|
34
|
-
/**
|
|
35
|
-
* SQLite `CURRENT_TIMESTAMP` is UTC "YYYY-MM-DD HH:MM:SS". It carries no zone
|
|
36
|
-
* marker, and `Date.parse` reads that shape as LOCAL time — so the zone is
|
|
37
|
-
* added explicitly instead of being trusted to the engine.
|
|
38
|
-
*/
|
|
39
|
-
function sqliteUtcMs(value) {
|
|
40
|
-
if (!value)
|
|
41
|
-
return undefined;
|
|
42
|
-
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)
|
|
43
|
-
? `${value.replace(' ', 'T')}Z`
|
|
44
|
-
: value;
|
|
45
|
-
const ms = Date.parse(normalized);
|
|
46
|
-
return Number.isNaN(ms) ? undefined : ms;
|
|
47
|
-
}
|
|
48
36
|
/**
|
|
49
37
|
* Epoch ms -> ISO 8601 UTC, or undefined when it is not a usable instant.
|
|
50
38
|
* `toISOString` throws on an out-of-range Date, and a throw here would abort the
|
|
@@ -135,6 +123,7 @@ class SlotCoordinator {
|
|
|
135
123
|
slotName: slot.slotName,
|
|
136
124
|
manualRequestId: slot.manualRequestId ?? null,
|
|
137
125
|
correlationId: slot.correlationId ?? null,
|
|
126
|
+
paramsJson: slot.paramsJson ?? null,
|
|
138
127
|
recoveryRequestId: slot.recoveryRequestId ?? null,
|
|
139
128
|
recoveryMode: slot.recoveryMode ?? null,
|
|
140
129
|
});
|
|
@@ -434,17 +423,82 @@ class SlotCoordinator {
|
|
|
434
423
|
});
|
|
435
424
|
return next;
|
|
436
425
|
}
|
|
437
|
-
|
|
426
|
+
/**
|
|
427
|
+
* True when the cell's durable delivery intent can no longer be completed by
|
|
428
|
+
* any worker: at least one intent row exists (so this IS a delivery that was
|
|
429
|
+
* handed off, not an unstarted cell), none is confirmed, and none still has an
|
|
430
|
+
* actionable outbox row (dead / cancelled / lost with the process).
|
|
431
|
+
*
|
|
432
|
+
* Answered from the LEDGER rather than from the injected delivery port on
|
|
433
|
+
* purpose: `finish` is also called by the schedule-summary reconciler, which
|
|
434
|
+
* has no port, and "will anything still deliver this?" is a durable fact.
|
|
435
|
+
* `unknown` (no intent at all) must never converge a cell — an unstarted or
|
|
436
|
+
* port-less rollup keeps today's behaviour.
|
|
437
|
+
*/
|
|
438
|
+
abandonedDelivery(slotId, targetId) {
|
|
439
|
+
const intents = this.database.deliveries.listForSlotCell(slotId, targetId);
|
|
440
|
+
if (intents.length === 0)
|
|
441
|
+
return false;
|
|
442
|
+
if (intents.some((row) => row.status === 'delivered' || row.status === 'duplicate'))
|
|
443
|
+
return false;
|
|
444
|
+
return !intents.some((row) => row.status === 'pending' && this.database.outbox.hasActionableDelivery(row.id));
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* A LIVE lease owned by someone other than the finishing run. Blocking on it
|
|
448
|
+
* is what stops a rollup (or the reconciler, which passes no owner) from
|
|
449
|
+
* converging cells that a concurrently running worker still owns.
|
|
450
|
+
*/
|
|
451
|
+
foreignLiveLease(slotId, ownOwner) {
|
|
452
|
+
const lease = this.database.slots.getSlotLease(slotId);
|
|
453
|
+
if (lease.owner === null || lease.until === null || lease.until <= Date.now())
|
|
454
|
+
return false;
|
|
455
|
+
return ownOwner === undefined || lease.owner !== ownOwner;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* @param options.leaseOwner the lease owner of the run that is finishing.
|
|
459
|
+
* A LIVE lease held by a DIFFERENT owner means another worker owns this slot
|
|
460
|
+
* right now, so an abandoned-looking cell must be left to that worker
|
|
461
|
+
* instead of being converged from here (the reconciler path passes nothing,
|
|
462
|
+
* so any live lease blocks convergence for it).
|
|
463
|
+
*/
|
|
464
|
+
finish(slot, schedule, targets, options = {}) {
|
|
438
465
|
const membership = this.database.slots.getSlotTargetIds(slot.slotId);
|
|
439
466
|
const ids = membership.length > 0 ? membership : targets.map((t) => t.id).filter(Boolean);
|
|
467
|
+
const foreignLiveLease = this.foreignLiveLease(slot.slotId, options.leaseOwner);
|
|
440
468
|
for (const targetId of ids) {
|
|
441
469
|
const cell = this.database.slots.getCell(slot.slotId, targetId);
|
|
442
470
|
if (!cell)
|
|
443
471
|
continue;
|
|
444
472
|
// delivery_pending / artifact_ready are recoverable: the OutboxWorker (or
|
|
445
|
-
// the next trigger) resumes the SAME work, so the slot stays running
|
|
446
|
-
|
|
473
|
+
// the next trigger) resumes the SAME work, so the slot stays running — but
|
|
474
|
+
// ONLY while a durable delivery intent still has an actionable outbox row.
|
|
475
|
+
// A dead-lettered/cancelled intent (or one lost with the process) has no
|
|
476
|
+
// worker left to converge it, so the cell would stay non-terminal forever
|
|
477
|
+
// and `deriveSlotStatus` would keep reporting `running`.
|
|
478
|
+
if (cell.status === 'delivery_pending' || cell.status === 'artifact_ready') {
|
|
479
|
+
if (foreignLiveLease)
|
|
480
|
+
continue;
|
|
481
|
+
if (!this.abandonedDelivery(slot.slotId, targetId))
|
|
482
|
+
continue;
|
|
483
|
+
const error = cell.lastError ?? TargetOutcome_1.TERMINAL_REASON_MESSAGES.delivery_abandoned;
|
|
484
|
+
try {
|
|
485
|
+
this.database.slots.transitionCell(slot.slotId, targetId, 'failed', error);
|
|
486
|
+
this.database.slots.setCellTerminalReason(slot.slotId, targetId, 'delivery_abandoned', TargetOutcome_1.TERMINAL_REASON_MESSAGES.delivery_abandoned);
|
|
487
|
+
}
|
|
488
|
+
catch (reasonError) {
|
|
489
|
+
logger_1.logger.debug('Failed to converge an abandoned delivery cell', {
|
|
490
|
+
slot: slot.slotId, target: targetId,
|
|
491
|
+
error: reasonError instanceof Error ? reasonError.message : String(reasonError),
|
|
492
|
+
});
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
logger_1.logger.warn('Converged an abandoned delivery cell: no actionable outbox row remained', {
|
|
496
|
+
slot: slot.slotId,
|
|
497
|
+
target: targetId,
|
|
498
|
+
manualRequestId: slot.manualRequestId ?? null,
|
|
499
|
+
});
|
|
447
500
|
continue;
|
|
501
|
+
}
|
|
448
502
|
if (cell.status === 'pending' || cell.status === 'selected') {
|
|
449
503
|
// Ran but never reached a terminal state (target threw before delivery).
|
|
450
504
|
// Persist a normalized reason so the operator sees a first-level cause,
|
|
@@ -503,8 +557,9 @@ class SlotCoordinator {
|
|
|
503
557
|
let duplicate = 0;
|
|
504
558
|
let executor_failed = 0;
|
|
505
559
|
let delivery_failed = 0;
|
|
560
|
+
let cancelled = 0;
|
|
506
561
|
const targetsDetail = cellRows.map((cell) => {
|
|
507
|
-
const classified = this.classifyCell(cell
|
|
562
|
+
const classified = this.classifyCell(cell, slot.slotId, deliveryTargetsByTargetId);
|
|
508
563
|
if (classified === 'submitted')
|
|
509
564
|
submitted += 1;
|
|
510
565
|
else if (classified === 'no_match')
|
|
@@ -513,6 +568,8 @@ class SlotCoordinator {
|
|
|
513
568
|
duplicate += 1;
|
|
514
569
|
else if (classified === 'delivery_failed')
|
|
515
570
|
delivery_failed += 1;
|
|
571
|
+
else if (classified === 'cancelled')
|
|
572
|
+
cancelled += 1;
|
|
516
573
|
else if (classified === 'executor_failed')
|
|
517
574
|
executor_failed += 1;
|
|
518
575
|
return {
|
|
@@ -529,8 +586,8 @@ class SlotCoordinator {
|
|
|
529
586
|
// `all_duplicates` there would be a lie — so the count must be non-zero.
|
|
530
587
|
const nonSubmitted = cellRows.length - submitted;
|
|
531
588
|
const all_duplicates = nonSubmitted > 0 && duplicate === nonSubmitted;
|
|
532
|
-
const startedMs = sqliteUtcMs(slotRec?.startedAt);
|
|
533
|
-
const completedMs = sqliteUtcMs(slotRec?.completedAt);
|
|
589
|
+
const startedMs = (0, ledger_time_1.sqliteUtcMs)(slotRec?.startedAt);
|
|
590
|
+
const completedMs = (0, ledger_time_1.sqliteUtcMs)(slotRec?.completedAt);
|
|
534
591
|
const duration_ms = startedMs !== undefined && completedMs !== undefined
|
|
535
592
|
? Math.max(0, completedMs - startedMs)
|
|
536
593
|
: undefined;
|
|
@@ -543,10 +600,11 @@ class SlotCoordinator {
|
|
|
543
600
|
duplicate_exhausted: targetsDetail.filter((t) => t.terminal_reason_code === 'duplicate_exhausted').length,
|
|
544
601
|
executor_failed,
|
|
545
602
|
delivery_failed,
|
|
603
|
+
cancelled,
|
|
546
604
|
});
|
|
547
605
|
return {
|
|
548
606
|
event: 'schedule.outcome',
|
|
549
|
-
outcome_version:
|
|
607
|
+
outcome_version: 2,
|
|
550
608
|
schedule_id: slotRec?.scheduleId ?? slot.scheduleId,
|
|
551
609
|
slot_id: slot.slotId,
|
|
552
610
|
occurrence_at: isoUtcOrUndefined(occurrenceAt),
|
|
@@ -563,6 +621,7 @@ class SlotCoordinator {
|
|
|
563
621
|
all_duplicates,
|
|
564
622
|
executor_failed,
|
|
565
623
|
delivery_failed,
|
|
624
|
+
cancelled,
|
|
566
625
|
targets: targetsDetail,
|
|
567
626
|
},
|
|
568
627
|
};
|
|
@@ -572,14 +631,22 @@ class SlotCoordinator {
|
|
|
572
631
|
* durable delivery ledger through the SAME port the FSM already uses, never
|
|
573
632
|
* from an error string, and it is decided BEFORE `executor_failed` because a
|
|
574
633
|
* terminally lost delivery also leaves the cell `failed`: one cause, one count.
|
|
634
|
+
*
|
|
635
|
+
* A consumer cancel is decided before both: it also ends as a `failed` cell,
|
|
636
|
+
* but it is a deliberate stop rather than a system failure, so it must not be
|
|
637
|
+
* counted as an executor failure (which would make it alertable).
|
|
575
638
|
*/
|
|
576
|
-
classifyCell(
|
|
639
|
+
classifyCell(cell, slotId, deliveryTargetsByTargetId) {
|
|
640
|
+
const cellStatus = cell.status;
|
|
641
|
+
const targetId = cell.targetId;
|
|
577
642
|
if (cellStatus === 'submitted')
|
|
578
643
|
return 'submitted';
|
|
579
644
|
if (cellStatus === 'no_candidate')
|
|
580
645
|
return 'no_match';
|
|
581
646
|
if (cellStatus === 'duplicate')
|
|
582
647
|
return 'duplicate';
|
|
648
|
+
if (cellStatus === 'failed' && cell.terminalReasonCode === ProtocolErrors_1.CANCELLED_BY_CONSUMER)
|
|
649
|
+
return 'cancelled';
|
|
583
650
|
const deliveryTargets = deliveryTargetsByTargetId.get(targetId);
|
|
584
651
|
if (deliveryTargets && deliveryTargets.length > 0 && this.delivery) {
|
|
585
652
|
try {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveness sweep for the durable Slot ledger (§liveness).
|
|
3
|
+
*
|
|
4
|
+
* Accepted work must not be able to sit in a non-terminal state forever. Two
|
|
5
|
+
* situations can strand a Slot that no worker will ever touch again:
|
|
6
|
+
*
|
|
7
|
+
* - `queued_too_long`: the Slot was admitted (202) but nothing ever claimed it
|
|
8
|
+
* — the plan is disabled, its Scheduler is stopped, or the resource wait
|
|
9
|
+
* queue never drained. `recoverableSlots()` retries such rows on every tick,
|
|
10
|
+
* which keeps reporting them as "unfinished" forever when admission keeps
|
|
11
|
+
* failing.
|
|
12
|
+
* - `stalled_no_heartbeat`: a worker claimed the Slot and then stopped
|
|
13
|
+
* heartbeating (crash, OOM, redeploy window). Its lease expires, but nothing
|
|
14
|
+
* terminalises the row, so `countActiveSlots()` keeps the worker alive and a
|
|
15
|
+
* caller polling the job state sees a frozen `running`.
|
|
16
|
+
*
|
|
17
|
+
* Both rules are LIVENESS-based and deliberately generous: a genuinely
|
|
18
|
+
* progressing job renews its lease every 30s and writes a heartbeat, so it can
|
|
19
|
+
* never be selected — however long a real Pixiv search takes (production runs of
|
|
20
|
+
* 10-40 minutes, occasionally queued hours behind a busy account).
|
|
21
|
+
*
|
|
22
|
+
* The sweep only ever writes terminal states for cells that never made progress
|
|
23
|
+
* (`pending`/`selected`). A cell already handed to the delivery ledger keeps its
|
|
24
|
+
* own lifecycle; `SlotCoordinator.finish` is what converges an abandoned one.
|
|
25
|
+
*/
|
|
26
|
+
import type { Database } from '../storage/Database';
|
|
27
|
+
/** Nothing is ever stalled before this: a floor against a misconfigured budget. */
|
|
28
|
+
export declare const STALL_SWEEP_MIN_TIMEOUT_MS: number;
|
|
29
|
+
/** Admitted but never claimed for this long -> `queued_too_long`. */
|
|
30
|
+
export declare const DEFAULT_QUEUED_TIMEOUT_MS: number;
|
|
31
|
+
/** No heartbeat/lease for this long while `running` -> `stalled_no_heartbeat`. */
|
|
32
|
+
export declare const DEFAULT_STALL_TIMEOUT_MS: number;
|
|
33
|
+
/** Bounded batch: one tick must stay cheap on a large ledger. */
|
|
34
|
+
export declare const STALL_SWEEP_BATCH_LIMIT = 100;
|
|
35
|
+
export interface StallTimeouts {
|
|
36
|
+
queuedTimeoutMs: number;
|
|
37
|
+
stallTimeoutMs: number;
|
|
38
|
+
}
|
|
39
|
+
/** The two budgets as configured, each clamped to the floor / its default. */
|
|
40
|
+
export declare function resolveStallTimeouts(rt?: {
|
|
41
|
+
queuedTimeoutMs?: unknown;
|
|
42
|
+
stallTimeoutMs?: unknown;
|
|
43
|
+
}): StallTimeouts;
|
|
44
|
+
export interface StallSweepOptions extends Partial<StallTimeouts> {
|
|
45
|
+
/** Injectable clock (tests). */
|
|
46
|
+
now?: number;
|
|
47
|
+
/** Max rows examined per rule per tick. */
|
|
48
|
+
limit?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Slots recovery re-dispatched on this same tick. They are owned by an
|
|
51
|
+
* asynchronous run that has not claimed its lease yet, so they are excluded
|
|
52
|
+
* here: crash-resume must not be turned into a failure by the sweep that runs
|
|
53
|
+
* right after it.
|
|
54
|
+
*/
|
|
55
|
+
skipSlotIds?: ReadonlySet<string>;
|
|
56
|
+
}
|
|
57
|
+
export interface StallSweepResult {
|
|
58
|
+
/** Rows examined (both rules together). */
|
|
59
|
+
scanned: number;
|
|
60
|
+
queuedTooLong: number;
|
|
61
|
+
stalledNoHeartbeat: number;
|
|
62
|
+
}
|
|
63
|
+
/** One bounded, liveness-aware sweep. Never throws for a single bad row. */
|
|
64
|
+
export declare function sweepStalledSlots(database: Database, options?: StallSweepOptions): StallSweepResult;
|
|
65
|
+
//# sourceMappingURL=StallSweep.d.ts.map
|