pixivflow 3.2.0 → 3.4.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 +92 -0
- package/dist/scheduler/CandidateSearchParams.js +103 -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 +305 -0
- package/dist/scheduler/JobFacade.js +670 -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 +134 -0
- package/dist/scheduler/ManualJobAdmission.js +316 -0
- package/dist/scheduler/ManualJobService.d.ts +57 -0
- package/dist/scheduler/ManualJobService.js +92 -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/package.json +1 -1
|
@@ -6,6 +6,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
6
6
|
const promises_1 = require("node:fs/promises");
|
|
7
7
|
const errorClass_1 = require("./errorClass");
|
|
8
8
|
const redact_1 = require("../utils/redact");
|
|
9
|
+
const ProtocolErrors_1 = require("../scheduler/ProtocolErrors");
|
|
9
10
|
const logger_1 = require("../logger");
|
|
10
11
|
/** Exponential backoff with jitter, capped. */
|
|
11
12
|
function backoffDelayMs(attempt, base, max) {
|
|
@@ -81,6 +82,10 @@ class OutboxWorker {
|
|
|
81
82
|
break;
|
|
82
83
|
let deferred = false;
|
|
83
84
|
for (const row of rows) {
|
|
85
|
+
if (this.stopCancelledWork(row)) {
|
|
86
|
+
dead++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
84
89
|
const readiness = await this.checkReadiness(row);
|
|
85
90
|
if (!readiness.ready) {
|
|
86
91
|
this.database.outbox.release(row.id);
|
|
@@ -114,6 +119,8 @@ class OutboxWorker {
|
|
|
114
119
|
this.database.outbox.release(row.id);
|
|
115
120
|
continue;
|
|
116
121
|
}
|
|
122
|
+
if (this.stopCancelledWork(row))
|
|
123
|
+
continue;
|
|
117
124
|
const readiness = await this.checkReadiness(row);
|
|
118
125
|
if (!readiness.ready) {
|
|
119
126
|
this.database.outbox.release(row.id);
|
|
@@ -138,6 +145,36 @@ class OutboxWorker {
|
|
|
138
145
|
}
|
|
139
146
|
return { ready: await this.dispatcher.isReady(row.deliveryTarget) };
|
|
140
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Honour a consumer cancel.
|
|
150
|
+
*
|
|
151
|
+
* A job cancelled while a delivery attempt was in flight (or waiting to
|
|
152
|
+
* retry) must not be delivered afterwards: the ledger's cell already carries
|
|
153
|
+
* the terminal `cancelled_by_consumer` verdict, so the intent behind this row
|
|
154
|
+
* is void. The row is dead-lettered rather than retried — no amount of
|
|
155
|
+
* retrying can make a cancelled delivery correct — and the skip is logged so
|
|
156
|
+
* the operator can see what did NOT happen.
|
|
157
|
+
*/
|
|
158
|
+
stopCancelledWork(row) {
|
|
159
|
+
if (!row.deliveryId)
|
|
160
|
+
return false;
|
|
161
|
+
const delivery = this.database.deliveries.getById(row.deliveryId);
|
|
162
|
+
if (!delivery?.slotId || !delivery.targetId)
|
|
163
|
+
return false;
|
|
164
|
+
const cell = this.database.slots.getCell(delivery.slotId, delivery.targetId);
|
|
165
|
+
if (!cell || cell.status !== 'failed' || cell.terminalReasonCode !== ProtocolErrors_1.CANCELLED_BY_CONSUMER) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const reason = 'work cancelled by consumer';
|
|
169
|
+
this.database.outbox.markDead(row.id, reason, Date.now());
|
|
170
|
+
logger_1.logger.warn('Delivery skipped: work cancelled by consumer', {
|
|
171
|
+
outboxId: row.id,
|
|
172
|
+
deliveryId: delivery.id,
|
|
173
|
+
slot: delivery.slotId,
|
|
174
|
+
target: delivery.targetId,
|
|
175
|
+
});
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
141
178
|
/** Record at most one deferral per outbox row per 60s (cold-start poll guard). */
|
|
142
179
|
recordDeferred(row, probe) {
|
|
143
180
|
const now = Date.now();
|
|
@@ -223,6 +260,16 @@ class OutboxWorker {
|
|
|
223
260
|
: undefined,
|
|
224
261
|
});
|
|
225
262
|
}
|
|
263
|
+
else if (row.kind === 'event_callback') {
|
|
264
|
+
// A `$defs/Event` posted to the Task's declared callback_url. It reuses
|
|
265
|
+
// the whole outbox contract: the idempotency key is the event_id, so a
|
|
266
|
+
// duplicate intent dedupes here and a retry re-sends the same event.
|
|
267
|
+
const payload = JSON.parse(row.payloadJson);
|
|
268
|
+
await this.dispatcher.deliverEventCallback(row.deliveryTarget, {
|
|
269
|
+
payload,
|
|
270
|
+
idempotencyKey: row.idempotencyKey ?? row.id,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
226
273
|
else {
|
|
227
274
|
const payload = JSON.parse(row.payloadJson);
|
|
228
275
|
const result = await this.dispatcher.deliver(row.deliveryTarget, {
|
|
@@ -258,7 +305,10 @@ class OutboxWorker {
|
|
|
258
305
|
}
|
|
259
306
|
catch (error) {
|
|
260
307
|
const message = (0, redact_1.redactError)(error).slice(0, 1000);
|
|
261
|
-
|
|
308
|
+
// A provider that answers with an HTTP status is classified by that status
|
|
309
|
+
// (429/5xx retry, other 4xx dead-letter); transport errors classify from
|
|
310
|
+
// their message as before.
|
|
311
|
+
const { errorClass, retryable } = (0, errorClass_1.classifyError)(error, error?.status);
|
|
262
312
|
// Deterministic failures cannot improve under the same idempotent intent.
|
|
263
313
|
// Retrying them only leaves the owning Slot running until the budget expires.
|
|
264
314
|
if (!retryable) {
|
package/dist/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Occurrence-scoped retrieval view for `candidate_search` (§6 `$defs/CandidateSearchParams`).
|
|
3
|
+
*
|
|
4
|
+
* A generic job may narrow or redirect HOW it looks for a work — tags, expansion,
|
|
5
|
+
* scan depth, exclusions — but never WHAT it delivers: `applyCandidateSearchParams`
|
|
6
|
+
* is a pure function over a target snapshot that touches retrieval levers only.
|
|
7
|
+
* Delivery wiring, the target id and the plan identity are structurally out of
|
|
8
|
+
* reach here, which is why this mapping lives in one small pure module instead of
|
|
9
|
+
* being spread over the run path.
|
|
10
|
+
*
|
|
11
|
+
* The requested view is stored on the manual Slot (the same occurrence-scoped
|
|
12
|
+
* precedent as `recovery_mode`) so a worker that resumes a crashed job re-applies
|
|
13
|
+
* exactly the retrieval the requester asked for. Nothing here writes global
|
|
14
|
+
* config: a manual job can never change future scheduled runs.
|
|
15
|
+
*/
|
|
16
|
+
import { TargetConfig } from '../config';
|
|
17
|
+
/** `$defs/CandidateSearchParams.constraints.exclude` item. */
|
|
18
|
+
export interface CandidateSearchExclude {
|
|
19
|
+
kind: 'work' | 'candidate' | 'tag';
|
|
20
|
+
id: string;
|
|
21
|
+
}
|
|
22
|
+
/** `$defs/CandidateSearchParams`. Only the retrieval/constraint part is modelled. */
|
|
23
|
+
export interface CandidateSearchParams {
|
|
24
|
+
source?: {
|
|
25
|
+
platform?: string;
|
|
26
|
+
account?: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Scope selector: which of the producer's CONFIGURED targets this job runs
|
|
30
|
+
* for. It selects an existing target — it never overrides that target's
|
|
31
|
+
* delivery wiring or plan identity (see `ManualJobAdmission.resolveTarget`).
|
|
32
|
+
* It is deliberately NOT part of the stored retrieval view: the admission
|
|
33
|
+
* lifts it into `CandidateSearchJobRequest.targetSelector`, so a job that only
|
|
34
|
+
* names a target runs that target exactly as configured. This is what makes
|
|
35
|
+
* the generic `POST /jobs` face equivalent to the legacy refetch entry point,
|
|
36
|
+
* whose target used to live in the request path.
|
|
37
|
+
*/
|
|
38
|
+
target_id?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Retrieval override. Optional: omitting it (with or without `target_id`)
|
|
41
|
+
* means "run the resolved target's own configured search", which is what every
|
|
42
|
+
* pre-protocol refetch did.
|
|
43
|
+
*/
|
|
44
|
+
query?: {
|
|
45
|
+
tags: string[];
|
|
46
|
+
expand?: boolean;
|
|
47
|
+
};
|
|
48
|
+
constraints?: {
|
|
49
|
+
exclude?: CandidateSearchExclude[];
|
|
50
|
+
limit?: number;
|
|
51
|
+
scan_limit?: number;
|
|
52
|
+
work_types?: string[];
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Scan limits are clamped to the same 1..100 window the config validation uses. */
|
|
56
|
+
export declare const CANDIDATE_SEARCH_SCAN_LIMIT_MAX = 100;
|
|
57
|
+
/**
|
|
58
|
+
* Project one target through a requested retrieval view (pure; never mutates the
|
|
59
|
+
* input or the global config).
|
|
60
|
+
*
|
|
61
|
+
* - `query.tags` becomes the tag expression (`tag` is the repo's space-joined
|
|
62
|
+
* multi-tag field).
|
|
63
|
+
* - `query.expand` maps to `tagRelation: 'or'`: for multiple tags that is exactly
|
|
64
|
+
* "recall expansion" (union instead of intersection), and for a single tag it
|
|
65
|
+
* is a no-op rather than an invented behaviour.
|
|
66
|
+
* - `constraints.limit` / `constraints.scan_limit` feed the existing per-target
|
|
67
|
+
* knobs, clamped to the validated window and never below `limit`.
|
|
68
|
+
*
|
|
69
|
+
* `constraints.exclude` and `constraints.work_types` are NOT applied here: an
|
|
70
|
+
* exclusion is a run-level duplicate filter (`excludedWorkIdsFromParams`) and a
|
|
71
|
+
* work-type restriction is checked against the resolved target by the admission,
|
|
72
|
+
* where the target is known.
|
|
73
|
+
*/
|
|
74
|
+
export declare function applyCandidateSearchParams(target: TargetConfig, params: CandidateSearchParams): TargetConfig;
|
|
75
|
+
/**
|
|
76
|
+
* Map `constraints.exclude` onto the existing per-run duplicate history.
|
|
77
|
+
*
|
|
78
|
+
* A `kind: 'work'` exclusion is a work id the runner must not select. The ledger
|
|
79
|
+
* keys history by work type, and a protocol caller does not know the producer's
|
|
80
|
+
* work type, so the id is excluded for both types — narrowing, never widening.
|
|
81
|
+
*/
|
|
82
|
+
export declare function excludedWorkIdsFromParams(params: CandidateSearchParams): {
|
|
83
|
+
illustration: string[];
|
|
84
|
+
novel: string[];
|
|
85
|
+
} | null;
|
|
86
|
+
/**
|
|
87
|
+
* Read the retrieval view back off a Slot row. Tolerant by design: a row written
|
|
88
|
+
* by an older build (or corrupted JSON) yields `null`, which means "run the plan
|
|
89
|
+
* exactly as configured" — the behaviour of every pre-existing slot.
|
|
90
|
+
*/
|
|
91
|
+
export declare function parseCandidateSearchParamsJson(json: string | null | undefined): CandidateSearchParams | null;
|
|
92
|
+
//# sourceMappingURL=CandidateSearchParams.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Occurrence-scoped retrieval view for `candidate_search` (§6 `$defs/CandidateSearchParams`).
|
|
4
|
+
*
|
|
5
|
+
* A generic job may narrow or redirect HOW it looks for a work — tags, expansion,
|
|
6
|
+
* scan depth, exclusions — but never WHAT it delivers: `applyCandidateSearchParams`
|
|
7
|
+
* is a pure function over a target snapshot that touches retrieval levers only.
|
|
8
|
+
* Delivery wiring, the target id and the plan identity are structurally out of
|
|
9
|
+
* reach here, which is why this mapping lives in one small pure module instead of
|
|
10
|
+
* being spread over the run path.
|
|
11
|
+
*
|
|
12
|
+
* The requested view is stored on the manual Slot (the same occurrence-scoped
|
|
13
|
+
* precedent as `recovery_mode`) so a worker that resumes a crashed job re-applies
|
|
14
|
+
* exactly the retrieval the requester asked for. Nothing here writes global
|
|
15
|
+
* config: a manual job can never change future scheduled runs.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX = void 0;
|
|
19
|
+
exports.applyCandidateSearchParams = applyCandidateSearchParams;
|
|
20
|
+
exports.excludedWorkIdsFromParams = excludedWorkIdsFromParams;
|
|
21
|
+
exports.parseCandidateSearchParamsJson = parseCandidateSearchParamsJson;
|
|
22
|
+
/** Scan limits are clamped to the same 1..100 window the config validation uses. */
|
|
23
|
+
exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX = 100;
|
|
24
|
+
function clamp(value, min, max) {
|
|
25
|
+
return Math.max(min, Math.min(max, value));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Project one target through a requested retrieval view (pure; never mutates the
|
|
29
|
+
* input or the global config).
|
|
30
|
+
*
|
|
31
|
+
* - `query.tags` becomes the tag expression (`tag` is the repo's space-joined
|
|
32
|
+
* multi-tag field).
|
|
33
|
+
* - `query.expand` maps to `tagRelation: 'or'`: for multiple tags that is exactly
|
|
34
|
+
* "recall expansion" (union instead of intersection), and for a single tag it
|
|
35
|
+
* is a no-op rather than an invented behaviour.
|
|
36
|
+
* - `constraints.limit` / `constraints.scan_limit` feed the existing per-target
|
|
37
|
+
* knobs, clamped to the validated window and never below `limit`.
|
|
38
|
+
*
|
|
39
|
+
* `constraints.exclude` and `constraints.work_types` are NOT applied here: an
|
|
40
|
+
* exclusion is a run-level duplicate filter (`excludedWorkIdsFromParams`) and a
|
|
41
|
+
* work-type restriction is checked against the resolved target by the admission,
|
|
42
|
+
* where the target is known.
|
|
43
|
+
*/
|
|
44
|
+
function applyCandidateSearchParams(target, params) {
|
|
45
|
+
const tags = params.query?.tags;
|
|
46
|
+
const limit = params.constraints?.limit !== undefined ? clamp(params.constraints.limit, 1, exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX) : target.limit;
|
|
47
|
+
const scanLimit = params.constraints?.scan_limit !== undefined
|
|
48
|
+
? clamp(params.constraints.scan_limit, limit ?? 1, exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX)
|
|
49
|
+
: target.candidateScanLimit;
|
|
50
|
+
const constrained = {
|
|
51
|
+
...target,
|
|
52
|
+
...(limit !== undefined ? { limit } : {}),
|
|
53
|
+
...(scanLimit !== undefined ? { candidateScanLimit: scanLimit } : {}),
|
|
54
|
+
};
|
|
55
|
+
// `query` is optional: a job may carry only a scope selector and/or
|
|
56
|
+
// constraints, in which case the retrieval side stays exactly as the target is
|
|
57
|
+
// configured. (`parseCandidateSearchParamsJson` already yields `null` for such
|
|
58
|
+
// a stored view, so this is the belt to that braces.)
|
|
59
|
+
if (!tags || tags.length === 0)
|
|
60
|
+
return constrained;
|
|
61
|
+
return {
|
|
62
|
+
...constrained,
|
|
63
|
+
tag: tags.join(' '),
|
|
64
|
+
...(tags.length > 1 && params.query?.expand === true ? { tagRelation: 'or' } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Map `constraints.exclude` onto the existing per-run duplicate history.
|
|
69
|
+
*
|
|
70
|
+
* A `kind: 'work'` exclusion is a work id the runner must not select. The ledger
|
|
71
|
+
* keys history by work type, and a protocol caller does not know the producer's
|
|
72
|
+
* work type, so the id is excluded for both types — narrowing, never widening.
|
|
73
|
+
*/
|
|
74
|
+
function excludedWorkIdsFromParams(params) {
|
|
75
|
+
const ids = (params.constraints?.exclude ?? [])
|
|
76
|
+
.filter((entry) => entry.kind === 'work')
|
|
77
|
+
.map((entry) => entry.id)
|
|
78
|
+
.filter((id) => id.length > 0);
|
|
79
|
+
if (ids.length === 0)
|
|
80
|
+
return null;
|
|
81
|
+
return { illustration: [...ids], novel: [...ids] };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read the retrieval view back off a Slot row. Tolerant by design: a row written
|
|
85
|
+
* by an older build (or corrupted JSON) yields `null`, which means "run the plan
|
|
86
|
+
* exactly as configured" — the behaviour of every pre-existing slot.
|
|
87
|
+
*/
|
|
88
|
+
function parseCandidateSearchParamsJson(json) {
|
|
89
|
+
if (!json)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(json);
|
|
93
|
+
if (!parsed || typeof parsed !== 'object')
|
|
94
|
+
return null;
|
|
95
|
+
if (!Array.isArray(parsed.query?.tags) || parsed.query.tags.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=CandidateSearchParams.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer-initiated cancellation of one job, in ONE transaction.
|
|
3
|
+
*
|
|
4
|
+
* The protocol has no separate "cancelled" ledger state on purpose (§11.1: no
|
|
5
|
+
* new cell-FSM state may be invented). A cancel is therefore expressed with the
|
|
6
|
+
* existing terminal cell state: the cell becomes `failed` carrying the
|
|
7
|
+
* dedicated terminal reason `cancelled_by_consumer`, which the taxonomy treats
|
|
8
|
+
* as an operator/consumer stop rather than a Pixiv failure, and which the job
|
|
9
|
+
* facade surfaces as protocol `status: "cancelled"`.
|
|
10
|
+
*
|
|
11
|
+
* Stopping delivery is part of the same intent: every actionable outbox row for
|
|
12
|
+
* the cancelled cells is cancelled inside the transaction, and the delivery
|
|
13
|
+
* worker refuses to (re)attempt a delivery whose cell carries the cancel
|
|
14
|
+
* reason — a cancel that is undone by a retry would be worse than no cancel.
|
|
15
|
+
*/
|
|
16
|
+
import type { Database } from '../storage/Database';
|
|
17
|
+
/** The user-facing business message for a consumer cancel. */
|
|
18
|
+
export declare const CANCELLED_BY_CONSUMER_MESSAGE = "\u4EFB\u52A1\u5DF2\u88AB\u53D6\u6D88";
|
|
19
|
+
export interface JobCancellationResult {
|
|
20
|
+
slotId: string;
|
|
21
|
+
/** True when THIS call performed the terminal transition. */
|
|
22
|
+
cancelled: boolean;
|
|
23
|
+
/** True when the work was already terminal before this call (idempotent replay). */
|
|
24
|
+
alreadyTerminal: boolean;
|
|
25
|
+
/** Outbox intents this call stopped from ever being attempted again. */
|
|
26
|
+
stoppedDeliveries: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Cancel one job (a slot) by id. Idempotent: a second call observes a terminal
|
|
30
|
+
* slot and reports `alreadyTerminal` without touching the ledger.
|
|
31
|
+
*
|
|
32
|
+
* A slot that already delivered something is never downgraded to `failed`; it
|
|
33
|
+
* keeps its delivered cell and rolls up as `partial`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function cancelConsumerJob(database: Database, slotId: string, now?: number): JobCancellationResult;
|
|
36
|
+
//# sourceMappingURL=JobCancellation.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Consumer-initiated cancellation of one job, in ONE transaction.
|
|
4
|
+
*
|
|
5
|
+
* The protocol has no separate "cancelled" ledger state on purpose (§11.1: no
|
|
6
|
+
* new cell-FSM state may be invented). A cancel is therefore expressed with the
|
|
7
|
+
* existing terminal cell state: the cell becomes `failed` carrying the
|
|
8
|
+
* dedicated terminal reason `cancelled_by_consumer`, which the taxonomy treats
|
|
9
|
+
* as an operator/consumer stop rather than a Pixiv failure, and which the job
|
|
10
|
+
* facade surfaces as protocol `status: "cancelled"`.
|
|
11
|
+
*
|
|
12
|
+
* Stopping delivery is part of the same intent: every actionable outbox row for
|
|
13
|
+
* the cancelled cells is cancelled inside the transaction, and the delivery
|
|
14
|
+
* worker refuses to (re)attempt a delivery whose cell carries the cancel
|
|
15
|
+
* reason — a cancel that is undone by a retry would be worse than no cancel.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.CANCELLED_BY_CONSUMER_MESSAGE = void 0;
|
|
19
|
+
exports.cancelConsumerJob = cancelConsumerJob;
|
|
20
|
+
const ProtocolErrors_1 = require("./ProtocolErrors");
|
|
21
|
+
const SlotStateMachine_1 = require("./SlotStateMachine");
|
|
22
|
+
/** The user-facing business message for a consumer cancel. */
|
|
23
|
+
exports.CANCELLED_BY_CONSUMER_MESSAGE = '任务已被取消';
|
|
24
|
+
/**
|
|
25
|
+
* Cancel one job (a slot) by id. Idempotent: a second call observes a terminal
|
|
26
|
+
* slot and reports `alreadyTerminal` without touching the ledger.
|
|
27
|
+
*
|
|
28
|
+
* A slot that already delivered something is never downgraded to `failed`; it
|
|
29
|
+
* keeps its delivered cell and rolls up as `partial`.
|
|
30
|
+
*/
|
|
31
|
+
function cancelConsumerJob(database, slotId, now = Date.now()) {
|
|
32
|
+
return database.transaction(() => {
|
|
33
|
+
const slot = database.slots.getSlot(slotId);
|
|
34
|
+
if (!slot) {
|
|
35
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
|
|
36
|
+
message: 'unknown job',
|
|
37
|
+
detail: { reason: 'unknown_job', job_id: slotId },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const cells = slot.targetIds
|
|
41
|
+
.map((targetId) => database.slots.getCell(slotId, targetId))
|
|
42
|
+
.filter((cell) => cell !== null);
|
|
43
|
+
const open = cells.filter((cell) => !SlotStateMachine_1.TERMINAL_CELL_STATES.has(cell.status));
|
|
44
|
+
if (open.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
slotId,
|
|
47
|
+
cancelled: false,
|
|
48
|
+
alreadyTerminal: true,
|
|
49
|
+
stoppedDeliveries: 0,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
let stoppedDeliveries = 0;
|
|
53
|
+
for (const cell of open) {
|
|
54
|
+
for (const delivery of database.deliveries.listForSlotCell(slotId, cell.targetId)) {
|
|
55
|
+
if (!database.outbox.hasActionableDelivery(delivery.id))
|
|
56
|
+
continue;
|
|
57
|
+
const row = database.outbox.listForDeliveryIds([delivery.id]).get(delivery.id);
|
|
58
|
+
if (row && database.outbox.cancel(row.id, now))
|
|
59
|
+
stoppedDeliveries++;
|
|
60
|
+
}
|
|
61
|
+
database.slots.transitionCell(slotId, cell.targetId, 'failed', exports.CANCELLED_BY_CONSUMER_MESSAGE);
|
|
62
|
+
database.slots.setCellTerminalReason(slotId, cell.targetId, ProtocolErrors_1.CANCELLED_BY_CONSUMER, exports.CANCELLED_BY_CONSUMER_MESSAGE);
|
|
63
|
+
}
|
|
64
|
+
// Never report a slot with a confirmed delivery as failed.
|
|
65
|
+
const delivered = cells.some((cell) => cell.status === 'submitted');
|
|
66
|
+
database.slots.markSlotStatus(slotId, delivered ? 'partial' : 'failed', exports.CANCELLED_BY_CONSUMER_MESSAGE);
|
|
67
|
+
const lease = database.slots.getSlotLease(slotId);
|
|
68
|
+
if (lease.owner)
|
|
69
|
+
database.slots.releaseSlotLease(slotId, lease.owner);
|
|
70
|
+
return { slotId, cancelled: true, alreadyTerminal: false, stoppedDeliveries };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=JobCancellation.js.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable job event stream (Workflow Protocol v1 §events).
|
|
3
|
+
*
|
|
4
|
+
* The stream is a PROJECTION of `delivery_events` — the log the delivery
|
|
5
|
+
* subsystem already writes — plus ONE durable ack cursor
|
|
6
|
+
* (`job_event_cursors`). There is no second event queue and no second table
|
|
7
|
+
* family: `slot_id` is the job id, `ts` is the protocol `at`, and `event` is the
|
|
8
|
+
* internal kind translated by the single mapping in `JobFacade`.
|
|
9
|
+
*
|
|
10
|
+
* Three properties this module owns, because they are protocol obligations:
|
|
11
|
+
*
|
|
12
|
+
* 1. **A terminal job always has a terminal event.** `SlotCoordinator`'s
|
|
13
|
+
* `execution.summary` hook only fires for success/partial/failed, and
|
|
14
|
+
* cancelled/expired slots would never produce one. Instead of widening the
|
|
15
|
+
* slot/business-status logic, the lifecycle events are *reconciled from the
|
|
16
|
+
* durable projection*: whatever the ledger already implies must exist, is
|
|
17
|
+
* materialised exactly once (mirroring `reconcileScheduleSummaries`). The
|
|
18
|
+
* page is therefore never empty for a job that exists.
|
|
19
|
+
*
|
|
20
|
+
* 2. **Ack is monotonic, idempotent and side-effect free.** It writes one row
|
|
21
|
+
* in `job_event_cursors` and nothing else. An unknown or older cursor is a
|
|
22
|
+
* no-op that reports the durable state — never an error.
|
|
23
|
+
*
|
|
24
|
+
* 3. **A declared `callback_url` is never silently dropped.** The event row and
|
|
25
|
+
* its callback intent are committed in ONE transaction, and a sweep over
|
|
26
|
+
* durable rows repairs any job whose terminal event is still missing (for
|
|
27
|
+
* example because the process died mid-flight). Retry/dead-lettering stay in
|
|
28
|
+
* the existing outbox worker.
|
|
29
|
+
*/
|
|
30
|
+
import { ProtocolAckResult, ProtocolEventPage } from './JobFacade';
|
|
31
|
+
import { JobViewDeps } from './JobView';
|
|
32
|
+
/** One page of events. Bounded so a long-running job cannot stream unbounded JSON. */
|
|
33
|
+
export declare const EVENTS_PAGE_LIMIT = 200;
|
|
34
|
+
/** Bounded callback budget: a flaky third-party URL must converge, not retry forever. */
|
|
35
|
+
export declare const EVENT_CALLBACK_MAX_ATTEMPTS = 8;
|
|
36
|
+
export interface JobEventQuery {
|
|
37
|
+
after: string | null;
|
|
38
|
+
unackedOnly: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare class JobEventStream {
|
|
41
|
+
private readonly deps;
|
|
42
|
+
constructor(deps: JobViewDeps);
|
|
43
|
+
private get db();
|
|
44
|
+
/**
|
|
45
|
+
* `$defs/EventPage`. Throws 404 for an unknown job, exactly like
|
|
46
|
+
* `GET /jobs/:jobId`.
|
|
47
|
+
*/
|
|
48
|
+
page(jobId: string, query: JobEventQuery): ProtocolEventPage;
|
|
49
|
+
/**
|
|
50
|
+
* `$defs/AckResult`. `ack_through` is monotonic and idempotent: replaying it,
|
|
51
|
+
* or sending an unknown/older cursor, is a no-op that reports what is durably
|
|
52
|
+
* stored. Acking never touches the slot ledger.
|
|
53
|
+
*/
|
|
54
|
+
ack(jobId: string, body: unknown): ProtocolAckResult;
|
|
55
|
+
/**
|
|
56
|
+
* Materialise every lifecycle event the durable ledger already implies, at
|
|
57
|
+
* most once each, and enqueue the matching callback in the same transaction.
|
|
58
|
+
* `callbackUrl` is only read when THIS call is the one that first records the
|
|
59
|
+
* job's request: a replay never rewrites the declared callback.
|
|
60
|
+
*/
|
|
61
|
+
reconcile(jobId: string, callbackUrl?: string | null): void;
|
|
62
|
+
/**
|
|
63
|
+
* Repair sweep for jobs that declared a callback and have no durable terminal
|
|
64
|
+
* event yet, so a process that died before the terminal reconcile still
|
|
65
|
+
* delivers its terminal callback without waiting for a poll. Only jobs that
|
|
66
|
+
* already own a durable `job.requested` row are in scope: that is exactly the
|
|
67
|
+
* set whose callback endpoint is known, and re-running it can never queue a
|
|
68
|
+
* second copy (the event_id is the outbox idempotency key). A job that never
|
|
69
|
+
* recorded its request event is repaired on the consumer's next read instead.
|
|
70
|
+
* Returns how many jobs it looked at, so a caller can assert it drains.
|
|
71
|
+
*/
|
|
72
|
+
reconcileOutstanding(limit?: number): number;
|
|
73
|
+
private ensure;
|
|
74
|
+
/** The `callback_url` a job declared, read from its durable request event. */
|
|
75
|
+
private declaredCallbackUrl;
|
|
76
|
+
private enqueueCallback;
|
|
77
|
+
private eventBodyFor;
|
|
78
|
+
private requireJob;
|
|
79
|
+
/**
|
|
80
|
+
* `?after=` is a "give me everything after this" hint. An unrecognised or
|
|
81
|
+
* foreign cursor cannot filter, so it is treated as absent and the consumer
|
|
82
|
+
* sees the whole stream again — `event_id` is the dedupe key, and at-least-once
|
|
83
|
+
* is the contract.
|
|
84
|
+
*/
|
|
85
|
+
private resolveAfter;
|
|
86
|
+
/**
|
|
87
|
+
* Map an opaque `event_id` back to a durable position. Unknown ids, ids of
|
|
88
|
+
* other jobs and internal-only rows all resolve to null, which callers treat
|
|
89
|
+
* as "no-op" — never as an error.
|
|
90
|
+
*/
|
|
91
|
+
private resolveAckTarget;
|
|
92
|
+
private durableProjectedPosition;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=JobEventStream.d.ts.map
|