pixivflow 2.20.5 → 2.22.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.en.md +179 -56
- package/README.md +110 -57
- package/dist/commands/SchedulerCommand.js +63 -0
- package/dist/commands/SchedulerIdleLifecycle.d.ts +8 -0
- package/dist/commands/SchedulerIdleLifecycle.js +2 -1
- package/dist/commands/scheduler-runtime.js +17 -0
- package/dist/config/types.d.ts +26 -0
- package/dist/config/validation.js +21 -0
- package/dist/delivery/types.d.ts +15 -0
- package/dist/notification/NotificationPolicy.d.ts +2 -0
- package/dist/notification/NotificationPolicy.js +18 -1
- package/dist/package.json +1 -1
- package/dist/scheduler/MultiScheduleManager.d.ts +12 -1
- package/dist/scheduler/MultiScheduleManager.js +46 -50
- package/dist/scheduler/RecoveryPolicy.d.ts +45 -0
- package/dist/scheduler/RecoveryPolicy.js +63 -0
- package/dist/scheduler/ResourceAdmission.d.ts +68 -0
- package/dist/scheduler/ResourceAdmission.js +83 -0
- package/dist/scheduler/ScheduleTriggerServer.d.ts +16 -0
- package/dist/scheduler/ScheduleTriggerServer.js +55 -0
- package/dist/scheduler/Scheduler.d.ts +19 -2
- package/dist/scheduler/Scheduler.js +12 -3
- package/dist/scheduler/SlotCoordinator.d.ts +20 -0
- package/dist/scheduler/SlotCoordinator.js +44 -2
- package/dist/scheduler/TargetOutcome.d.ts +31 -0
- package/dist/scheduler/TargetOutcome.js +128 -0
- package/dist/storage/DatabaseMigration.js +17 -0
- package/dist/storage/repositories/SlotRepository.d.ts +29 -0
- package/dist/storage/repositories/SlotRepository.js +28 -2
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/package.json +2 -2
|
@@ -208,6 +208,65 @@ class SchedulerCommand extends Command_1.BaseCommand {
|
|
|
208
208
|
? { requestId, slotId: slot.id, state: cell.status, slotStatus: slot.status }
|
|
209
209
|
: null;
|
|
210
210
|
},
|
|
211
|
+
/**
|
|
212
|
+
* Manual recovery of a FAILED target (§manual-recovery). Distinct
|
|
213
|
+
* from refetch: there is no review chain to replace and no
|
|
214
|
+
* replacement payload — the target is simply re-acquired under a
|
|
215
|
+
* server-defined policy preset, and its outcome is reported through
|
|
216
|
+
* the ordinary schedule-outcome channel so the automatic history is
|
|
217
|
+
* never rewritten.
|
|
218
|
+
*/
|
|
219
|
+
recover: async (targetId, requestId, retryMode, correlationId) => {
|
|
220
|
+
const cfg = resolveConfig();
|
|
221
|
+
const plans = (cfg.schedules ?? []).filter((plan) => plan.enabled !== false && (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).some((target) => target.id === targetId));
|
|
222
|
+
if (plans.length === 0)
|
|
223
|
+
throw new Error('unknown target');
|
|
224
|
+
if (plans.length !== 1)
|
|
225
|
+
throw new Error('ambiguous target');
|
|
226
|
+
const plan = plans[0];
|
|
227
|
+
const target = (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).find((item) => item.id === targetId);
|
|
228
|
+
const deliveryName = target.delivery?.target;
|
|
229
|
+
const delivery = deliveryName ? cfg.delivery?.targets?.[deliveryName] : undefined;
|
|
230
|
+
if (delivery?.type !== 'httpMultipart' || !delivery.scheduleOutcomeUrl?.trim()) {
|
|
231
|
+
throw new Error('schedule outcome endpoint not configured');
|
|
232
|
+
}
|
|
233
|
+
const now = new Date();
|
|
234
|
+
const date = new Intl.DateTimeFormat('en-CA', {
|
|
235
|
+
timeZone: plan.timezone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
236
|
+
}).format(now);
|
|
237
|
+
const slot = {
|
|
238
|
+
slotId: `${plan.id}@recover-${requestId.toLowerCase()}`,
|
|
239
|
+
scheduleId: plan.id,
|
|
240
|
+
occurrenceAt: now.getTime(),
|
|
241
|
+
occurrenceDate: date,
|
|
242
|
+
occurrenceLabel: 'manual',
|
|
243
|
+
timezone: plan.timezone ?? 'UTC',
|
|
244
|
+
triggerSource: 'manual',
|
|
245
|
+
slotName: retryMode === 'relaxed' ? '手动恢复(放宽条件重试)' : '手动恢复(再试一次)',
|
|
246
|
+
slotDate: date,
|
|
247
|
+
recoveryRequestId: requestId,
|
|
248
|
+
recoveryMode: retryMode,
|
|
249
|
+
correlationId: correlationId || undefined,
|
|
250
|
+
};
|
|
251
|
+
const existing = runtime.database.slots.getSlot(slot.slotId);
|
|
252
|
+
if (existing && (existing.scheduleId !== plan.id || existing.targetIds.length !== 1 || existing.targetIds[0] !== targetId)) {
|
|
253
|
+
throw new Error('ambiguous target');
|
|
254
|
+
}
|
|
255
|
+
const prepared = coordinator.prepare(slot, plan, [target]);
|
|
256
|
+
if (prepared.alreadyCompleted)
|
|
257
|
+
return { slotId: slot.slotId, disposition: 'already_completed' };
|
|
258
|
+
// Same resource admission as every other Pixiv-consuming work
|
|
259
|
+
// item: a busy account queues this run, it does not fail it.
|
|
260
|
+
const started = manager.triggerSchedule(plan.id, { slot, onlyTarget: targetId, triggerSource: 'manual' });
|
|
261
|
+
return { slotId: slot.slotId, disposition: started ? 'accepted' : 'queued' };
|
|
262
|
+
},
|
|
263
|
+
recoverStatus: (targetId, requestId) => {
|
|
264
|
+
const slot = runtime.database.slots.findRecoverySlot(requestId, targetId);
|
|
265
|
+
const cell = slot && runtime.database.slots.getCell(slot.id, targetId);
|
|
266
|
+
return slot && cell
|
|
267
|
+
? { requestId, slotId: slot.id, state: cell.status, slotStatus: slot.status }
|
|
268
|
+
: null;
|
|
269
|
+
},
|
|
211
270
|
status: (scheduleId) => {
|
|
212
271
|
const cfg = resolveConfig();
|
|
213
272
|
const plan = findPlan(cfg, scheduleId);
|
|
@@ -258,6 +317,10 @@ class SchedulerCommand extends Command_1.BaseCommand {
|
|
|
258
317
|
// Second belt: never exit underneath an in-flight slot execution
|
|
259
318
|
// even if a durable row ever looks a beat early (§idle-inflight).
|
|
260
319
|
activeExecutions: runtime.activeExecutionCount(),
|
|
320
|
+
// Third belt: work parked waiting for resource capacity is
|
|
321
|
+
// unfinished work (§resource-governance) — a queued run must not
|
|
322
|
+
// let the machine stop before it runs.
|
|
323
|
+
waitingForResource: manager.waitingWorkCount(),
|
|
261
324
|
};
|
|
262
325
|
},
|
|
263
326
|
idleGraceMs: scheduling.idleGraceMs,
|
|
@@ -50,6 +50,14 @@ export interface IdleSnapshot {
|
|
|
50
50
|
* durable slot/outbox rows.
|
|
51
51
|
*/
|
|
52
52
|
activeExecutions: number;
|
|
53
|
+
/**
|
|
54
|
+
* Work items parked in the resource admission queue (§resource-governance).
|
|
55
|
+
* Waiting for capacity is UNFINISHED work, not idleness: a queued run has a
|
|
56
|
+
* durable slot row (counted above) but may not have reached `claimRunLease`
|
|
57
|
+
* yet in internal mode, so this explicit belt prevents the worker from
|
|
58
|
+
* shutting down while a run is still waiting to start.
|
|
59
|
+
*/
|
|
60
|
+
waitingForResource: number;
|
|
53
61
|
}
|
|
54
62
|
/** True only when the worker has no work of any kind left. */
|
|
55
63
|
export declare function isIdle(snapshot: IdleSnapshot): boolean;
|
|
@@ -39,7 +39,8 @@ function isIdle(snapshot) {
|
|
|
39
39
|
return (snapshot.activeSlots === 0 &&
|
|
40
40
|
snapshot.processingOutbox === 0 &&
|
|
41
41
|
snapshot.pendingOutbox === 0 &&
|
|
42
|
-
snapshot.activeExecutions === 0
|
|
42
|
+
snapshot.activeExecutions === 0 &&
|
|
43
|
+
snapshot.waitingForResource === 0);
|
|
43
44
|
}
|
|
44
45
|
/** Default idle grace: long enough to merge two schedules ~10 minutes apart. */
|
|
45
46
|
exports.DEFAULT_IDLE_GRACE_MS = 10 * 60 * 1000;
|
|
@@ -58,6 +58,7 @@ const DeliveryDispatcher_1 = require("../delivery/DeliveryDispatcher");
|
|
|
58
58
|
const token_maintenance_1 = require("../utils/token-maintenance");
|
|
59
59
|
const schedules_1 = require("../scheduler/schedules");
|
|
60
60
|
const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
|
|
61
|
+
const RecoveryPolicy_1 = require("../scheduler/RecoveryPolicy");
|
|
61
62
|
const DeliveryLedgerPort_1 = require("../delivery/DeliveryLedgerPort");
|
|
62
63
|
const OutboxWorker_1 = require("../delivery/OutboxWorker");
|
|
63
64
|
const settleDeliveryTerminal_1 = require("../delivery/settleDeliveryTerminal");
|
|
@@ -286,6 +287,20 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
286
287
|
// "重抓/换一张" 只重跑产生该审核的那一个 target。
|
|
287
288
|
targets = targets.filter((t) => t.id === onlyTarget);
|
|
288
289
|
}
|
|
290
|
+
// Occurrence-scoped acquisition policy (§recovery-policy). A manual recovery
|
|
291
|
+
// slot carries a server-defined preset; `relaxed` widens only SOFT criteria
|
|
292
|
+
// (search range / candidate count / language window) for THIS occurrence.
|
|
293
|
+
// The global config is never written, so future schedules are unaffected.
|
|
294
|
+
const recoveryMode = providedSlot?.recoveryMode;
|
|
295
|
+
if (recoveryMode && recoveryMode !== 'normal') {
|
|
296
|
+
targets = targets.map((target) => (0, RecoveryPolicy_1.applyAcquisitionPolicy)(target, recoveryMode));
|
|
297
|
+
logger_1.logger.info('Manual recovery policy applied to this occurrence', {
|
|
298
|
+
scheduleId: schedule.id,
|
|
299
|
+
slot: providedSlot?.slotId,
|
|
300
|
+
recoveryMode,
|
|
301
|
+
targets: targets.map((t) => t.id),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
289
304
|
if (targets.length === 0) {
|
|
290
305
|
logger_1.logger.warn('Scheduled plan has no selected targets; skipping', {
|
|
291
306
|
scheduleId: schedule.id,
|
|
@@ -639,6 +654,8 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
639
654
|
status: c.status,
|
|
640
655
|
workId: c.workId,
|
|
641
656
|
error: c.error ?? null,
|
|
657
|
+
terminal_reason_code: c.terminalReasonCode ?? null,
|
|
658
|
+
reason: c.terminalReasonMessage ?? null,
|
|
642
659
|
};
|
|
643
660
|
}));
|
|
644
661
|
}
|
package/dist/config/types.d.ts
CHANGED
|
@@ -279,6 +279,25 @@ export interface PixivCredentialConfig {
|
|
|
279
279
|
deviceToken: string;
|
|
280
280
|
refreshToken: string;
|
|
281
281
|
userAgent: string;
|
|
282
|
+
/**
|
|
283
|
+
* Stable internal identity of this credential profile (§resource-governance).
|
|
284
|
+
* It is the RESOURCE identity used for admission keys (`pixiv-account:<id>`),
|
|
285
|
+
* never a token/cookie and never a bot/schedule/target name. Default 'default'.
|
|
286
|
+
*/
|
|
287
|
+
accountId?: string;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Operator-level resource capacity configuration (§resource-governance).
|
|
291
|
+
* Concurrency is scoped by the real constrained resource, not by bot/schedule/
|
|
292
|
+
* target. Current production resource: the single Pixiv account profile, with
|
|
293
|
+
* `maxConcurrency: 1` recommended. This is deployment config — never exposed
|
|
294
|
+
* to ordinary manager UI.
|
|
295
|
+
*/
|
|
296
|
+
export interface ResourceGovernanceConfig {
|
|
297
|
+
/** Per-Pixiv-account-profile capacity. Keyed by `pixiv.accountId`. */
|
|
298
|
+
pixivAccounts?: Record<string, {
|
|
299
|
+
maxConcurrency?: number;
|
|
300
|
+
}>;
|
|
282
301
|
}
|
|
283
302
|
export interface NetworkConfig {
|
|
284
303
|
/**
|
|
@@ -473,6 +492,13 @@ export interface SchedulerRuntimeConfig {
|
|
|
473
492
|
* resumes from the same durable Slot/outbox rows. Default: 10800000ms (3h).
|
|
474
493
|
*/
|
|
475
494
|
maxLifetimeMs?: number;
|
|
495
|
+
/**
|
|
496
|
+
* Operator-level resource capacity (§resource-governance). Concurrency is
|
|
497
|
+
* scoped by the real constrained resource (e.g. the shared Pixiv account),
|
|
498
|
+
* never by bot/schedule/target. Waiting for capacity is a normal state, not
|
|
499
|
+
* an execution failure. Not exposed to ordinary manager UI.
|
|
500
|
+
*/
|
|
501
|
+
resourceGovernance?: ResourceGovernanceConfig;
|
|
476
502
|
/**
|
|
477
503
|
* External-mode HTTP trigger settings. Ignored in internal mode.
|
|
478
504
|
*/
|
|
@@ -473,6 +473,27 @@ function validateConfig(config, location, databasePath) {
|
|
|
473
473
|
errors.push('schedulerRuntime.trigger.graceMinutes: Must be a positive integer (minutes)');
|
|
474
474
|
}
|
|
475
475
|
}
|
|
476
|
+
const rg = rt.resourceGovernance;
|
|
477
|
+
if (rg) {
|
|
478
|
+
if (rg.pixivAccounts !== undefined && (typeof rg.pixivAccounts !== 'object' || rg.pixivAccounts === null || Array.isArray(rg.pixivAccounts))) {
|
|
479
|
+
errors.push('schedulerRuntime.resourceGovernance.pixivAccounts: Must be an object keyed by pixiv account id');
|
|
480
|
+
}
|
|
481
|
+
else if (rg.pixivAccounts) {
|
|
482
|
+
for (const [accountId, profile] of Object.entries(rg.pixivAccounts)) {
|
|
483
|
+
if (!accountId.trim()) {
|
|
484
|
+
errors.push('schedulerRuntime.resourceGovernance.pixivAccounts: account ids must be non-empty');
|
|
485
|
+
}
|
|
486
|
+
if (profile === null || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
487
|
+
errors.push(`schedulerRuntime.resourceGovernance.pixivAccounts.${accountId}: Must be an object`);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const max = profile.maxConcurrency;
|
|
491
|
+
if (max !== undefined && (!Number.isInteger(max) || max < 1 || max > 16)) {
|
|
492
|
+
errors.push(`schedulerRuntime.resourceGovernance.pixivAccounts.${accountId}.maxConcurrency: Must be an integer between 1 and 16`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
476
497
|
}
|
|
477
498
|
// Validate download config
|
|
478
499
|
if (config.download) {
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -122,11 +122,26 @@ export interface DeliveryNotificationRequest {
|
|
|
122
122
|
scheduleId: string;
|
|
123
123
|
slotId: string;
|
|
124
124
|
status: 'success' | 'partial' | 'failed';
|
|
125
|
+
/**
|
|
126
|
+
* Present when this terminal outcome belongs to a MANUAL RECOVERY run
|
|
127
|
+
* (§manual-recovery): the request id and the policy preset used. Lets the
|
|
128
|
+
* receiving service render "已恢复" instead of the daily summary.
|
|
129
|
+
*/
|
|
130
|
+
recovery?: {
|
|
131
|
+
mode: 'normal' | 'relaxed';
|
|
132
|
+
requestId: string;
|
|
133
|
+
};
|
|
125
134
|
targets?: Array<{
|
|
126
135
|
targetId: string;
|
|
127
136
|
workType: string;
|
|
128
137
|
status: string;
|
|
129
138
|
workId?: string | null;
|
|
139
|
+
/** Raw cell error (request-level observability; NOT user-safe text). */
|
|
140
|
+
error?: string | null;
|
|
141
|
+
/** Normalized terminal failure code (§terminal-reason). */
|
|
142
|
+
terminal_reason_code?: string | null;
|
|
143
|
+
/** User-facing business reason message for the failure. */
|
|
144
|
+
reason?: string | null;
|
|
130
145
|
}>;
|
|
131
146
|
};
|
|
132
147
|
}
|
|
@@ -44,6 +44,8 @@ export declare class NotificationPolicy {
|
|
|
44
44
|
status: string;
|
|
45
45
|
workId: string | null;
|
|
46
46
|
error: string | null;
|
|
47
|
+
terminal_reason_code?: string | null;
|
|
48
|
+
reason?: string | null;
|
|
47
49
|
}>): void;
|
|
48
50
|
/**
|
|
49
51
|
* Delivery targets whose HTTP target declares the given outcome URL.
|
|
@@ -88,7 +88,13 @@ class NotificationPolicy {
|
|
|
88
88
|
(r.workId ? ` #${r.workId}` : '') +
|
|
89
89
|
(r.status === 'delivery_pending' ? ' 投递中' : '') +
|
|
90
90
|
(r.status === 'no_candidate' ? ' 无候选' : '') +
|
|
91
|
-
|
|
91
|
+
// First-level cause, business language only: the durable normalized
|
|
92
|
+
// reason replaces any raw error text (§terminal-reason).
|
|
93
|
+
((r.status === 'failed' || r.status === 'no_candidate' || r.status === 'duplicate') && r.reason
|
|
94
|
+
? ` ${r.reason.slice(0, 80)}`
|
|
95
|
+
: r.status === 'failed' && r.error
|
|
96
|
+
? ` ${r.error.slice(0, 80)}`
|
|
97
|
+
: ''));
|
|
92
98
|
const submitted = rows.filter((r) => r.status === 'submitted').length;
|
|
93
99
|
const text = [
|
|
94
100
|
`${schedule.name?.trim() || schedule.id} · ${slot.occurrenceLabel}`,
|
|
@@ -102,11 +108,22 @@ class NotificationPolicy {
|
|
|
102
108
|
scheduleId: schedule.id,
|
|
103
109
|
slotId: slot.slotId,
|
|
104
110
|
status: outcomeStatus,
|
|
111
|
+
...(slot.recoveryRequestId
|
|
112
|
+
? {
|
|
113
|
+
recovery: {
|
|
114
|
+
mode: slot.recoveryMode ?? 'normal',
|
|
115
|
+
requestId: slot.recoveryRequestId,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
105
119
|
targets: rows.map((r) => ({
|
|
106
120
|
targetId: r.targetId,
|
|
107
121
|
workType: r.workType,
|
|
108
122
|
status: r.status,
|
|
109
123
|
workId: r.workId,
|
|
124
|
+
error: r.error,
|
|
125
|
+
terminal_reason_code: r.terminal_reason_code ?? null,
|
|
126
|
+
reason: r.reason ?? null,
|
|
110
127
|
})),
|
|
111
128
|
});
|
|
112
129
|
}
|
package/dist/package.json
CHANGED
|
@@ -34,7 +34,9 @@ export declare const RECOVERY_INTERVAL_MS: number;
|
|
|
34
34
|
/**
|
|
35
35
|
* Hosts many cron plans in one process. A validated config snapshot replaces
|
|
36
36
|
* the complete cron table at once; invalid updates leave the previous table
|
|
37
|
-
* running. All jobs
|
|
37
|
+
* running. All jobs that consume the same constrained resource (today: the
|
|
38
|
+
* shared Pixiv account) pass through ONE bounded resource admission; work for
|
|
39
|
+
* different resource identities runs concurrently.
|
|
38
40
|
*/
|
|
39
41
|
export declare class MultiScheduleManager {
|
|
40
42
|
private readonly options;
|
|
@@ -46,6 +48,15 @@ export declare class MultiScheduleManager {
|
|
|
46
48
|
private watching;
|
|
47
49
|
private readonly admission;
|
|
48
50
|
constructor(options: MultiScheduleManagerOptions);
|
|
51
|
+
/**
|
|
52
|
+
* Deduplicated, log-safe observable: how many work items are parked waiting
|
|
53
|
+
* for any resource. Used by the idle lifecycle — waiting work is unfinished
|
|
54
|
+
* work and must prevent premature shutdown (§waiting-is-not-idle).
|
|
55
|
+
*/
|
|
56
|
+
waitingWorkCount(): number;
|
|
57
|
+
/** The resource key a plan's work consumes (resource identity, never a bot/
|
|
58
|
+
* schedule/target name). All Pixiv-consuming work shares the account id. */
|
|
59
|
+
private resourceKeyForPlan;
|
|
49
60
|
start(initialConfig?: StandaloneConfig): ConfigReloadResult;
|
|
50
61
|
private startRecoveryLoop;
|
|
51
62
|
/**
|
|
@@ -9,6 +9,7 @@ const node_fs_1 = require("node:fs");
|
|
|
9
9
|
const cron_parser_1 = __importDefault(require("cron-parser"));
|
|
10
10
|
const logger_1 = require("../logger");
|
|
11
11
|
const Scheduler_1 = require("./Scheduler");
|
|
12
|
+
const ResourceAdmission_1 = require("./ResourceAdmission");
|
|
12
13
|
const schedules_1 = require("./schedules");
|
|
13
14
|
const OccurrenceResolver_1 = require("./OccurrenceResolver");
|
|
14
15
|
/**
|
|
@@ -23,57 +24,26 @@ function asTriggerSource(value) {
|
|
|
23
24
|
? value
|
|
24
25
|
: 'catchup';
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.active = true;
|
|
40
|
-
return Promise.resolve(this.createLease());
|
|
41
|
-
}
|
|
42
|
-
// A slow task may span multiple cron ticks. Retain at most one pending run
|
|
43
|
-
// for each plan so a temporary outage cannot create an unbounded backlog.
|
|
44
|
-
if (this.pendingIds.has(scheduleId) || this.queue.length >= this.queueLimit) {
|
|
45
|
-
return Promise.resolve(null);
|
|
46
|
-
}
|
|
47
|
-
this.pendingIds.add(scheduleId);
|
|
48
|
-
return new Promise((resolve) => {
|
|
49
|
-
this.queue.push({ scheduleId, resolve });
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
createLease() {
|
|
53
|
-
let released = false;
|
|
54
|
-
return {
|
|
55
|
-
release: () => {
|
|
56
|
-
if (released)
|
|
57
|
-
return;
|
|
58
|
-
released = true;
|
|
59
|
-
this.releaseNext();
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
releaseNext() {
|
|
64
|
-
const next = this.queue.shift();
|
|
65
|
-
if (!next) {
|
|
66
|
-
this.active = false;
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
this.pendingIds.delete(next.scheduleId);
|
|
70
|
-
next.resolve(this.createLease());
|
|
71
|
-
}
|
|
27
|
+
/**
|
|
28
|
+
* Capacity resolver for one resource key, from the configuration's
|
|
29
|
+
* resource-governance block (§resource-governance). Unknown resources default
|
|
30
|
+
* to capacity 1. The key encodes the resource identity (`pixiv-account:<id>`),
|
|
31
|
+
* so two different accounts get two independent capacities.
|
|
32
|
+
*/
|
|
33
|
+
function capacityOfResourceKey(config, key) {
|
|
34
|
+
const prefix = 'pixiv-account:';
|
|
35
|
+
if (!key.startsWith(prefix))
|
|
36
|
+
return 1;
|
|
37
|
+
const accountId = key.slice(prefix.length);
|
|
38
|
+
const profile = config?.schedulerRuntime?.resourceGovernance?.pixivAccounts?.[accountId];
|
|
39
|
+
return Math.max(1, profile?.maxConcurrency ?? 1);
|
|
72
40
|
}
|
|
73
41
|
/**
|
|
74
42
|
* Hosts many cron plans in one process. A validated config snapshot replaces
|
|
75
43
|
* the complete cron table at once; invalid updates leave the previous table
|
|
76
|
-
* running. All jobs
|
|
44
|
+
* running. All jobs that consume the same constrained resource (today: the
|
|
45
|
+
* shared Pixiv account) pass through ONE bounded resource admission; work for
|
|
46
|
+
* different resource identities runs concurrently.
|
|
77
47
|
*/
|
|
78
48
|
class MultiScheduleManager {
|
|
79
49
|
options;
|
|
@@ -83,9 +53,25 @@ class MultiScheduleManager {
|
|
|
83
53
|
reloadTimer = null;
|
|
84
54
|
recoveryTimer = null;
|
|
85
55
|
watching = false;
|
|
86
|
-
admission
|
|
56
|
+
admission;
|
|
87
57
|
constructor(options) {
|
|
88
58
|
this.options = options;
|
|
59
|
+
// Capacity is read lazily from the current config snapshot so a hot
|
|
60
|
+
// reload that changes `resourceGovernance` applies immediately.
|
|
61
|
+
this.admission = new ResourceAdmission_1.ResourceAdmission((key) => capacityOfResourceKey(this.activeConfig, key), 8);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Deduplicated, log-safe observable: how many work items are parked waiting
|
|
65
|
+
* for any resource. Used by the idle lifecycle — waiting work is unfinished
|
|
66
|
+
* work and must prevent premature shutdown (§waiting-is-not-idle).
|
|
67
|
+
*/
|
|
68
|
+
waitingWorkCount() {
|
|
69
|
+
return this.admission.waitingTotal();
|
|
70
|
+
}
|
|
71
|
+
/** The resource key a plan's work consumes (resource identity, never a bot/
|
|
72
|
+
* schedule/target name). All Pixiv-consuming work shares the account id. */
|
|
73
|
+
resourceKeyForPlan() {
|
|
74
|
+
return (0, ResourceAdmission_1.pixivAccountResourceKey)(this.activeConfig?.pixiv?.accountId);
|
|
89
75
|
}
|
|
90
76
|
start(initialConfig) {
|
|
91
77
|
const config = initialConfig ?? this.options.loadConfig();
|
|
@@ -180,6 +166,12 @@ class MultiScheduleManager {
|
|
|
180
166
|
slotDate: slot.slotDate || slot.occurrenceDate,
|
|
181
167
|
manualRequestId: slot.manualRequestId ?? undefined,
|
|
182
168
|
correlationId: slot.correlationId ?? undefined,
|
|
169
|
+
// A crashed manual recovery resumes with the SAME occurrence-scoped
|
|
170
|
+
// policy, never a re-resolution that would lose the override.
|
|
171
|
+
recoveryRequestId: slot.recoveryRequestId ?? undefined,
|
|
172
|
+
recoveryMode: slot.recoveryMode === 'relaxed' || slot.recoveryMode === 'normal'
|
|
173
|
+
? slot.recoveryMode
|
|
174
|
+
: undefined,
|
|
183
175
|
};
|
|
184
176
|
const admitted = this.triggerSchedule(slot.scheduleId, {
|
|
185
177
|
triggerSource: context.triggerSource,
|
|
@@ -284,12 +276,15 @@ class MultiScheduleManager {
|
|
|
284
276
|
this.activeConfig = config;
|
|
285
277
|
this.generation++;
|
|
286
278
|
const generation = this.generation;
|
|
287
|
-
|
|
279
|
+
// Bounded wait queue across ALL resources (mirrors the legacy queueLimit
|
|
280
|
+
// guard against an unbounded backlog); capacity itself is per resource key.
|
|
281
|
+
this.admission.setMaxWaiting(queueLimit);
|
|
282
|
+
const resourceKey = this.resourceKeyForPlan();
|
|
288
283
|
for (const plan of enabledPlans) {
|
|
289
284
|
// Watchdog: schedules without an explicit timeout still get a cap so a
|
|
290
285
|
// wedged run cannot hold the shared admission queue forever.
|
|
291
286
|
const schedulerConfig = plan.timeout !== undefined ? plan : { ...plan, timeout: Scheduler_1.DEFAULT_SCHEDULE_TIMEOUT_MS };
|
|
292
|
-
const scheduler = new Scheduler_1.Scheduler(schedulerConfig, this.options.database, this.options.telemetry, plan.id, this.admission, (failure) => this.options.onFailure?.(config, plan, failure), (abandoned) => this.options.onAbandoned?.(config, plan, abandoned));
|
|
287
|
+
const scheduler = new Scheduler_1.Scheduler(schedulerConfig, this.options.database, this.options.telemetry, plan.id, this.admission, (failure) => this.options.onFailure?.(config, plan, failure), (abandoned) => this.options.onAbandoned?.(config, plan, abandoned), resourceKey);
|
|
293
288
|
scheduler[registerCron ? 'start' : 'init'](async (options) => {
|
|
294
289
|
// The closure keeps the exact validated snapshot for an in-flight run.
|
|
295
290
|
// Cron fires pass no options (runJob resolves the occurrence for now);
|
|
@@ -308,6 +303,7 @@ class MultiScheduleManager {
|
|
|
308
303
|
targets: plan.targetIds?.length ?? 'all',
|
|
309
304
|
})),
|
|
310
305
|
queueLimit,
|
|
306
|
+
resourceKey,
|
|
311
307
|
});
|
|
312
308
|
return { ok: true, generation, schedules: scheduleIds };
|
|
313
309
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution / acquisition policy preset (§recovery-policy).
|
|
3
|
+
*
|
|
4
|
+
* Operators do not tune `lookbackDays` / `candidateScanLimit` / language
|
|
5
|
+
* windows per attempt. The server defines a small number of named presets —
|
|
6
|
+
* `normal` and `relaxed` — and a recovery run reads the "effective policy"
|
|
7
|
+
* derived from the base config plus the occurrence-scoped override.
|
|
8
|
+
*
|
|
9
|
+
* Hard business constraints are NEVER relaxed by an ordinary recovery policy:
|
|
10
|
+
* security rules, explicit bans, wrong media type, data-integrity requirements,
|
|
11
|
+
* permission constraints, the selected topic/work-type boundary, delivery
|
|
12
|
+
* wiring and already-successfully-submitted exact duplicates are untouched.
|
|
13
|
+
* Only SOFT criteria (search range, candidate scan count, language-candidate
|
|
14
|
+
* window) may widen.
|
|
15
|
+
*
|
|
16
|
+
* The override is occurrence-scoped: `applyAcquisitionPolicy` is a pure
|
|
17
|
+
* function over a target snapshot. Nothing here ever writes the global config,
|
|
18
|
+
* so a manual relaxed recovery can never change future scheduled runs.
|
|
19
|
+
*/
|
|
20
|
+
import { TargetConfig } from '../config';
|
|
21
|
+
export type RecoveryMode = 'normal' | 'relaxed';
|
|
22
|
+
/** The soft levers an acquisition policy may adjust. */
|
|
23
|
+
export interface AcquisitionPolicy {
|
|
24
|
+
/** Multiplier over the effective candidate scan limit (capped at 100). */
|
|
25
|
+
scanLimitMultiplier: number;
|
|
26
|
+
/** Multiplier over `noMatchPolicy.lookbackDays` (capped at the config max). */
|
|
27
|
+
lookbackDaysMultiplier: number;
|
|
28
|
+
/** Multiplier over the novel `languageCandidateLimit` search window. */
|
|
29
|
+
languageCandidateLimitMultiplier: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Server-defined presets. `normal` is the identity policy (the base config);
|
|
33
|
+
* `relaxed` widens only the soft criteria above.
|
|
34
|
+
*/
|
|
35
|
+
export declare const ACQUISITION_POLICIES: Record<RecoveryMode, AcquisitionPolicy>;
|
|
36
|
+
/** Cap on the relaxed scan limit, mirroring the schedule fallback cap. */
|
|
37
|
+
export declare const RELAXED_SCAN_LIMIT_CAP = 100;
|
|
38
|
+
/** Cap on lookback days widened by a policy (matches the config validation cap). */
|
|
39
|
+
export declare const RELAXED_LOOKBACK_DAYS_CAP = 7;
|
|
40
|
+
/**
|
|
41
|
+
* Apply an acquisition policy to ONE target snapshot (pure; never mutates the
|
|
42
|
+
* input or global config). `normal` returns the target unchanged.
|
|
43
|
+
*/
|
|
44
|
+
export declare function applyAcquisitionPolicy(target: TargetConfig, mode: RecoveryMode): TargetConfig;
|
|
45
|
+
//# sourceMappingURL=RecoveryPolicy.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RELAXED_LOOKBACK_DAYS_CAP = exports.RELAXED_SCAN_LIMIT_CAP = exports.ACQUISITION_POLICIES = void 0;
|
|
4
|
+
exports.applyAcquisitionPolicy = applyAcquisitionPolicy;
|
|
5
|
+
/**
|
|
6
|
+
* Server-defined presets. `normal` is the identity policy (the base config);
|
|
7
|
+
* `relaxed` widens only the soft criteria above.
|
|
8
|
+
*/
|
|
9
|
+
exports.ACQUISITION_POLICIES = {
|
|
10
|
+
normal: {
|
|
11
|
+
scanLimitMultiplier: 1,
|
|
12
|
+
lookbackDaysMultiplier: 1,
|
|
13
|
+
languageCandidateLimitMultiplier: 1,
|
|
14
|
+
},
|
|
15
|
+
relaxed: {
|
|
16
|
+
scanLimitMultiplier: 3,
|
|
17
|
+
lookbackDaysMultiplier: 3,
|
|
18
|
+
languageCandidateLimitMultiplier: 2,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
/** Cap on the relaxed scan limit, mirroring the schedule fallback cap. */
|
|
22
|
+
exports.RELAXED_SCAN_LIMIT_CAP = 100;
|
|
23
|
+
/** Cap on lookback days widened by a policy (matches the config validation cap). */
|
|
24
|
+
exports.RELAXED_LOOKBACK_DAYS_CAP = 7;
|
|
25
|
+
/**
|
|
26
|
+
* Apply an acquisition policy to ONE target snapshot (pure; never mutates the
|
|
27
|
+
* input or global config). `normal` returns the target unchanged.
|
|
28
|
+
*/
|
|
29
|
+
function applyAcquisitionPolicy(target, mode) {
|
|
30
|
+
if (mode === 'normal')
|
|
31
|
+
return target;
|
|
32
|
+
const policy = exports.ACQUISITION_POLICIES[mode];
|
|
33
|
+
const scanLimit = target.candidateScanLimit !== undefined
|
|
34
|
+
? cap(MULTIPLY(target.candidateScanLimit, policy.scanLimitMultiplier), exports.RELAXED_SCAN_LIMIT_CAP)
|
|
35
|
+
: undefined;
|
|
36
|
+
const languageCandidateLimit = target.languageCandidateLimit !== undefined
|
|
37
|
+
? MULTIPLY(target.languageCandidateLimit, policy.languageCandidateLimitMultiplier)
|
|
38
|
+
: undefined;
|
|
39
|
+
const lookbackDays = widenLookback(target.noMatchPolicy?.lookbackDays, policy.lookbackDaysMultiplier);
|
|
40
|
+
if (scanLimit === undefined && languageCandidateLimit === undefined && lookbackDays === undefined) {
|
|
41
|
+
return target;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
...target,
|
|
45
|
+
...(scanLimit !== undefined ? { candidateScanLimit: scanLimit } : {}),
|
|
46
|
+
...(languageCandidateLimit !== undefined ? { languageCandidateLimit } : {}),
|
|
47
|
+
...(lookbackDays !== undefined
|
|
48
|
+
? { noMatchPolicy: { ...(target.noMatchPolicy ?? {}), lookbackDays } }
|
|
49
|
+
: {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function MULTIPLY(value, multiplier) {
|
|
53
|
+
return Math.max(1, Math.floor(value * multiplier));
|
|
54
|
+
}
|
|
55
|
+
function cap(value, maximum) {
|
|
56
|
+
return Math.min(value, maximum);
|
|
57
|
+
}
|
|
58
|
+
function widenLookback(value, multiplier) {
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
return Math.min(MULTIPLY(value, multiplier), exports.RELAXED_LOOKBACK_DAYS_CAP);
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=RecoveryPolicy.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource-scoped execution admission (§resource-governance).
|
|
3
|
+
*
|
|
4
|
+
* Concurrency is governed by the constrained RESOURCE a work item consumes
|
|
5
|
+
* (today: a Pixiv account), never by bot, schedule or target. Every work item
|
|
6
|
+
* that touches the scarce upstream — scheduled acquisition, fallback passes,
|
|
7
|
+
* manual refetch, manual normal/relaxed recovery — declares the resource key
|
|
8
|
+
* it needs and passes through this same bounded-capacity admission.
|
|
9
|
+
*
|
|
10
|
+
* Design constraints honoured here:
|
|
11
|
+
*
|
|
12
|
+
* - Resource identity is a stable internal profile key (e.g.
|
|
13
|
+
* `pixiv-account:<accountId>`). Tokens/cookies/session values are never a
|
|
14
|
+
* resource key and never appear in logs.
|
|
15
|
+
* - Capacity is config-driven per resource identity; the scheduler code does
|
|
16
|
+
* not hardcode `1`.
|
|
17
|
+
* - Waiting is FIFO (arrival order) per resource key, so no producer is
|
|
18
|
+
* starved. Capacity is checked at the moment a lease is handed out, so a
|
|
19
|
+
* single process never exceeds `capacity` active leases for one key.
|
|
20
|
+
* - Different resource identities never block each other: account A and
|
|
21
|
+
* account B run concurrently under independent capacities (no global lock).
|
|
22
|
+
* - Waiting is NOT a failure: `acquire` resolves with `null` only when the
|
|
23
|
+
* bounded wait queue itself is full (the caller then leaves the work in its
|
|
24
|
+
* durable ledger for the recovery sweep). A queued work item is unfinished
|
|
25
|
+
* work — it must keep the idle-detector from shutting the worker down
|
|
26
|
+
* (see `waitingTotal` on the idle snapshot).
|
|
27
|
+
*
|
|
28
|
+
* Implementation level matches the production topology: one PixivFlow process
|
|
29
|
+
* owns the whole ledger on one machine, so an in-process FIFO semaphore is the
|
|
30
|
+
* correct mechanism. A durable/distributed lease would only be needed if the
|
|
31
|
+
* same resource could be consumed by multiple processes at once.
|
|
32
|
+
*/
|
|
33
|
+
export interface ResourceLease {
|
|
34
|
+
release(): void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* FIFO per-resource admission with config-driven capacity.
|
|
38
|
+
*
|
|
39
|
+
* `capacityOf(resourceKey)` returns the configured capacity for that key
|
|
40
|
+
* (default 1). `maxWaiting` bounds the TOTAL number of parked work items
|
|
41
|
+
* across all resources, mirroring the legacy `schedulerRuntime.queueLimit`
|
|
42
|
+
* guard against an unbounded backlog.
|
|
43
|
+
*/
|
|
44
|
+
export declare class ResourceAdmission {
|
|
45
|
+
private readonly capacityOf;
|
|
46
|
+
private maxWaiting;
|
|
47
|
+
private readonly active;
|
|
48
|
+
private readonly waiting;
|
|
49
|
+
constructor(capacityOf: (resourceKey: string) => number, maxWaiting?: number);
|
|
50
|
+
setMaxWaiting(maxWaiting: number): void;
|
|
51
|
+
/**
|
|
52
|
+
* Acquire a lease on `resourceKey`. Resolves immediately when capacity is
|
|
53
|
+
* available, parks the caller in a FIFO per-key queue otherwise, and resolves
|
|
54
|
+
* `null` (never rejects) when the bounded wait queue is full.
|
|
55
|
+
*/
|
|
56
|
+
acquire(resourceKey: string): Promise<ResourceLease | null>;
|
|
57
|
+
private createLease;
|
|
58
|
+
private releaseNext;
|
|
59
|
+
/** Active leases for one resource key. */
|
|
60
|
+
activeCount(resourceKey: string): number;
|
|
61
|
+
/** Work items parked waiting for one resource key. */
|
|
62
|
+
waitingCount(resourceKey: string): number;
|
|
63
|
+
/** Work items parked waiting for ANY resource. Unfinished work, not failure. */
|
|
64
|
+
waitingTotal(): number;
|
|
65
|
+
}
|
|
66
|
+
/** The canonical resource identity for the process's Pixiv credential profile. */
|
|
67
|
+
export declare function pixivAccountResourceKey(accountId: string | undefined): string;
|
|
68
|
+
//# sourceMappingURL=ResourceAdmission.d.ts.map
|