@3minapi/cli 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +82 -2
- package/dist/bin.js +54 -0
- package/dist/cli.js +241 -0
- package/dist/commands/auth.js +573 -0
- package/dist/commands/common.js +48 -0
- package/dist/commands/dashboard.js +298 -0
- package/dist/commands/delete.js +167 -0
- package/dist/commands/doctor.js +326 -0
- package/dist/commands/guide.js +84 -0
- package/dist/commands/index.js +28 -0
- package/dist/commands/init.js +248 -0
- package/dist/commands/logs.js +154 -0
- package/dist/commands/new.js +109 -0
- package/dist/commands/paths.js +64 -0
- package/dist/commands/reload.js +32 -0
- package/dist/commands/results.js +445 -0
- package/dist/commands/start.js +443 -0
- package/dist/commands/stop.js +68 -0
- package/dist/commands/tasks.js +93 -0
- package/dist/commands/worker.js +178 -0
- package/dist/core/api.js +132 -0
- package/dist/core/config.js +188 -0
- package/dist/core/control-db.js +462 -0
- package/dist/core/credentials.js +75 -0
- package/dist/core/domain/constants.js +121 -0
- package/dist/core/domain/contract.js +102 -0
- package/dist/core/domain/errors.js +57 -0
- package/dist/core/domain/index.js +3 -0
- package/dist/core/domain/types.js +1 -0
- package/dist/core/error-report.js +103 -0
- package/dist/core/host/detach.js +49 -0
- package/dist/core/host/live-connection.js +270 -0
- package/dist/core/host/live-receiver.js +264 -0
- package/dist/core/host/module.js +1 -0
- package/dist/core/host/poller.js +113 -0
- package/dist/core/host/pull-step.js +54 -0
- package/dist/core/host/receiver.js +1 -0
- package/dist/core/host/reload.js +34 -0
- package/dist/core/host/runner-lock.js +245 -0
- package/dist/core/host/session.js +84 -0
- package/dist/core/host/usage.js +20 -0
- package/dist/core/log-read.js +59 -0
- package/dist/core/logger.js +206 -0
- package/dist/core/names.js +27 -0
- package/dist/core/network.js +10 -0
- package/dist/core/open-browser.js +26 -0
- package/dist/core/output.js +68 -0
- package/dist/core/paths.js +26 -0
- package/dist/core/project.js +85 -0
- package/dist/core/prompt.js +140 -0
- package/dist/core/redact.js +45 -0
- package/dist/core/secure-fs.js +50 -0
- package/dist/core/text-file.js +12 -0
- package/dist/core/text.js +27 -0
- package/dist/core/time.js +84 -0
- package/dist/core/update-notice.js +142 -0
- package/dist/core/version.js +16 -0
- package/dist/core/warnings.js +10 -0
- package/dist/help.js +144 -0
- package/dist/modules/dashboard/assets.js +411 -0
- package/dist/modules/dashboard/data.js +565 -0
- package/dist/modules/dashboard/guard.js +75 -0
- package/dist/modules/dashboard/lock.js +93 -0
- package/dist/modules/dashboard/server.js +295 -0
- package/dist/modules/dashboard/views/html.js +114 -0
- package/dist/modules/dashboard/views/layout.js +59 -0
- package/dist/modules/dashboard/views/logs.js +122 -0
- package/dist/modules/dashboard/views/overview.js +316 -0
- package/dist/modules/dashboard/views/runs.js +486 -0
- package/dist/modules/dashboard/views/workers.js +157 -0
- package/dist/modules/workers/builtin/host-identity.js +59 -0
- package/dist/modules/workers/builtin/results-worker.js +37 -0
- package/dist/modules/workers/local-server.js +244 -0
- package/dist/modules/workers/module.js +98 -0
- package/dist/modules/workers/reload-result.js +16 -0
- package/dist/modules/workers/repeat-scheduler.js +165 -0
- package/dist/modules/workers/results/missing-paths.js +66 -0
- package/dist/modules/workers/results/query.js +175 -0
- package/dist/modules/workers/results/reader-input.js +134 -0
- package/dist/modules/workers/results/reader.js +430 -0
- package/dist/modules/workers/results/results-child.js +64 -0
- package/dist/modules/workers/results/shape.js +212 -0
- package/dist/modules/workers/results/store.js +336 -0
- package/dist/modules/workers/results/substitute.js +30 -0
- package/dist/modules/workers/runtime/env.js +43 -0
- package/dist/modules/workers/runtime/error-chain.js +109 -0
- package/dist/modules/workers/runtime/executor.js +289 -0
- package/dist/modules/workers/runtime/inspect.js +53 -0
- package/dist/modules/workers/runtime/protocol.js +1 -0
- package/dist/modules/workers/runtime/resolve.js +30 -0
- package/dist/modules/workers/runtime/schema.js +85 -0
- package/dist/modules/workers/runtime/thread.js +117 -0
- package/dist/modules/workers/start.js +265 -0
- package/dist/modules/workers/sync.js +241 -0
- package/dist/modules/workers/task-runner.js +892 -0
- package/dist/modules/workers/template.js +100 -0
- package/dist/modules/workers/worker-set.js +122 -0
- package/dist/modules/workers/workers.js +66 -0
- package/package.json +48 -5
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
import { RepeatScheduler, parseSettings } from "./repeat-scheduler.js";
|
|
2
|
+
import { ApiError } from "../../core/api.js";
|
|
3
|
+
import { ERROR_MESSAGE_MAX_CHARS, EVENT_MESSAGE_MAX_CHARS, REPORT_ENVELOPE_RESERVE, REPEAT_FAIL_STREAK_MAX, REPORT_MAX_BYTES, RESULT_RESERVED_KEY, STATUS_MESSAGE_MAX_CHARS, StopError, TASK_CODES, TASK_ERROR_CODE } from "../../core/domain/index.js";
|
|
4
|
+
import { computeShape } from "./results/shape.js";
|
|
5
|
+
import { substituteResult, substituteStatusMessage } from "./results/substitute.js";
|
|
6
|
+
import { formatCount, oneLine, stripControl, truncate } from "../../core/text.js";
|
|
7
|
+
export const CANCEL = {
|
|
8
|
+
server: 'cancelled',
|
|
9
|
+
superseded: 'superseded',
|
|
10
|
+
shutdown: 'host_stopped'
|
|
11
|
+
};
|
|
12
|
+
const STOPPED_PREFIX = 'stopped: ';
|
|
13
|
+
const PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
export class TaskRunner {
|
|
15
|
+
opts;
|
|
16
|
+
inflight = new Map();
|
|
17
|
+
pending = [];
|
|
18
|
+
repeatAcks = 0;
|
|
19
|
+
flushing = null;
|
|
20
|
+
flushAgain = null;
|
|
21
|
+
scheduler;
|
|
22
|
+
constructor(opts) {
|
|
23
|
+
this.opts = opts;
|
|
24
|
+
this.scheduler = new RepeatScheduler({
|
|
25
|
+
db: opts.db,
|
|
26
|
+
logger: opts.logger,
|
|
27
|
+
now: opts.now,
|
|
28
|
+
run: (taskId, runNo) => this.runRepeat(taskId, runNo),
|
|
29
|
+
every: opts.repeatEvery
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
get free() {
|
|
33
|
+
return Math.max(0, this.opts.executor.concurrency - this.inflight.size - this.repeatAcks);
|
|
34
|
+
}
|
|
35
|
+
get busy() {
|
|
36
|
+
return this.inflight.size;
|
|
37
|
+
}
|
|
38
|
+
get repeating() {
|
|
39
|
+
return this.scheduler.size;
|
|
40
|
+
}
|
|
41
|
+
startRepeats() {
|
|
42
|
+
this.scheduler.load();
|
|
43
|
+
}
|
|
44
|
+
dropRepeats(workers) {
|
|
45
|
+
const gone = new Set(workers);
|
|
46
|
+
const ids = new Set(this.scheduler.dropWorkers(workers));
|
|
47
|
+
for (const row of this.opts.db.repeatTasks())
|
|
48
|
+
if (gone.has(row.worker))
|
|
49
|
+
ids.add(row.task_id);
|
|
50
|
+
for (const id of ids) {
|
|
51
|
+
this.scheduler.remove(id);
|
|
52
|
+
this.opts.logger.info('repeating task stopped; its worker was deleted on the server', {
|
|
53
|
+
task_id: id
|
|
54
|
+
});
|
|
55
|
+
this.opts.db.markFinished(id, 'cancelled', this.opts.now(), 'worker deleted on the server');
|
|
56
|
+
this.opts.executor.cancel(id, CANCEL.server);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
handle(task, reporter, { ack }) {
|
|
60
|
+
return this.process(task, reporter, ack).catch((err) => {
|
|
61
|
+
this.opts.logger.error('task handling failed', {
|
|
62
|
+
task_id: task.task_id,
|
|
63
|
+
worker: task.worker,
|
|
64
|
+
message: err?.stack ?? String(err)
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
cancel(taskId) {
|
|
69
|
+
const repeat = this.scheduler.has(taskId) || this.isWaitingRepeat(taskId);
|
|
70
|
+
if (repeat) {
|
|
71
|
+
this.scheduler.remove(taskId);
|
|
72
|
+
this.opts.db.markFinished(taskId, 'cancelled', this.opts.now());
|
|
73
|
+
this.event(`${this.workerOf(taskId)} ${shortId(taskId)} repeat cancelled`);
|
|
74
|
+
}
|
|
75
|
+
if (this.opts.executor.cancel(taskId, CANCEL.server) || repeat) {
|
|
76
|
+
this.opts.logger.info('task cancelled by the server', { task_id: taskId });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
isWaitingRepeat(taskId) {
|
|
80
|
+
const row = this.opts.db.getTask(taskId);
|
|
81
|
+
return Boolean(row?.repeat) && (row.state === 'received' || row.state === 'running');
|
|
82
|
+
}
|
|
83
|
+
async shutdown() {
|
|
84
|
+
this.scheduler.stop();
|
|
85
|
+
await this.opts.executor.cancelAll(CANCEL.shutdown);
|
|
86
|
+
await this.settled();
|
|
87
|
+
}
|
|
88
|
+
async halt(reason) {
|
|
89
|
+
this.scheduler.stop();
|
|
90
|
+
await (this.flushAgain ?? this.flushing);
|
|
91
|
+
this.pending = [];
|
|
92
|
+
await this.opts.executor.haltAll(STOPPED_PREFIX + reason);
|
|
93
|
+
await this.settled();
|
|
94
|
+
}
|
|
95
|
+
flushPending() {
|
|
96
|
+
if (this.flushing) {
|
|
97
|
+
this.flushAgain ??= this.flushing.then(() => {
|
|
98
|
+
this.flushAgain = null;
|
|
99
|
+
return this.flushPending();
|
|
100
|
+
});
|
|
101
|
+
return this.flushAgain;
|
|
102
|
+
}
|
|
103
|
+
this.flushing = this.flushQueue().finally(() => (this.flushing = null));
|
|
104
|
+
return this.flushing;
|
|
105
|
+
}
|
|
106
|
+
async flushQueue() {
|
|
107
|
+
const queue = this.pending;
|
|
108
|
+
this.pending = [];
|
|
109
|
+
for (const [i, p] of queue.entries()) {
|
|
110
|
+
const { r, at } = await this.deliver(p.task, p.reporter, p.replies);
|
|
111
|
+
if (r === 'stop') {
|
|
112
|
+
this.pending.push({ ...p, replies: p.replies.slice(at) }, ...queue.slice(i + 1));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (r === 'retry') {
|
|
116
|
+
if (this.opts.now() - p.since < PENDING_MAX_AGE_MS) {
|
|
117
|
+
this.pending.push({ ...p, replies: p.replies.slice(at) });
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
this.setReply(p.task, 'rejected');
|
|
121
|
+
this.finish(p.task, p.replies[at].state, 'reply was not delivered within 24 hours');
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
this.settle(p.task, r, p.replies[at]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async recover(reporter) {
|
|
129
|
+
const rows = this.opts.db.unfinishedTasks();
|
|
130
|
+
for (const row of rows)
|
|
131
|
+
await this.recoverOne(row, reporter);
|
|
132
|
+
for (const row of this.opts.db.repeatTasks())
|
|
133
|
+
await this.recoverRepeat(row);
|
|
134
|
+
return rows.length;
|
|
135
|
+
}
|
|
136
|
+
async recoverRepeat(row) {
|
|
137
|
+
const task = this.repeatEnvelope(row);
|
|
138
|
+
if (!task)
|
|
139
|
+
return;
|
|
140
|
+
const runNo = row.run_no ?? 0;
|
|
141
|
+
if (runNo <= (row.runs ?? 0)) {
|
|
142
|
+
await this.closeIfDone(task, tallyOf(row), row.last_run_at ?? this.opts.now(), null);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.opts.logger.warn('a run of a repeating task was cut off by the previous run', {
|
|
146
|
+
task_id: row.task_id,
|
|
147
|
+
worker: row.worker,
|
|
148
|
+
run_no: runNo
|
|
149
|
+
});
|
|
150
|
+
const message = "the 3minapi CLI on the user's PC stopped during this run — it is unknown whether it finished";
|
|
151
|
+
await this.recordRun(task, runNo, tallyOf(row), {
|
|
152
|
+
ok: false,
|
|
153
|
+
reason: 'host_restarted',
|
|
154
|
+
message,
|
|
155
|
+
payload: JSON.stringify(failedBody('host_restarted', message).error),
|
|
156
|
+
report: false,
|
|
157
|
+
close: true
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async recoverOne(row, reporter) {
|
|
161
|
+
const { logger } = this.opts;
|
|
162
|
+
const pull = Boolean(row.lease_id);
|
|
163
|
+
if (row.repeat) {
|
|
164
|
+
logger.info('dropping an unacknowledged repeating task; the server will deliver it again', {
|
|
165
|
+
task_id: row.task_id,
|
|
166
|
+
worker: row.worker
|
|
167
|
+
});
|
|
168
|
+
return this.opts.db.markFinished(row.task_id, 'superseded', this.opts.now(), 'not acknowledged before the restart; the server will deliver it again');
|
|
169
|
+
}
|
|
170
|
+
const task = {
|
|
171
|
+
task_id: row.task_id,
|
|
172
|
+
worker: row.worker,
|
|
173
|
+
input: null,
|
|
174
|
+
callback_url: row.callback_url ?? null,
|
|
175
|
+
lease_id: pull ? row.lease_id : null,
|
|
176
|
+
delivery_attempt: row.delivery_attempt,
|
|
177
|
+
source: 'pull'
|
|
178
|
+
};
|
|
179
|
+
const id = `${row.worker} ${shortId(row.task_id)}`;
|
|
180
|
+
const attrs = { task_id: row.task_id, worker: row.worker, state: row.state };
|
|
181
|
+
const message = "the 3minapi CLI on the user's PC stopped before this task finished — it is unknown whether it ran";
|
|
182
|
+
if (!pull) {
|
|
183
|
+
logger.warn('closing a pushed task left unfinished by the previous run', attrs);
|
|
184
|
+
return this.finish(task, 'failed', message);
|
|
185
|
+
}
|
|
186
|
+
const stored = this.storedResult(row.task_id);
|
|
187
|
+
if (stored && stored.reply !== 'pending') {
|
|
188
|
+
return this.finish(task, stored.status);
|
|
189
|
+
}
|
|
190
|
+
if (stored) {
|
|
191
|
+
logger.info('resending the reply of a task the previous run finished', attrs);
|
|
192
|
+
if (stored.status === 'completed')
|
|
193
|
+
return this.completed(task, reporter, stored.payload, 0, stored);
|
|
194
|
+
const failed = parseStoredError(stored.payload);
|
|
195
|
+
if (failed) {
|
|
196
|
+
this.event(`${id} failed earlier (${failed.reason}); resent its reply`);
|
|
197
|
+
return this.terminal(task, reporter, this.failedReplies(failed.reason, failed.message));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (row.state === 'running' || row.acked_at != null) {
|
|
201
|
+
logger.warn('closing a task left unfinished by the previous run', attrs);
|
|
202
|
+
this.event(`${id} failed (host_restarted): the previous run stopped before it finished`);
|
|
203
|
+
if (task.callback_url === null)
|
|
204
|
+
return this.finish(task, 'failed', message);
|
|
205
|
+
this.store(task, 'failed', JSON.stringify(failedBody('host_restarted', message).error));
|
|
206
|
+
return this.terminal(task, reporter, this.failedReplies('host_restarted', message));
|
|
207
|
+
}
|
|
208
|
+
logger.info('dropping an unacknowledged task; the server will deliver it again', attrs);
|
|
209
|
+
this.finish(task, 'superseded', 'not acknowledged before the restart; the server will deliver it again');
|
|
210
|
+
}
|
|
211
|
+
storedResult(taskId) {
|
|
212
|
+
if (!this.opts.results)
|
|
213
|
+
return null;
|
|
214
|
+
try {
|
|
215
|
+
return this.opts.results.get(taskId);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
this.opts.logger.warn('could not read the saved result', {
|
|
219
|
+
task_id: taskId,
|
|
220
|
+
message: err.message
|
|
221
|
+
});
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async settled() {
|
|
226
|
+
while (this.inflight.size > 0) {
|
|
227
|
+
await Promise.all([...this.inflight.values()].map((f) => f.done));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async process(task, reporter, ack) {
|
|
231
|
+
const { executor, db, logger } = this.opts;
|
|
232
|
+
if (task.repeat && task.lease_id !== null)
|
|
233
|
+
return this.receiveRepeat(task, reporter, ack);
|
|
234
|
+
const prev = this.inflight.get(task.task_id);
|
|
235
|
+
if (prev) {
|
|
236
|
+
if (prev.leaseId === task.lease_id)
|
|
237
|
+
return;
|
|
238
|
+
executor.cancel(task.task_id, CANCEL.superseded);
|
|
239
|
+
await prev.done;
|
|
240
|
+
}
|
|
241
|
+
if (db.receive(this.received(task), this.opts.now()) === 'duplicate-done') {
|
|
242
|
+
logger.info('task already finished here; not running it again', { task_id: task.task_id });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
let release;
|
|
246
|
+
const entry = { leaseId: task.lease_id, done: new Promise((r) => (release = r)) };
|
|
247
|
+
this.inflight.set(task.task_id, entry);
|
|
248
|
+
try {
|
|
249
|
+
await this.execute(task, reporter, ack);
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
this.releaseSlot(() => {
|
|
253
|
+
if (this.inflight.get(task.task_id) === entry)
|
|
254
|
+
this.inflight.delete(task.task_id);
|
|
255
|
+
});
|
|
256
|
+
release();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async execute(task, reporter, ack) {
|
|
260
|
+
const { executor, db, logger, now } = this.opts;
|
|
261
|
+
const attrs = { task_id: task.task_id, worker: task.worker };
|
|
262
|
+
if (ack) {
|
|
263
|
+
const r = await this.send(task, reporter, { status: 'working' });
|
|
264
|
+
if (r === 'ok')
|
|
265
|
+
db.markAcked(task.task_id, now());
|
|
266
|
+
if (r === 'stop')
|
|
267
|
+
return this.finish(task, 'cancelled', 'stopped before it started');
|
|
268
|
+
if (r === 'drop')
|
|
269
|
+
return this.finish(task, 'superseded', 'the server no longer accepts this task');
|
|
270
|
+
if (r === 'retry' || r === 'too_large') {
|
|
271
|
+
logger.warn('could not confirm the acknowledgement; running the task anyway', attrs);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
let startedAt = null;
|
|
275
|
+
const outcome = await executor.run({ id: task.task_id, worker: task.worker, input: task.input }, {
|
|
276
|
+
onStart: () => {
|
|
277
|
+
startedAt = now();
|
|
278
|
+
db.markRunning(task.task_id, startedAt);
|
|
279
|
+
logger.info('task started', attrs);
|
|
280
|
+
this.event(`${task.worker} ${shortId(task.task_id)} started`);
|
|
281
|
+
},
|
|
282
|
+
onReport: async (message) => {
|
|
283
|
+
const r = await this.send(task, reporter, {
|
|
284
|
+
status: 'working',
|
|
285
|
+
status_message: clip(message, STATUS_MESSAGE_MAX_CHARS)
|
|
286
|
+
});
|
|
287
|
+
if (r === 'drop')
|
|
288
|
+
executor.cancel(task.task_id, CANCEL.superseded);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
const ms = startedAt === null ? 0 : now() - startedAt;
|
|
292
|
+
await this.conclude(task, reporter, outcome, ms);
|
|
293
|
+
}
|
|
294
|
+
async conclude(task, reporter, outcome, ms) {
|
|
295
|
+
const { logger } = this.opts;
|
|
296
|
+
const attrs = { task_id: task.task_id, worker: task.worker, duration_ms: ms };
|
|
297
|
+
if (outcome.status === 'cancelled') {
|
|
298
|
+
if (outcome.reason.startsWith(STOPPED_PREFIX)) {
|
|
299
|
+
logger.warn('task aborted by the stop; not reported', attrs);
|
|
300
|
+
this.event(`${task.worker} ${shortId(task.task_id)} aborted (not reported — calls are stopped)`);
|
|
301
|
+
return this.finish(task, 'cancelled', outcome.reason);
|
|
302
|
+
}
|
|
303
|
+
if (outcome.reason === CANCEL.shutdown) {
|
|
304
|
+
logger.info('task aborted because the CLI is stopping', attrs);
|
|
305
|
+
this.event(`${task.worker} ${shortId(task.task_id)} aborted (CLI stopping)`);
|
|
306
|
+
const message = "the 3minapi CLI on the user's PC stopped before the task finished";
|
|
307
|
+
this.store(task, 'failed', JSON.stringify(failedBody('host_stopped', message).error));
|
|
308
|
+
return this.terminal(task, reporter, [
|
|
309
|
+
{ body: failedBody('host_stopped', message), state: 'failed', reply: 'failed' }
|
|
310
|
+
]);
|
|
311
|
+
}
|
|
312
|
+
const state = outcome.reason === CANCEL.superseded ? 'superseded' : 'cancelled';
|
|
313
|
+
logger.info(`task ${state}`, attrs);
|
|
314
|
+
this.event(`${task.worker} ${shortId(task.task_id)} ${state}`);
|
|
315
|
+
return this.finish(task, state);
|
|
316
|
+
}
|
|
317
|
+
if (outcome.status === 'completed')
|
|
318
|
+
return this.completed(task, reporter, outcome.resultJson, ms);
|
|
319
|
+
const builtin = this.opts.isBuiltin?.(task.worker) ?? false;
|
|
320
|
+
logger.warn('task failed', {
|
|
321
|
+
...attrs,
|
|
322
|
+
reason: outcome.reason,
|
|
323
|
+
...(builtin ? {} : { message: outcome.message })
|
|
324
|
+
});
|
|
325
|
+
this.event(`${task.worker} ${shortId(task.task_id)} failed (${outcome.reason}): ${clip(outcome.message, EVENT_MESSAGE_MAX_CHARS)}`);
|
|
326
|
+
this.store(task, 'failed', JSON.stringify(failedBody(outcome.reason, outcome.detail ?? outcome.message).error));
|
|
327
|
+
return this.terminal(task, reporter, [
|
|
328
|
+
{
|
|
329
|
+
body: failedBody(outcome.reason, outcome.message),
|
|
330
|
+
state: 'failed',
|
|
331
|
+
reply: 'failed',
|
|
332
|
+
error: builtin ? outcome.reason : outcome.message
|
|
333
|
+
}
|
|
334
|
+
]);
|
|
335
|
+
}
|
|
336
|
+
completed(task, reporter, resultJson, ms, resend = null) {
|
|
337
|
+
const { logger } = this.opts;
|
|
338
|
+
const attrs = { task_id: task.task_id, worker: task.worker, duration_ms: ms };
|
|
339
|
+
const id = `${task.worker} ${shortId(task.task_id)}`;
|
|
340
|
+
const done = resend ? 'completed earlier; resent its reply' : `completed in ${ms} ms`;
|
|
341
|
+
const value = JSON.parse(resultJson);
|
|
342
|
+
const bytes = Buffer.byteLength(resultJson);
|
|
343
|
+
let shape = null;
|
|
344
|
+
let saved;
|
|
345
|
+
if (resend) {
|
|
346
|
+
shape =
|
|
347
|
+
resend.shape && resend.shape_hash ? { shape: resend.shape, hash: resend.shape_hash } : null;
|
|
348
|
+
saved = true;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
if (this.opts.results && !this.opts.isBuiltin?.(task.worker))
|
|
352
|
+
shape = computeShape(value);
|
|
353
|
+
saved = this.store(task, 'completed', resultJson, shape);
|
|
354
|
+
}
|
|
355
|
+
const where = saved
|
|
356
|
+
? "it was saved on the user's PC (3minapi worker results show " + task.task_id + ')'
|
|
357
|
+
: "it could not be saved on the user's PC";
|
|
358
|
+
if (isReserved(value)) {
|
|
359
|
+
const message = `the result has a top-level "${RESULT_RESERVED_KEY}" key, which 3minapi reserves — rename it; ${where}`;
|
|
360
|
+
logger.warn('task failed', { ...attrs, reason: 'reserved_result_key' });
|
|
361
|
+
this.event(`${id} failed (reserved_result_key): rename the top-level "${RESULT_RESERVED_KEY}" key`);
|
|
362
|
+
return this.terminal(task, reporter, this.failedReplies('reserved_result_key', message));
|
|
363
|
+
}
|
|
364
|
+
const tooLarge = this.failedReplies('result_too_large', `the result is ${formatCount(bytes)} bytes, over the 100 KiB reply limit; ${where}`);
|
|
365
|
+
const substitute = task.source === 'pull' && saved && shape
|
|
366
|
+
? [
|
|
367
|
+
{
|
|
368
|
+
body: {
|
|
369
|
+
status: 'completed',
|
|
370
|
+
status_message: substituteStatusMessage(bytes),
|
|
371
|
+
result: substituteResult({
|
|
372
|
+
taskId: task.task_id,
|
|
373
|
+
worker: task.worker,
|
|
374
|
+
bytes,
|
|
375
|
+
shape: shape.shape,
|
|
376
|
+
shapeHash: shape.hash,
|
|
377
|
+
reader: this.opts.reader?.() ?? null
|
|
378
|
+
})
|
|
379
|
+
},
|
|
380
|
+
state: 'completed',
|
|
381
|
+
reply: 'substitute'
|
|
382
|
+
}
|
|
383
|
+
]
|
|
384
|
+
: [];
|
|
385
|
+
const max = this.opts.maxResultBytes ?? REPORT_MAX_BYTES - REPORT_ENVELOPE_RESERVE;
|
|
386
|
+
if (bytes <= max) {
|
|
387
|
+
logger.info('task completed', attrs);
|
|
388
|
+
this.event(`${id} ${done}`);
|
|
389
|
+
return this.terminal(task, reporter, [
|
|
390
|
+
{ body: { status: 'completed', result: value }, state: 'completed', reply: 'result' },
|
|
391
|
+
...substitute,
|
|
392
|
+
...tooLarge
|
|
393
|
+
]);
|
|
394
|
+
}
|
|
395
|
+
if (substitute.length > 0) {
|
|
396
|
+
logger.info('task completed; the result is stored locally and a summary was sent', {
|
|
397
|
+
...attrs,
|
|
398
|
+
bytes
|
|
399
|
+
});
|
|
400
|
+
this.event(`${id} ${done} — ${formatCount(bytes)} bytes saved in data.db, sent a summary`);
|
|
401
|
+
return this.terminal(task, reporter, [...substitute, ...tooLarge]);
|
|
402
|
+
}
|
|
403
|
+
logger.warn('task failed', { ...attrs, reason: 'result_too_large', bytes, saved });
|
|
404
|
+
this.event(`${id} failed (result_too_large): ${formatCount(bytes)} bytes${saved ? ' — saved in data.db' : ''}`);
|
|
405
|
+
return this.terminal(task, reporter, tooLarge);
|
|
406
|
+
}
|
|
407
|
+
failedReplies(reason, message) {
|
|
408
|
+
return [
|
|
409
|
+
{ body: failedBody(reason, message), state: 'failed', reply: 'failed', error: message }
|
|
410
|
+
];
|
|
411
|
+
}
|
|
412
|
+
store(task, status, payload, shape = null, runNo) {
|
|
413
|
+
const { results } = this.opts;
|
|
414
|
+
if (!results || this.opts.isBuiltin?.(task.worker))
|
|
415
|
+
return false;
|
|
416
|
+
try {
|
|
417
|
+
results.save({
|
|
418
|
+
taskId: task.task_id,
|
|
419
|
+
worker: task.worker,
|
|
420
|
+
createdAt: this.opts.now(),
|
|
421
|
+
source: task.source,
|
|
422
|
+
status,
|
|
423
|
+
reply: task.source === 'serve' || runNo !== undefined ? 'none' : 'pending',
|
|
424
|
+
payload,
|
|
425
|
+
shape: shape?.shape ?? null,
|
|
426
|
+
shapeHash: shape?.hash ?? null,
|
|
427
|
+
...(runNo !== undefined ? { runNo } : {})
|
|
428
|
+
});
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
const message = err.message;
|
|
433
|
+
this.opts.logger.warn('could not save the result to data.db', {
|
|
434
|
+
task_id: task.task_id,
|
|
435
|
+
message
|
|
436
|
+
});
|
|
437
|
+
this.event(`${task.worker} ${shortId(task.task_id)} result not saved to data.db: ${message}`);
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
setReply(task, reply) {
|
|
442
|
+
if (task.repeat)
|
|
443
|
+
return;
|
|
444
|
+
if (!this.opts.results || task.source === 'serve' || this.opts.isBuiltin?.(task.worker))
|
|
445
|
+
return;
|
|
446
|
+
try {
|
|
447
|
+
this.opts.results.setReply(task.task_id, reply);
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
this.opts.logger.warn('could not update the saved result', {
|
|
451
|
+
task_id: task.task_id,
|
|
452
|
+
message: err.message
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async terminal(task, reporter, replies) {
|
|
457
|
+
const { r, at } = await this.deliver(task, reporter, replies);
|
|
458
|
+
if (r === 'retry') {
|
|
459
|
+
this.opts.logger.warn('reply not delivered; will retry later', {
|
|
460
|
+
task_id: task.task_id
|
|
461
|
+
});
|
|
462
|
+
this.pending.push({
|
|
463
|
+
task,
|
|
464
|
+
reporter: reporter,
|
|
465
|
+
replies: replies.slice(at),
|
|
466
|
+
since: this.opts.now()
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
this.settle(task, r, replies[at]);
|
|
471
|
+
}
|
|
472
|
+
async deliver(task, reporter, replies) {
|
|
473
|
+
let at = 0;
|
|
474
|
+
let r = await this.send(task, reporter, replies[0].body);
|
|
475
|
+
while (r === 'too_large' && at < replies.length - 1) {
|
|
476
|
+
at++;
|
|
477
|
+
r = await this.send(task, reporter, replies[at].body);
|
|
478
|
+
}
|
|
479
|
+
return { r, at };
|
|
480
|
+
}
|
|
481
|
+
settle(task, r, reply) {
|
|
482
|
+
switch (r) {
|
|
483
|
+
case 'ok':
|
|
484
|
+
this.setReply(task, reply.reply);
|
|
485
|
+
return this.finish(task, reply.state, reply.error);
|
|
486
|
+
case 'skip':
|
|
487
|
+
return this.finish(task, reply.state, reply.error);
|
|
488
|
+
case 'drop':
|
|
489
|
+
this.setReply(task, 'rejected');
|
|
490
|
+
return this.finish(task, reply.state, 'the server no longer accepts this task');
|
|
491
|
+
case 'stop':
|
|
492
|
+
this.setReply(task, 'rejected');
|
|
493
|
+
return this.finish(task, reply.state, 'not reported — calls are stopped');
|
|
494
|
+
case 'too_large':
|
|
495
|
+
this.setReply(task, 'rejected');
|
|
496
|
+
return this.finish(task, 'failed', 'the server rejected every reply as too large');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async send(task, reporter, body, invalidAsDrop = false, runNo) {
|
|
500
|
+
if (!reporter || task.callback_url === null)
|
|
501
|
+
return 'skip';
|
|
502
|
+
const withLease = task.lease_id === null ? body : { ...body, lease_id: task.lease_id };
|
|
503
|
+
try {
|
|
504
|
+
await reporter.report(task.callback_url, withLease);
|
|
505
|
+
return 'ok';
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
if (err instanceof StopError) {
|
|
509
|
+
this.opts.onStop(err);
|
|
510
|
+
return 'stop';
|
|
511
|
+
}
|
|
512
|
+
if (err instanceof ApiError) {
|
|
513
|
+
const attrs = {
|
|
514
|
+
task_id: task.task_id,
|
|
515
|
+
...(runNo !== undefined ? { run_no: runNo } : {}),
|
|
516
|
+
status: err.status,
|
|
517
|
+
code: err.code,
|
|
518
|
+
message: err.message
|
|
519
|
+
};
|
|
520
|
+
if (err.code !== null && TASK_CODES.includes(err.code)) {
|
|
521
|
+
this.opts.logger.info('the server dropped the task', attrs);
|
|
522
|
+
return 'drop';
|
|
523
|
+
}
|
|
524
|
+
if (invalidAsDrop && err.code === 'invalid_request') {
|
|
525
|
+
this.opts.logger.error('the server rejected a repeat report (a CLI bug)', attrs);
|
|
526
|
+
return 'drop';
|
|
527
|
+
}
|
|
528
|
+
if (err.code === 'payload_too_large' || err.code === 'unsupported_media_type') {
|
|
529
|
+
this.opts.logger.warn('the server rejected the reply', attrs);
|
|
530
|
+
return 'too_large';
|
|
531
|
+
}
|
|
532
|
+
this.opts.logger.warn('reply failed', attrs);
|
|
533
|
+
return 'retry';
|
|
534
|
+
}
|
|
535
|
+
throw err;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async receiveRepeat(task, reporter, ack) {
|
|
539
|
+
const { db, logger, now } = this.opts;
|
|
540
|
+
const attrs = { task_id: task.task_id, worker: task.worker };
|
|
541
|
+
if (db.receive(this.received(task), now()) === 'duplicate-done') {
|
|
542
|
+
logger.info('task already finished here; not running it again', attrs);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (this.opts.isBuiltin?.(task.worker) || !this.opts.repeatReporter) {
|
|
546
|
+
const message = this.opts.isBuiltin?.(task.worker)
|
|
547
|
+
? 'the built-in results reader does not repeat — create a single task for each read'
|
|
548
|
+
: 'this 3minapi CLI cannot run repeating tasks here';
|
|
549
|
+
logger.warn('repeating task rejected', attrs);
|
|
550
|
+
return this.terminal(task, reporter, this.failedReplies('invalid_input', message));
|
|
551
|
+
}
|
|
552
|
+
this.repeatAcks++;
|
|
553
|
+
let r = 'skip';
|
|
554
|
+
try {
|
|
555
|
+
if (ack)
|
|
556
|
+
r = await this.send(task, reporter, { status: 'working' });
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
this.releaseSlot(() => this.repeatAcks--);
|
|
560
|
+
}
|
|
561
|
+
if (r === 'ok')
|
|
562
|
+
db.markAcked(task.task_id, now());
|
|
563
|
+
if (r === 'stop')
|
|
564
|
+
return this.finish(task, 'cancelled', 'stopped before it started');
|
|
565
|
+
if (r === 'drop')
|
|
566
|
+
return this.finish(task, 'superseded', 'the server no longer accepts this task');
|
|
567
|
+
if (r === 'retry' || r === 'too_large') {
|
|
568
|
+
logger.warn('could not confirm the acknowledgement; running the repeating task anyway', attrs);
|
|
569
|
+
}
|
|
570
|
+
if (this.scheduler.has(task.task_id)) {
|
|
571
|
+
logger.info('repeating task delivered again; continuing with the new lease', attrs);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const every = task.repeat.max_runs === undefined ? '' : `, ${task.repeat.max_runs} runs`;
|
|
575
|
+
logger.info('repeating task accepted', {
|
|
576
|
+
...attrs,
|
|
577
|
+
interval_seconds: task.repeat.interval_seconds,
|
|
578
|
+
max_runs: task.repeat.max_runs ?? null
|
|
579
|
+
});
|
|
580
|
+
this.event(`${task.worker} ${shortId(task.task_id)} repeating every ${task.repeat.interval_seconds}s${every}`);
|
|
581
|
+
const row = db.getTask(task.task_id);
|
|
582
|
+
this.scheduler.add(task.task_id, task.worker, task.repeat, Math.max(row?.run_no ?? 0, row?.runs ?? 0));
|
|
583
|
+
}
|
|
584
|
+
async runRepeat(taskId, runNo) {
|
|
585
|
+
const { executor, db, logger, now } = this.opts;
|
|
586
|
+
const row = db.getTask(taskId);
|
|
587
|
+
const task = row && (row.state === 'received' || row.state === 'running')
|
|
588
|
+
? this.repeatEnvelope(row)
|
|
589
|
+
: null;
|
|
590
|
+
if (!row || !task) {
|
|
591
|
+
this.scheduler.remove(taskId);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
let release;
|
|
595
|
+
const entry = { leaseId: task.lease_id, done: new Promise((r) => (release = r)) };
|
|
596
|
+
this.inflight.set(taskId, entry);
|
|
597
|
+
try {
|
|
598
|
+
let startedAt = null;
|
|
599
|
+
const outcome = await executor.run({ id: taskId, worker: task.worker, input: task.input, run: runNo }, {
|
|
600
|
+
onStart: () => {
|
|
601
|
+
startedAt = now();
|
|
602
|
+
db.startRun(taskId, runNo, startedAt);
|
|
603
|
+
logger.info('repeat run started', {
|
|
604
|
+
task_id: taskId,
|
|
605
|
+
worker: task.worker,
|
|
606
|
+
run_no: runNo
|
|
607
|
+
});
|
|
608
|
+
},
|
|
609
|
+
onReport: async (message) => {
|
|
610
|
+
const r = await this.send(this.withCurrentLease(task), this.opts.repeatReporter, { status: 'working', status_message: clip(message, STATUS_MESSAGE_MAX_CHARS) }, false, runNo);
|
|
611
|
+
if (r === 'drop')
|
|
612
|
+
executor.cancel(taskId, CANCEL.superseded);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
const ms = startedAt === null ? 0 : now() - startedAt;
|
|
616
|
+
const current = db.getTask(taskId) ?? row;
|
|
617
|
+
await this.concludeRun(this.withCurrentLease(task, current), runNo, tallyOf(current), outcome, ms, startedAt !== null);
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
logger.error('repeat run failed', {
|
|
621
|
+
task_id: taskId,
|
|
622
|
+
run_no: runNo,
|
|
623
|
+
message: err?.stack ?? String(err)
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
finally {
|
|
627
|
+
this.releaseSlot(() => {
|
|
628
|
+
if (this.inflight.get(taskId) === entry)
|
|
629
|
+
this.inflight.delete(taskId);
|
|
630
|
+
});
|
|
631
|
+
release();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
releaseSlot(release) {
|
|
635
|
+
const wasFull = this.free === 0;
|
|
636
|
+
release();
|
|
637
|
+
if (wasFull && this.free > 0)
|
|
638
|
+
this.opts.onSlotFree?.();
|
|
639
|
+
}
|
|
640
|
+
async concludeRun(task, runNo, tally, outcome, ms, started) {
|
|
641
|
+
const { logger } = this.opts;
|
|
642
|
+
const attrs = { task_id: task.task_id, worker: task.worker, run_no: runNo, duration_ms: ms };
|
|
643
|
+
if (!this.scheduler.has(task.task_id))
|
|
644
|
+
return;
|
|
645
|
+
if (outcome.status === 'cancelled') {
|
|
646
|
+
if (outcome.reason === CANCEL.server || outcome.reason === CANCEL.superseded) {
|
|
647
|
+
this.scheduler.remove(task.task_id);
|
|
648
|
+
logger.info('repeating task closed', { ...attrs, reason: outcome.reason });
|
|
649
|
+
return this.finish(task, 'cancelled', outcome.reason === CANCEL.superseded
|
|
650
|
+
? 'the server no longer accepts this task'
|
|
651
|
+
: undefined);
|
|
652
|
+
}
|
|
653
|
+
if (!started) {
|
|
654
|
+
logger.info('queued repeat run dropped; the repeating task resumes on the next start', attrs);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const message = outcome.reason.startsWith(STOPPED_PREFIX)
|
|
658
|
+
? `the run was aborted because calls are ${outcome.reason}`
|
|
659
|
+
: "the 3minapi CLI on the user's PC stopped during this run";
|
|
660
|
+
logger.info('repeat run aborted; the repeating task resumes on the next start', attrs);
|
|
661
|
+
return this.recordRun(task, runNo, tally, {
|
|
662
|
+
ok: false,
|
|
663
|
+
reason: 'host_stopped',
|
|
664
|
+
message,
|
|
665
|
+
payload: JSON.stringify(failedBody('host_stopped', message).error),
|
|
666
|
+
report: false,
|
|
667
|
+
close: false
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
if (outcome.status === 'completed') {
|
|
671
|
+
const shape = this.opts.results ? computeShape(JSON.parse(outcome.resultJson)) : null;
|
|
672
|
+
logger.info('repeat run completed', attrs);
|
|
673
|
+
return this.recordRun(task, runNo, tally, {
|
|
674
|
+
ok: true,
|
|
675
|
+
payload: outcome.resultJson,
|
|
676
|
+
shape,
|
|
677
|
+
report: true,
|
|
678
|
+
close: true
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
logger.warn('repeat run failed', {
|
|
682
|
+
...attrs,
|
|
683
|
+
reason: outcome.reason,
|
|
684
|
+
message: outcome.message
|
|
685
|
+
});
|
|
686
|
+
return this.recordRun(task, runNo, tally, {
|
|
687
|
+
ok: false,
|
|
688
|
+
reason: outcome.reason,
|
|
689
|
+
message: outcome.message,
|
|
690
|
+
payload: JSON.stringify(failedBody(outcome.reason, outcome.detail ?? outcome.message).error),
|
|
691
|
+
report: true,
|
|
692
|
+
close: true
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
async recordRun(task, runNo, tally, run) {
|
|
696
|
+
const { db, logger, now } = this.opts;
|
|
697
|
+
this.store(task, run.ok ? 'completed' : 'failed', run.payload, run.ok ? run.shape : null, runNo);
|
|
698
|
+
const next = {
|
|
699
|
+
runs: tally.runs + 1,
|
|
700
|
+
failed: tally.failed + (run.ok ? 0 : 1),
|
|
701
|
+
streak: run.ok ? 0 : tally.streak + 1
|
|
702
|
+
};
|
|
703
|
+
const lastRunAt = now();
|
|
704
|
+
db.recordRun(task.task_id, {
|
|
705
|
+
runs: next.runs,
|
|
706
|
+
failed: next.failed,
|
|
707
|
+
failStreak: next.streak,
|
|
708
|
+
lastRunAt
|
|
709
|
+
});
|
|
710
|
+
const settings = task.repeat;
|
|
711
|
+
const of = settings.max_runs === undefined ? '' : ` of ${settings.max_runs}`;
|
|
712
|
+
this.event(run.ok
|
|
713
|
+
? `${task.worker} ${shortId(task.task_id)} run ${runNo}${of} completed`
|
|
714
|
+
: `${task.worker} ${shortId(task.task_id)} run ${runNo}${of} failed (${run.reason}): ${clip(run.message, EVENT_MESSAGE_MAX_CHARS)}`);
|
|
715
|
+
if (!run.close)
|
|
716
|
+
return;
|
|
717
|
+
if (await this.closeIfDone(task, next, lastRunAt, run.ok ? null : run.message))
|
|
718
|
+
return;
|
|
719
|
+
if (!run.report)
|
|
720
|
+
return;
|
|
721
|
+
const progress = {
|
|
722
|
+
runs: next.runs,
|
|
723
|
+
failed: next.failed,
|
|
724
|
+
last_run_at: new Date(lastRunAt).toISOString()
|
|
725
|
+
};
|
|
726
|
+
const r = await this.send(task, this.opts.repeatReporter ?? null, {
|
|
727
|
+
status: 'working',
|
|
728
|
+
status_message: clip(run.ok ? `Run ${runNo}${of} completed.` : `Run ${runNo}${of} failed: ${run.message}`, STATUS_MESSAGE_MAX_CHARS),
|
|
729
|
+
repeat: progress
|
|
730
|
+
}, true, runNo);
|
|
731
|
+
if (r === 'ok') {
|
|
732
|
+
if (this.opts.db.getTask(task.task_id)?.acked_at == null) {
|
|
733
|
+
this.opts.db.markAcked(task.task_id, this.opts.now());
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
else if (r === 'drop') {
|
|
737
|
+
this.scheduler.remove(task.task_id);
|
|
738
|
+
this.event(`${task.worker} ${shortId(task.task_id)} repeat ended on the server`);
|
|
739
|
+
this.finish(task, 'cancelled', 'the server no longer accepts this task');
|
|
740
|
+
}
|
|
741
|
+
else if (r === 'retry') {
|
|
742
|
+
logger.warn('repeat report not delivered; the next run reports the totals', {
|
|
743
|
+
task_id: task.task_id,
|
|
744
|
+
run_no: runNo
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
async closeIfDone(task, tally, lastRunAt, lastMessage) {
|
|
749
|
+
const { db, logger } = this.opts;
|
|
750
|
+
const settings = task.repeat;
|
|
751
|
+
const progress = {
|
|
752
|
+
runs: tally.runs,
|
|
753
|
+
failed: tally.failed,
|
|
754
|
+
last_run_at: new Date(lastRunAt).toISOString()
|
|
755
|
+
};
|
|
756
|
+
const reporter = this.opts.repeatReporter ?? null;
|
|
757
|
+
if (tally.streak >= REPEAT_FAIL_STREAK_MAX) {
|
|
758
|
+
this.scheduler.remove(task.task_id);
|
|
759
|
+
logger.warn('repeating task failed; too many runs failed in a row', {
|
|
760
|
+
task_id: task.task_id,
|
|
761
|
+
runs: tally.runs,
|
|
762
|
+
failed: tally.failed
|
|
763
|
+
});
|
|
764
|
+
this.event(`${task.worker} ${shortId(task.task_id)} repeat failed: ${REPEAT_FAIL_STREAK_MAX} runs in a row failed`);
|
|
765
|
+
const message = `${REPEAT_FAIL_STREAK_MAX} runs in a row failed` +
|
|
766
|
+
(lastMessage ? `; the last one: ${lastMessage}` : '');
|
|
767
|
+
const body = failedBody('repeat_failed', message);
|
|
768
|
+
body.error.data = { reason: 'repeat_failed', runs: tally.runs, failed: tally.failed };
|
|
769
|
+
await this.terminal(task, reporter, [
|
|
770
|
+
{ body: { ...body, repeat: progress }, state: 'failed', reply: 'failed', error: message }
|
|
771
|
+
]);
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
if (settings.max_runs !== undefined && tally.runs >= settings.max_runs) {
|
|
775
|
+
this.scheduler.remove(task.task_id);
|
|
776
|
+
logger.info('repeating task completed', {
|
|
777
|
+
task_id: task.task_id,
|
|
778
|
+
runs: tally.runs,
|
|
779
|
+
failed: tally.failed
|
|
780
|
+
});
|
|
781
|
+
this.event(`${task.worker} ${shortId(task.task_id)} repeat completed (${tally.runs} runs, ${tally.failed} failed)`);
|
|
782
|
+
const firstRunAt = db.getTask(task.task_id)?.started_at ?? lastRunAt;
|
|
783
|
+
await this.terminal(task, reporter, [
|
|
784
|
+
{
|
|
785
|
+
body: {
|
|
786
|
+
status: 'completed',
|
|
787
|
+
status_message: `All ${tally.runs} runs finished; ${tally.failed} failed.`,
|
|
788
|
+
result: {
|
|
789
|
+
runs: tally.runs,
|
|
790
|
+
failed: tally.failed,
|
|
791
|
+
first_run_at: new Date(firstRunAt).toISOString(),
|
|
792
|
+
last_run_at: progress.last_run_at
|
|
793
|
+
},
|
|
794
|
+
repeat: progress
|
|
795
|
+
},
|
|
796
|
+
state: 'completed',
|
|
797
|
+
reply: 'result'
|
|
798
|
+
}
|
|
799
|
+
]);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
repeatEnvelope(row) {
|
|
805
|
+
const repeat = parseSettings(row.repeat);
|
|
806
|
+
let input = null;
|
|
807
|
+
try {
|
|
808
|
+
input = JSON.parse(row.input_json ?? 'null');
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
}
|
|
812
|
+
if (!repeat || row.input_json == null || !row.lease_id) {
|
|
813
|
+
this.opts.logger.warn('closing a repeating task that cannot be resumed', {
|
|
814
|
+
task_id: row.task_id
|
|
815
|
+
});
|
|
816
|
+
this.scheduler.remove(row.task_id);
|
|
817
|
+
this.opts.db.markFinished(row.task_id, 'failed', this.opts.now(), 'its saved settings could not be read');
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
task_id: row.task_id,
|
|
822
|
+
worker: row.worker,
|
|
823
|
+
input,
|
|
824
|
+
callback_url: row.callback_url ?? null,
|
|
825
|
+
lease_id: row.lease_id,
|
|
826
|
+
delivery_attempt: row.delivery_attempt,
|
|
827
|
+
source: 'pull',
|
|
828
|
+
repeat
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
withCurrentLease(task, row = this.opts.db.getTask(task.task_id)) {
|
|
832
|
+
if (!row?.lease_id || row.lease_id === task.lease_id)
|
|
833
|
+
return task;
|
|
834
|
+
return { ...task, lease_id: row.lease_id, callback_url: row.callback_url ?? task.callback_url };
|
|
835
|
+
}
|
|
836
|
+
workerOf(taskId) {
|
|
837
|
+
return this.opts.db.getTask(taskId)?.worker ?? '';
|
|
838
|
+
}
|
|
839
|
+
finish(task, state, error) {
|
|
840
|
+
this.opts.db.markFinished(task.task_id, state, this.opts.now(), error);
|
|
841
|
+
}
|
|
842
|
+
received(task) {
|
|
843
|
+
return {
|
|
844
|
+
task_id: task.task_id,
|
|
845
|
+
worker: task.worker,
|
|
846
|
+
lease_id: task.lease_id ?? '',
|
|
847
|
+
delivery_attempt: task.delivery_attempt,
|
|
848
|
+
callback_url: task.callback_url,
|
|
849
|
+
...(task.repeat ? { repeat: task.repeat, input: task.input } : {})
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
event(line) {
|
|
853
|
+
this.opts.onEvent?.(stripControl(line));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
export function failedBody(reason, message) {
|
|
857
|
+
return {
|
|
858
|
+
status: 'failed',
|
|
859
|
+
status_message: clip(message, STATUS_MESSAGE_MAX_CHARS),
|
|
860
|
+
error: { code: TASK_ERROR_CODE, message, data: { reason } }
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
function parseStoredError(payload) {
|
|
864
|
+
try {
|
|
865
|
+
const error = JSON.parse(payload);
|
|
866
|
+
const reason = error?.data?.reason;
|
|
867
|
+
if (typeof reason !== 'string' || typeof error.message !== 'string')
|
|
868
|
+
return null;
|
|
869
|
+
return {
|
|
870
|
+
reason: reason,
|
|
871
|
+
message: truncate(error.message, ERROR_MESSAGE_MAX_CHARS)
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
function isReserved(value) {
|
|
879
|
+
return (value !== null &&
|
|
880
|
+
typeof value === 'object' &&
|
|
881
|
+
!Array.isArray(value) &&
|
|
882
|
+
Object.hasOwn(value, RESULT_RESERVED_KEY));
|
|
883
|
+
}
|
|
884
|
+
function clip(message, max) {
|
|
885
|
+
return truncate(oneLine(message), max);
|
|
886
|
+
}
|
|
887
|
+
function shortId(id) {
|
|
888
|
+
return id.length > 13 ? id.slice(-12) : id;
|
|
889
|
+
}
|
|
890
|
+
function tallyOf(row) {
|
|
891
|
+
return { runs: row.runs ?? 0, failed: row.failed ?? 0, streak: row.fail_streak ?? 0 };
|
|
892
|
+
}
|