pixivflow 2.19.1 → 2.19.3
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/ExecuteSlotCommand.js +1 -1
- package/dist/commands/SchedulerCommand.js +3 -1
- package/dist/commands/SchedulerRunOnceCommand.js +1 -1
- package/dist/commands/scheduler-runtime.d.ts +31 -2
- package/dist/commands/scheduler-runtime.js +63 -9
- package/dist/delivery/DeliveryLedgerPort.d.ts +14 -0
- package/dist/delivery/DeliveryLedgerPort.js +43 -0
- package/dist/delivery/OutboxWorker.d.ts +2 -0
- package/dist/delivery/OutboxWorker.js +25 -13
- package/dist/delivery/errorClass.d.ts +1 -1
- package/dist/delivery/errorClass.js +7 -0
- package/dist/download/DownloadManager.d.ts +5 -0
- package/dist/download/DownloadManager.js +12 -2
- package/dist/download/handlers/IllustrationTargetHandler.d.ts +27 -1
- package/dist/download/handlers/IllustrationTargetHandler.js +105 -7
- package/dist/download/handlers/NovelTargetHandler.d.ts +27 -1
- package/dist/download/handlers/NovelTargetHandler.js +107 -3
- package/dist/notification/NotificationPolicy.d.ts +11 -0
- package/dist/notification/NotificationPolicy.js +22 -5
- package/dist/package.json +1 -1
- package/dist/pixiv-client/TargetSearchRunner.js +24 -8
- package/dist/scheduler/SlotCoordinator.d.ts +74 -1
- package/dist/scheduler/SlotCoordinator.js +108 -1
- package/dist/scheduler/WorkIdentity.d.ts +49 -0
- package/dist/scheduler/WorkIdentity.js +31 -0
- package/dist/storage/repositories/DeliveryRepository.d.ts +9 -0
- package/dist/storage/repositories/DeliveryRepository.js +16 -0
- package/dist/storage/repositories/OutboxRepository.d.ts +15 -0
- package/dist/storage/repositories/OutboxRepository.js +31 -0
- package/dist/storage/repositories/SlotRepository.d.ts +27 -0
- package/dist/storage/repositories/SlotRepository.js +45 -0
- package/dist/topic/TopicPipeline.js +4 -0
- package/dist/utils/errors.d.ts +9 -0
- package/dist/utils/errors.js +14 -1
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/node_modules/@redtidev/pixiv-client/dist/transport/transport.d.ts +9 -0
- package/node_modules/@redtidev/pixiv-client/dist/transport/transport.d.ts.map +1 -1
- package/node_modules/@redtidev/pixiv-client/dist/transport/transport.js +66 -14
- package/node_modules/@redtidev/pixiv-client/dist/transport/transport.js.map +1 -1
- package/node_modules/@redtidev/pixiv-client/src/transport/__tests__/transport-cancellation.test.ts +257 -0
- package/node_modules/@redtidev/pixiv-client/src/transport/transport.ts +81 -18
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { DownloadPipeline } from '../pipeline/DownloadPipeline';
|
|
|
6
6
|
import { NovelDownloader } from '../NovelDownloader';
|
|
7
7
|
import { DeliveryService } from '../../delivery/DeliveryService';
|
|
8
8
|
import { TargetOutcome } from '../../scheduler/TargetOutcome';
|
|
9
|
+
import { TargetExecutionContext } from '../../scheduler/WorkIdentity';
|
|
9
10
|
import type { TopicPipelineFactory } from '../../topic/createTopicPipeline';
|
|
10
11
|
export declare class NovelTargetHandler {
|
|
11
12
|
private readonly client;
|
|
@@ -16,8 +17,14 @@ export declare class NovelTargetHandler {
|
|
|
16
17
|
private readonly topicPipelineFactory?;
|
|
17
18
|
private readonly deliveryService?;
|
|
18
19
|
private outcomes;
|
|
20
|
+
/**
|
|
21
|
+
* Cell identity for this handle() call. Set only for a single-work cell of a
|
|
22
|
+
* scheduled occurrence; null for ad-hoc runs and N-works-per-run targets, which
|
|
23
|
+
* have no single (slotId,targetId) -> workId identity to honour.
|
|
24
|
+
*/
|
|
25
|
+
private execution;
|
|
19
26
|
constructor(client: IPixivClient, database: IDatabase, rankingService: RankingService, pipeline: DownloadPipeline, novelDownloader: NovelDownloader, topicPipelineFactory?: TopicPipelineFactory | undefined, deliveryService?: DeliveryService | undefined);
|
|
20
|
-
handle(target: TargetConfig): Promise<TargetOutcome>;
|
|
27
|
+
handle(target: TargetConfig, execution?: TargetExecutionContext): Promise<TargetOutcome>;
|
|
21
28
|
private summarize;
|
|
22
29
|
private classifyError;
|
|
23
30
|
private fetchNovels;
|
|
@@ -34,6 +41,25 @@ export declare class NovelTargetHandler {
|
|
|
34
41
|
private handleUserNovels;
|
|
35
42
|
private sortByPopularityAndLog;
|
|
36
43
|
private logError;
|
|
44
|
+
/**
|
|
45
|
+
* Continue the work this cell ALREADY owns. Selection is deliberately skipped:
|
|
46
|
+
* no search, no ranking, no topic expansion, no backfill pool, and no
|
|
47
|
+
* "already downloaded / already delivered" exclusion — the cell's own work must
|
|
48
|
+
* never be filtered out of its own recovery.
|
|
49
|
+
*
|
|
50
|
+
* A locked work that is permanently gone is a terminal failure of THIS logical
|
|
51
|
+
* item (`LOCKED_WORK_UNAVAILABLE`). It is never silently replaced by another
|
|
52
|
+
* candidate; that would mutate the item's identity behind the operator's back.
|
|
53
|
+
*/
|
|
54
|
+
private recoverLockedWork;
|
|
55
|
+
/**
|
|
56
|
+
* Process ONE candidate work for this cell.
|
|
57
|
+
*
|
|
58
|
+
* The cell is bound to `novel` BEFORE any side effect: it is the binding, not
|
|
59
|
+
* the candidate list, that decides what a later recovery resumes. The binding
|
|
60
|
+
* is only rolled back when the attempt produced no artifact at all, so in-run
|
|
61
|
+
* backfill still works for a cell that has committed to nothing.
|
|
62
|
+
*/
|
|
37
63
|
private downloadAndDeliver;
|
|
38
64
|
}
|
|
39
65
|
//# sourceMappingURL=NovelTargetHandler.d.ts.map
|
|
@@ -5,6 +5,7 @@ const logger_1 = require("../../logger");
|
|
|
5
5
|
const errors_1 = require("../../utils/errors");
|
|
6
6
|
const pixiv_date_utils_1 = require("../../utils/pixiv-date-utils");
|
|
7
7
|
const pixiv_utils_1 = require("../../utils/pixiv-utils");
|
|
8
|
+
const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
|
|
8
9
|
const target_label_1 = require("../../utils/target-label");
|
|
9
10
|
class NovelTargetHandler {
|
|
10
11
|
client;
|
|
@@ -15,6 +16,12 @@ class NovelTargetHandler {
|
|
|
15
16
|
topicPipelineFactory;
|
|
16
17
|
deliveryService;
|
|
17
18
|
outcomes = [];
|
|
19
|
+
/**
|
|
20
|
+
* Cell identity for this handle() call. Set only for a single-work cell of a
|
|
21
|
+
* scheduled occurrence; null for ad-hoc runs and N-works-per-run targets, which
|
|
22
|
+
* have no single (slotId,targetId) -> workId identity to honour.
|
|
23
|
+
*/
|
|
24
|
+
execution = null;
|
|
18
25
|
constructor(client, database, rankingService, pipeline, novelDownloader, topicPipelineFactory, deliveryService) {
|
|
19
26
|
this.client = client;
|
|
20
27
|
this.database = database;
|
|
@@ -24,8 +31,17 @@ class NovelTargetHandler {
|
|
|
24
31
|
this.topicPipelineFactory = topicPipelineFactory;
|
|
25
32
|
this.deliveryService = deliveryService;
|
|
26
33
|
}
|
|
27
|
-
async handle(target) {
|
|
34
|
+
async handle(target, execution) {
|
|
28
35
|
this.outcomes = [];
|
|
36
|
+
this.execution = execution && (0, WorkIdentity_1.isSingleWorkCell)(target) ? execution : null;
|
|
37
|
+
// A cell that already owns a work is in RECOVERY, not in a new selection.
|
|
38
|
+
// Crash/shutdown recovery is not an intentional second run: running the
|
|
39
|
+
// candidate pipeline here would re-rank and could bind this logical item to a
|
|
40
|
+
// different work than the one it already committed to.
|
|
41
|
+
if (this.execution?.lockedWorkId) {
|
|
42
|
+
await this.recoverLockedWork(target, this.execution.lockedWorkId);
|
|
43
|
+
return this.summarize();
|
|
44
|
+
}
|
|
29
45
|
if (target.novelId !== undefined && target.novelId !== null && target.novelId !== '') {
|
|
30
46
|
const novelNum = typeof target.novelId === 'number' ? target.novelId : Number(target.novelId);
|
|
31
47
|
if (!Number.isFinite(novelNum)) {
|
|
@@ -410,10 +426,98 @@ class NovelTargetHandler {
|
|
|
410
426
|
stack: error instanceof Error ? error.stack : undefined,
|
|
411
427
|
});
|
|
412
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* Continue the work this cell ALREADY owns. Selection is deliberately skipped:
|
|
431
|
+
* no search, no ranking, no topic expansion, no backfill pool, and no
|
|
432
|
+
* "already downloaded / already delivered" exclusion — the cell's own work must
|
|
433
|
+
* never be filtered out of its own recovery.
|
|
434
|
+
*
|
|
435
|
+
* A locked work that is permanently gone is a terminal failure of THIS logical
|
|
436
|
+
* item (`LOCKED_WORK_UNAVAILABLE`). It is never silently replaced by another
|
|
437
|
+
* candidate; that would mutate the item's identity behind the operator's back.
|
|
438
|
+
*/
|
|
439
|
+
async recoverLockedWork(target, lockedWorkId) {
|
|
440
|
+
const displayTag = (0, target_label_1.getTargetLabel)(target);
|
|
441
|
+
logger_1.logger.info(`Recovering locked work ${lockedWorkId} for ${displayTag}; candidate selection is skipped`, {
|
|
442
|
+
slotId: this.execution?.slotId,
|
|
443
|
+
targetId: this.execution?.targetId,
|
|
444
|
+
lockedWorkId,
|
|
445
|
+
});
|
|
446
|
+
const novelId = Number(lockedWorkId);
|
|
447
|
+
if (!Number.isFinite(novelId)) {
|
|
448
|
+
this.outcomes.push({
|
|
449
|
+
kind: 'failed',
|
|
450
|
+
retryable: false,
|
|
451
|
+
error: `LOCKED_WORK_UNAVAILABLE: cell work id "${lockedWorkId}" is not a valid novel id`,
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const detail = await this.client.getNovelDetail(novelId);
|
|
457
|
+
const novel = {
|
|
458
|
+
id: detail.id,
|
|
459
|
+
title: detail.title,
|
|
460
|
+
user: detail.user,
|
|
461
|
+
create_date: detail.create_date,
|
|
462
|
+
};
|
|
463
|
+
await this.downloadAndDeliver(novel, `novel-${novelId}`, target);
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
467
|
+
const retryable = (0, errors_1.isRetryableNetworkError)(error);
|
|
468
|
+
this.logError(error, `Failed to recover locked novel ${lockedWorkId}`);
|
|
469
|
+
this.outcomes.push({
|
|
470
|
+
kind: 'failed',
|
|
471
|
+
retryable,
|
|
472
|
+
error: retryable
|
|
473
|
+
? `locked work ${lockedWorkId} could not be fetched (will retry the SAME work): ${message}`
|
|
474
|
+
: `LOCKED_WORK_UNAVAILABLE: locked work ${lockedWorkId} is no longer fetchable: ${message}`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Process ONE candidate work for this cell.
|
|
480
|
+
*
|
|
481
|
+
* The cell is bound to `novel` BEFORE any side effect: it is the binding, not
|
|
482
|
+
* the candidate list, that decides what a later recovery resumes. The binding
|
|
483
|
+
* is only rolled back when the attempt produced no artifact at all, so in-run
|
|
484
|
+
* backfill still works for a cell that has committed to nothing.
|
|
485
|
+
*/
|
|
413
486
|
async downloadAndDeliver(novel, tag, target) {
|
|
414
|
-
const
|
|
415
|
-
|
|
487
|
+
const execution = this.execution;
|
|
488
|
+
const workId = String(novel.id);
|
|
489
|
+
// Recovery continues a work the cell already bound; it must never be released.
|
|
490
|
+
const recovering = Boolean(execution?.lockedWorkId);
|
|
491
|
+
if (execution && !recovering) {
|
|
492
|
+
const binding = execution.bind(workId, 'novel');
|
|
493
|
+
if (!binding.won) {
|
|
494
|
+
// Another writer elected a different work for this cell. First selection
|
|
495
|
+
// is authoritative: never process a work the cell does not own.
|
|
496
|
+
logger_1.logger.warn(`Cell is bound to work ${binding.workId}; declining to select ${workId}`, {
|
|
497
|
+
slotId: execution.slotId,
|
|
498
|
+
targetId: execution.targetId,
|
|
499
|
+
boundWorkId: binding.workId,
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
let artifact;
|
|
505
|
+
try {
|
|
506
|
+
artifact = await this.novelDownloader.download(novel, tag, target);
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
// Nothing was persisted, so the cell may still pick another candidate.
|
|
510
|
+
if (execution && !recovering)
|
|
511
|
+
execution.release(workId);
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
if (!artifact) {
|
|
515
|
+
if (execution && !recovering)
|
|
516
|
+
execution.release(workId);
|
|
416
517
|
return;
|
|
518
|
+
}
|
|
519
|
+
// Committed: the artifact is durable and this work now defines the cell. Even
|
|
520
|
+
// if the delivery below throws, recovery must resume THIS work — never release.
|
|
417
521
|
const isDelivery = target.storageMode === 'cache' && target.delivery?.target?.trim();
|
|
418
522
|
if (!isDelivery || !this.deliveryService) {
|
|
419
523
|
this.outcomes.push({ kind: 'stored', workId: artifact.pixivId, workType: artifact.type });
|
|
@@ -16,6 +16,17 @@ export declare class NotificationPolicy {
|
|
|
16
16
|
private readonly config;
|
|
17
17
|
constructor(database: Database, config: StandaloneConfig);
|
|
18
18
|
private targetName;
|
|
19
|
+
/**
|
|
20
|
+
* Delivery targets that can actually RECEIVE an operational notification.
|
|
21
|
+
*
|
|
22
|
+
* `delivery.target` names a SUBMISSION endpoint (multipart POST of a work).
|
|
23
|
+
* Only targets that also declare a `notificationUrl` accept notifications —
|
|
24
|
+
* the same rule `config/validation.ts` enforces for `noMatchPolicy.notify`
|
|
25
|
+
* and that `docs/CONFIG.md` documents. A submission endpoint without one
|
|
26
|
+
* rejects every notification, so enqueuing to it would only grow an outbox
|
|
27
|
+
* row that can never be delivered.
|
|
28
|
+
*/
|
|
29
|
+
private notifiableTargets;
|
|
19
30
|
/** Stable slot-scoped keys (two occurrences on one date never collide). */
|
|
20
31
|
static keys: {
|
|
21
32
|
noMatch: (slotId: string, targetId: string) => string;
|
|
@@ -21,6 +21,23 @@ class NotificationPolicy {
|
|
|
21
21
|
targetName(target) {
|
|
22
22
|
return target.delivery?.target?.trim() || null;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Delivery targets that can actually RECEIVE an operational notification.
|
|
26
|
+
*
|
|
27
|
+
* `delivery.target` names a SUBMISSION endpoint (multipart POST of a work).
|
|
28
|
+
* Only targets that also declare a `notificationUrl` accept notifications —
|
|
29
|
+
* the same rule `config/validation.ts` enforces for `noMatchPolicy.notify`
|
|
30
|
+
* and that `docs/CONFIG.md` documents. A submission endpoint without one
|
|
31
|
+
* rejects every notification, so enqueuing to it would only grow an outbox
|
|
32
|
+
* row that can never be delivered.
|
|
33
|
+
*/
|
|
34
|
+
notifiableTargets() {
|
|
35
|
+
const targets = this.config.delivery?.targets ?? {};
|
|
36
|
+
return new Set(Object.keys(targets).filter((name) => {
|
|
37
|
+
const target = targets[name];
|
|
38
|
+
return target?.type === 'httpMultipart' && Boolean(target.notificationUrl?.trim());
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
24
41
|
/** Stable slot-scoped keys (two occurrences on one date never collide). */
|
|
25
42
|
static keys = {
|
|
26
43
|
noMatch: (slotId, targetId) => `notification:${slotId}:${targetId}:no-candidate`,
|
|
@@ -32,6 +49,10 @@ class NotificationPolicy {
|
|
|
32
49
|
const name = this.targetName(target);
|
|
33
50
|
if (!name)
|
|
34
51
|
return;
|
|
52
|
+
// No notifiable endpoint configured: drop the notification instead of
|
|
53
|
+
// enqueuing it against a submission target that will reject it forever.
|
|
54
|
+
if (!this.notifiableTargets().has(name))
|
|
55
|
+
return;
|
|
35
56
|
const label = target.id || target.filterTag || target.tag || target.type;
|
|
36
57
|
if (outcome.kind === 'no_candidate' && target.noMatchPolicy?.notify === true) {
|
|
37
58
|
this.send(name, NotificationPolicy.keys.noMatch(slotId, target.id ?? label), [
|
|
@@ -53,11 +74,7 @@ class NotificationPolicy {
|
|
|
53
74
|
}
|
|
54
75
|
/** One consolidated summary per slot, delivered to every notifying target's endpoint. */
|
|
55
76
|
sendSlotSummary(slot, schedule, rows) {
|
|
56
|
-
const
|
|
57
|
-
const notifiable = new Set(Object.keys(targets).filter((n) => {
|
|
58
|
-
const target = targets[n];
|
|
59
|
-
return target?.type === 'httpMultipart' && Boolean(target.notificationUrl?.trim());
|
|
60
|
-
}));
|
|
77
|
+
const notifiable = this.notifiableTargets();
|
|
61
78
|
if (notifiable.size === 0 || rows.length === 0)
|
|
62
79
|
return;
|
|
63
80
|
const icon = (s) => s === 'submitted' ? '✅' : s === 'no_candidate' ? '⚠️' : s === 'duplicate' ? '♱' : s === 'delivery_pending' ? '🕓' : '❌';
|
package/dist/package.json
CHANGED
|
@@ -56,10 +56,10 @@ class TargetSearchRunner {
|
|
|
56
56
|
return target.limit ? sorted.slice(0, target.limit) : sorted;
|
|
57
57
|
}
|
|
58
58
|
async searchSingleIllust(target, tag, requestDelayMs, signal) {
|
|
59
|
-
return this.searchWithPagination(target, tag, requestDelayMs, (options) => this.kit.illustrations.searchPage(options), (t, g) => (0, query_mapper_1.mapTargetToIllustQuery)({ ...t, tag: g }), signal);
|
|
59
|
+
return this.searchWithPagination(target, tag, requestDelayMs, (options, cursor) => this.kit.illustrations.searchPage(options, cursor), (t, g) => (0, query_mapper_1.mapTargetToIllustQuery)({ ...t, tag: g }), signal);
|
|
60
60
|
}
|
|
61
61
|
async searchSingleNovel(target, tag, requestDelayMs, signal) {
|
|
62
|
-
return this.searchWithPagination(target, tag, requestDelayMs, (options) => this.kit.novels.searchPage(options), (t, g) => (0, query_mapper_1.mapTargetToNovelQuery)({ ...t, tag: g }), signal);
|
|
62
|
+
return this.searchWithPagination(target, tag, requestDelayMs, (options, cursor) => this.kit.novels.searchPage(options, cursor), (t, g) => (0, query_mapper_1.mapTargetToNovelQuery)({ ...t, tag: g }), signal);
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
65
|
* Date-aware pagination. Mirrors the legacy SearchService semantics:
|
|
@@ -96,23 +96,39 @@ class TargetSearchRunner {
|
|
|
96
96
|
let cursor = null;
|
|
97
97
|
let pageCount = 0;
|
|
98
98
|
let shouldStop = false;
|
|
99
|
+
// Progress invariant: every iteration must terminate, advance to a cursor we
|
|
100
|
+
// have never fetched, or fail explicitly. A cursor that repeats (same `next`
|
|
101
|
+
// twice, or an A -> B -> A cycle) proves the upstream adapter is not making
|
|
102
|
+
// progress; re-requesting would spin forever, so we refuse instead.
|
|
103
|
+
const seenCursors = new Set();
|
|
99
104
|
while ((!fetchLimit || results.length < fetchLimit) && !shouldStop) {
|
|
100
105
|
(0, errors_1.throwIfAborted)(signal, 'search cancelled');
|
|
101
106
|
pageCount++;
|
|
102
107
|
// Dates are intentionally filtered CLIENT-side below (legacy behavior;
|
|
103
108
|
// PixivFlow needs the early-stop walk on create_date).
|
|
109
|
+
//
|
|
110
|
+
// The cursor must travel as searchPage's SECOND POSITIONAL argument: the
|
|
111
|
+
// kit reads `options.cursor` only inside its own `search()`. Putting it in
|
|
112
|
+
// the options object left the cursor permanently null, so page 1 (the
|
|
113
|
+
// newest works) was re-fetched on every iteration and a search that found
|
|
114
|
+
// no in-range work on that page never terminated.
|
|
104
115
|
const page = await fetchOne({
|
|
105
116
|
word: base.word,
|
|
106
117
|
sort: base.sort,
|
|
107
118
|
searchTarget: base.searchTarget,
|
|
108
119
|
includeR18: base.includeR18,
|
|
109
|
-
cursor,
|
|
110
|
-
// Threaded into the kit transport, which combines it with its per-request
|
|
111
|
-
// timeout and honours it in retry back-off. Without this the pager could
|
|
112
|
-
// stay blocked in a single hung request for the rest of the run.
|
|
113
120
|
signal,
|
|
114
|
-
});
|
|
115
|
-
|
|
121
|
+
}, cursor);
|
|
122
|
+
const nextCursor = page.next;
|
|
123
|
+
if (nextCursor !== null) {
|
|
124
|
+
if (nextCursor === cursor || seenCursors.has(nextCursor)) {
|
|
125
|
+
throw new errors_1.PaginationError(`Search pager for tag "${tag}" did not advance: page ${pageCount} returned a cursor that ` +
|
|
126
|
+
(nextCursor === cursor ? 'equals the one just fetched' : 'was already fetched') +
|
|
127
|
+
`. Refusing to re-request it indefinitely.`);
|
|
128
|
+
}
|
|
129
|
+
seenCursors.add(nextCursor);
|
|
130
|
+
}
|
|
131
|
+
cursor = nextCursor;
|
|
116
132
|
for (const item of page.items) {
|
|
117
133
|
const decision = this.filterItemByDate(item, target, startDate, endDate, sortMode);
|
|
118
134
|
if (decision.shouldStop) {
|
|
@@ -3,6 +3,7 @@ import { Database } from '../storage/Database';
|
|
|
3
3
|
import { CellStatus, SlotItemRecord, SlotRecord, SlotStatus } from '../storage/repositories/SlotRepository';
|
|
4
4
|
import { TriggerSource } from './OccurrenceResolver';
|
|
5
5
|
import { TargetOutcome } from './TargetOutcome';
|
|
6
|
+
import { TargetExecutionContext, WorkBinding } from './WorkIdentity';
|
|
6
7
|
/**
|
|
7
8
|
* Execution-lease TTL and heartbeat cadence.
|
|
8
9
|
*
|
|
@@ -54,6 +55,40 @@ export interface SlotResolveResult {
|
|
|
54
55
|
error?: string;
|
|
55
56
|
status?: number;
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* What a `delivery_pending` cell still owes downstream, read from the durable
|
|
60
|
+
* delivery ledger.
|
|
61
|
+
*
|
|
62
|
+
* - `live` : a non-terminal intent still owns the cell — the outbox is
|
|
63
|
+
* retrying THAT work toward a terminal ACK.
|
|
64
|
+
* - `confirmed` : the ACK already landed but the cell promotion was lost.
|
|
65
|
+
* - `lost` : the intent is terminally failed; nobody will converge it.
|
|
66
|
+
* - `unknown` : no delivery fact for this cell (not a delivery cell).
|
|
67
|
+
*/
|
|
68
|
+
export type CellDeliveryState = {
|
|
69
|
+
kind: 'live';
|
|
70
|
+
} | {
|
|
71
|
+
kind: 'confirmed';
|
|
72
|
+
workId: string;
|
|
73
|
+
workType: string;
|
|
74
|
+
} | {
|
|
75
|
+
kind: 'lost';
|
|
76
|
+
reason: string;
|
|
77
|
+
} | {
|
|
78
|
+
kind: 'unknown';
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Port for the delivery ledger, injected by the runtime. It lets the slot FSM
|
|
82
|
+
* hand a `delivery_pending` cell to whoever owns its delivery without this class
|
|
83
|
+
* learning anything about TelePost, bots or any hosting platform (see below).
|
|
84
|
+
*/
|
|
85
|
+
export interface SchedulerDeliveryPort {
|
|
86
|
+
stateFor(input: {
|
|
87
|
+
deliveryTarget: string;
|
|
88
|
+
slotId: string;
|
|
89
|
+
targetId: string;
|
|
90
|
+
}): CellDeliveryState;
|
|
91
|
+
}
|
|
57
92
|
/**
|
|
58
93
|
* Owns the Schedule Slot ledger for one scheduler run. A Slot is one durable
|
|
59
94
|
* execution occurrence of a Schedule (NOT a morning/evening row). It ensures
|
|
@@ -66,7 +101,8 @@ export interface SlotResolveResult {
|
|
|
66
101
|
*/
|
|
67
102
|
export declare class SlotCoordinator {
|
|
68
103
|
private readonly database;
|
|
69
|
-
|
|
104
|
+
private readonly delivery?;
|
|
105
|
+
constructor(database: Database, delivery?: SchedulerDeliveryPort | undefined);
|
|
70
106
|
/**
|
|
71
107
|
* Resolve + validate the canonical occurrence for a trigger. Uses ONLY the
|
|
72
108
|
* schedule's cron + timezone and the trigger instant (never a client-supplied
|
|
@@ -103,8 +139,33 @@ export declare class SlotCoordinator {
|
|
|
103
139
|
target: TargetConfig;
|
|
104
140
|
cell: SlotItemRecord;
|
|
105
141
|
}[];
|
|
142
|
+
/**
|
|
143
|
+
* Build the per-target execution contexts for one run, so every handler
|
|
144
|
+
* receives the cell's durable identity instead of just a TargetConfig.
|
|
145
|
+
*
|
|
146
|
+
* This is the boundary where the identity used to be dropped (`pending` was
|
|
147
|
+
* reduced to `p.target`), which is what let a resume re-rank and re-point the
|
|
148
|
+
* logical item at a different work.
|
|
149
|
+
*/
|
|
150
|
+
executionContextsFor(slotId: string, entries: {
|
|
151
|
+
target: TargetConfig;
|
|
152
|
+
cell: SlotItemRecord | null;
|
|
153
|
+
}[]): Map<string, TargetExecutionContext>;
|
|
106
154
|
/** Lock the selected work for a cell (first selection wins; retries keep it). */
|
|
107
155
|
lockWork(slotId: string, targetId: string, workId: string, workType: string): void;
|
|
156
|
+
/**
|
|
157
|
+
* CAS-bind a cell to the work a handler is about to process. Returns the
|
|
158
|
+
* authoritative binding: when another worker already elected a different work,
|
|
159
|
+
* `won` is false and the caller MUST continue with the returned `workId`
|
|
160
|
+
* instead of its own candidate.
|
|
161
|
+
*/
|
|
162
|
+
lockWorkCas(slotId: string, targetId: string, workId: string, workType: string): WorkBinding;
|
|
163
|
+
/**
|
|
164
|
+
* Release a provisional binding whose work produced no local artifact, so the
|
|
165
|
+
* next candidate of an UNBOUND cell can be tried. Refused once anything was
|
|
166
|
+
* committed (see SlotRepository.releaseCellWork).
|
|
167
|
+
*/
|
|
168
|
+
releaseWorkCas(slotId: string, targetId: string, workId: string): void;
|
|
108
169
|
/** Record a cell's terminal state from the download/delivery outcome. */
|
|
109
170
|
markCell(slotId: string, targetId: string, status: CellStatus, error?: string): void;
|
|
110
171
|
/**
|
|
@@ -116,6 +177,18 @@ export declare class SlotCoordinator {
|
|
|
116
177
|
applyOutcome(slotId: string, targetId: string, outcome: TargetOutcome): void;
|
|
117
178
|
/** Promote a delivery_pending cell to submitted from a confirmed ACK. */
|
|
118
179
|
markDelivered(slotId: string, targetId: string, workId: string, workType: string): void;
|
|
180
|
+
/**
|
|
181
|
+
* Decide what a `delivery_pending` cell owes, from the durable delivery
|
|
182
|
+
* ledger, and converge the FSM accordingly. Returns true when the scheduler
|
|
183
|
+
* must NOT run the target handler for it.
|
|
184
|
+
*
|
|
185
|
+
* Once an intent is durable the outbox owns the delivery: re-running selection
|
|
186
|
+
* could only produce a DIFFERENT work for the same logical item (and a second
|
|
187
|
+
* delivery, since the delivery idempotency key is work-scoped). A terminally
|
|
188
|
+
* failed delivery is converged explicitly — never "repaired" by picking
|
|
189
|
+
* another work behind the operator's back.
|
|
190
|
+
*/
|
|
191
|
+
private settlePendingDelivery;
|
|
119
192
|
private safeTransition;
|
|
120
193
|
/** Cross-process execution lease. Duplicate triggers converge, never parallel-run. */
|
|
121
194
|
claimRunLease(slotId: string, owner: string, leaseMs: number): boolean;
|
|
@@ -4,6 +4,7 @@ exports.SlotCoordinator = exports.SLOT_HEARTBEAT_MS = exports.SLOT_LEASE_TTL_MS
|
|
|
4
4
|
exports.timezoneForSchedule = timezoneForSchedule;
|
|
5
5
|
const logger_1 = require("../logger");
|
|
6
6
|
const OccurrenceResolver_1 = require("./OccurrenceResolver");
|
|
7
|
+
const WorkIdentity_1 = require("./WorkIdentity");
|
|
7
8
|
/**
|
|
8
9
|
* Execution-lease TTL and heartbeat cadence.
|
|
9
10
|
*
|
|
@@ -17,6 +18,13 @@ const OccurrenceResolver_1 = require("./OccurrenceResolver");
|
|
|
17
18
|
*/
|
|
18
19
|
exports.SLOT_LEASE_TTL_MS = 3 * 60 * 1000;
|
|
19
20
|
exports.SLOT_HEARTBEAT_MS = 30 * 1000;
|
|
21
|
+
/** The delivery channel a scheduled cell publishes to (null = download-only). */
|
|
22
|
+
function deliveryTargetOf(target) {
|
|
23
|
+
if (target.storageMode !== 'cache')
|
|
24
|
+
return null;
|
|
25
|
+
const name = target.delivery?.target;
|
|
26
|
+
return typeof name === 'string' && name.trim() ? name.trim() : null;
|
|
27
|
+
}
|
|
20
28
|
/**
|
|
21
29
|
* Owns the Schedule Slot ledger for one scheduler run. A Slot is one durable
|
|
22
30
|
* execution occurrence of a Schedule (NOT a morning/evening row). It ensures
|
|
@@ -29,8 +37,10 @@ exports.SLOT_HEARTBEAT_MS = 30 * 1000;
|
|
|
29
37
|
*/
|
|
30
38
|
class SlotCoordinator {
|
|
31
39
|
database;
|
|
32
|
-
|
|
40
|
+
delivery;
|
|
41
|
+
constructor(database, delivery) {
|
|
33
42
|
this.database = database;
|
|
43
|
+
this.delivery = delivery;
|
|
34
44
|
}
|
|
35
45
|
/**
|
|
36
46
|
* Resolve + validate the canonical occurrence for a trigger. Uses ONLY the
|
|
@@ -133,15 +143,65 @@ class SlotCoordinator {
|
|
|
133
143
|
continue;
|
|
134
144
|
if (cell.status === 'submitted' || cell.status === 'no_candidate')
|
|
135
145
|
continue;
|
|
146
|
+
// A cell whose work already has a durable delivery intent is NOT the
|
|
147
|
+
// scheduler's to re-run: the OutboxWorker retries the SAME work to a
|
|
148
|
+
// terminal ACK. Re-selecting here is what let recovery stop pointing at the
|
|
149
|
+
// cell's own work (or enqueue a second one), so it is delegated instead.
|
|
150
|
+
if (cell.status === 'delivery_pending' && (0, WorkIdentity_1.isSingleWorkCell)(target) && this.settlePendingDelivery(slotId, target)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
136
153
|
out.push({ target, cell });
|
|
137
154
|
}
|
|
138
155
|
return out;
|
|
139
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Build the per-target execution contexts for one run, so every handler
|
|
159
|
+
* receives the cell's durable identity instead of just a TargetConfig.
|
|
160
|
+
*
|
|
161
|
+
* This is the boundary where the identity used to be dropped (`pending` was
|
|
162
|
+
* reduced to `p.target`), which is what let a resume re-rank and re-point the
|
|
163
|
+
* logical item at a different work.
|
|
164
|
+
*/
|
|
165
|
+
executionContextsFor(slotId, entries) {
|
|
166
|
+
const contexts = new Map();
|
|
167
|
+
for (const { target, cell } of entries) {
|
|
168
|
+
if (!target.id || !cell)
|
|
169
|
+
continue;
|
|
170
|
+
const targetId = target.id;
|
|
171
|
+
contexts.set(targetId, {
|
|
172
|
+
slotId,
|
|
173
|
+
targetId,
|
|
174
|
+
lockedWorkId: cell.workId,
|
|
175
|
+
bind: (workId, workType) => this.lockWorkCas(slotId, targetId, workId, workType),
|
|
176
|
+
release: (workId) => this.releaseWorkCas(slotId, targetId, workId),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return contexts;
|
|
180
|
+
}
|
|
140
181
|
/** Lock the selected work for a cell (first selection wins; retries keep it). */
|
|
141
182
|
lockWork(slotId, targetId, workId, workType) {
|
|
142
183
|
this.database.slots.ensureCell(slotId, targetId, workType);
|
|
143
184
|
this.database.slots.lockCellWork(slotId, targetId, workId, workType);
|
|
144
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* CAS-bind a cell to the work a handler is about to process. Returns the
|
|
188
|
+
* authoritative binding: when another worker already elected a different work,
|
|
189
|
+
* `won` is false and the caller MUST continue with the returned `workId`
|
|
190
|
+
* instead of its own candidate.
|
|
191
|
+
*/
|
|
192
|
+
lockWorkCas(slotId, targetId, workId, workType) {
|
|
193
|
+
this.database.slots.ensureCell(slotId, targetId, workType);
|
|
194
|
+
const { cell, won } = this.database.slots.tryLockCellWork(slotId, targetId, workId, workType);
|
|
195
|
+
return { workId: cell.workId ?? workId, won };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Release a provisional binding whose work produced no local artifact, so the
|
|
199
|
+
* next candidate of an UNBOUND cell can be tried. Refused once anything was
|
|
200
|
+
* committed (see SlotRepository.releaseCellWork).
|
|
201
|
+
*/
|
|
202
|
+
releaseWorkCas(slotId, targetId, workId) {
|
|
203
|
+
this.database.slots.releaseCellWork(slotId, targetId, workId);
|
|
204
|
+
}
|
|
145
205
|
/** Record a cell's terminal state from the download/delivery outcome. */
|
|
146
206
|
markCell(slotId, targetId, status, error) {
|
|
147
207
|
const cell = this.database.slots.getCell(slotId, targetId);
|
|
@@ -203,6 +263,53 @@ class SlotCoordinator {
|
|
|
203
263
|
this.database.slots.lockCellWork(slotId, targetId, workId, workType);
|
|
204
264
|
this.safeTransition(slotId, targetId, 'submitted');
|
|
205
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Decide what a `delivery_pending` cell owes, from the durable delivery
|
|
268
|
+
* ledger, and converge the FSM accordingly. Returns true when the scheduler
|
|
269
|
+
* must NOT run the target handler for it.
|
|
270
|
+
*
|
|
271
|
+
* Once an intent is durable the outbox owns the delivery: re-running selection
|
|
272
|
+
* could only produce a DIFFERENT work for the same logical item (and a second
|
|
273
|
+
* delivery, since the delivery idempotency key is work-scoped). A terminally
|
|
274
|
+
* failed delivery is converged explicitly — never "repaired" by picking
|
|
275
|
+
* another work behind the operator's back.
|
|
276
|
+
*/
|
|
277
|
+
settlePendingDelivery(slotId, target) {
|
|
278
|
+
const deliveryTarget = deliveryTargetOf(target);
|
|
279
|
+
if (!deliveryTarget || !this.delivery || !target.id)
|
|
280
|
+
return false;
|
|
281
|
+
const state = this.delivery.stateFor({ deliveryTarget, slotId, targetId: target.id });
|
|
282
|
+
switch (state.kind) {
|
|
283
|
+
case 'live':
|
|
284
|
+
logger_1.logger.info('Cell delivery still owned by the outbox; selection not re-run', {
|
|
285
|
+
slot: slotId,
|
|
286
|
+
target: target.id,
|
|
287
|
+
deliveryTarget,
|
|
288
|
+
});
|
|
289
|
+
return true;
|
|
290
|
+
case 'confirmed':
|
|
291
|
+
// The ACK landed but the promotion was lost (crash between the delivery
|
|
292
|
+
// ledger write and the cell transition). Heal from the ledger — the work
|
|
293
|
+
// IS delivered, so re-selecting would post a second one.
|
|
294
|
+
logger_1.logger.info('Cell delivery already confirmed; promoting the cell from the ledger', {
|
|
295
|
+
slot: slotId,
|
|
296
|
+
target: target.id,
|
|
297
|
+
workId: state.workId,
|
|
298
|
+
});
|
|
299
|
+
this.markDelivered(slotId, target.id, state.workId, state.workType);
|
|
300
|
+
return true;
|
|
301
|
+
case 'lost':
|
|
302
|
+
logger_1.logger.warn('Cell delivery failed terminally; failing the cell instead of re-selecting', {
|
|
303
|
+
slot: slotId,
|
|
304
|
+
target: target.id,
|
|
305
|
+
reason: state.reason,
|
|
306
|
+
});
|
|
307
|
+
this.applyOutcome(slotId, target.id, { kind: 'failed', retryable: false, error: state.reason });
|
|
308
|
+
return true;
|
|
309
|
+
case 'unknown':
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
206
313
|
safeTransition(slotId, targetId, next, error) {
|
|
207
314
|
try {
|
|
208
315
|
this.database.slots.transitionCell(slotId, targetId, next, error);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { TargetConfig } from '../config';
|
|
2
|
+
/**
|
|
3
|
+
* Work identity for a scheduled cell.
|
|
4
|
+
*
|
|
5
|
+
* Invariant: (slotId, targetId) -> ONE stable workId. The first selection wins,
|
|
6
|
+
* and every later crash / shutdown / lease recovery continues with THAT work.
|
|
7
|
+
* Re-running candidate selection on resume silently re-points the logical item
|
|
8
|
+
* at a different work (A -> B): the item then reports success while A sits
|
|
9
|
+
* downloaded-but-never-delivered, and if A's delivery intent was already
|
|
10
|
+
* durable both A and B get posted under different idempotency identities.
|
|
11
|
+
*/
|
|
12
|
+
export interface WorkBinding {
|
|
13
|
+
/** Authoritative work id for the cell after the attempt (the CAS winner). */
|
|
14
|
+
workId: string;
|
|
15
|
+
/** True when this caller established — or already held — the binding. */
|
|
16
|
+
won: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Per-cell execution context handed to a target handler, so the cell's identity
|
|
20
|
+
* travels with the work instead of being dropped at the dispatch boundary.
|
|
21
|
+
*
|
|
22
|
+
* `lockedWorkId` is an authoritative INPUT: when it is set, the handler must
|
|
23
|
+
* continue exactly that work and must not run ranking / topic selection /
|
|
24
|
+
* backfill / already-seen filtering. `bind()` is called for a candidate BEFORE
|
|
25
|
+
* any of its side effects, so a crash during the download can never resume the
|
|
26
|
+
* cell onto a different work.
|
|
27
|
+
*/
|
|
28
|
+
export interface TargetExecutionContext {
|
|
29
|
+
readonly slotId: string;
|
|
30
|
+
readonly targetId: string;
|
|
31
|
+
/** The work this logical item is already bound to; null for a fresh cell. */
|
|
32
|
+
readonly lockedWorkId: string | null;
|
|
33
|
+
/** CAS-bind the cell to `workId`; returns the authoritative binding. */
|
|
34
|
+
bind(workId: string, workType: string): WorkBinding;
|
|
35
|
+
/** Roll back a provisional binding for a work that produced no artifact. */
|
|
36
|
+
release(workId: string): void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* True when one cell of this target selects at most ONE work per run — the only
|
|
40
|
+
* shape the (slotId, targetId) -> workId invariant can hold for.
|
|
41
|
+
*
|
|
42
|
+
* A target that intentionally pulls N works per run (limit > 1, a novel series,
|
|
43
|
+
* a user feed) owns N works inside a single cell, so pinning that cell to one
|
|
44
|
+
* locked id on resume would silently shrink the run to a single work. Those keep
|
|
45
|
+
* per-run candidate selection; the work-identity contract covers single-work
|
|
46
|
+
* cells, which is what a scheduled "one post per slot" target is.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isSingleWorkCell(target: TargetConfig): boolean;
|
|
49
|
+
//# sourceMappingURL=WorkIdentity.d.ts.map
|