dsh-dl-server 0.0.0-stage → 0.1.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 +46 -2
- package/README.zh.md +45 -0
- package/cordis.patch.yml +16 -0
- package/lib/bootlog.js +31 -0
- package/lib/domain.js +51 -0
- package/lib/index.js +92 -0
- package/lib/queue.js +591 -0
- package/lib/tools.js +216 -0
- package/lib/transport.js +357 -0
- package/lib/types.js +54 -0
- package/package.json +55 -4
package/lib/queue.js
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { createReadStream } from 'node:fs'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { formatJobId, isTerminal, parseJobId } from './types.js'
|
|
6
|
+
|
|
7
|
+
/** How many times one job may fail before it is parked as `failed`. */
|
|
8
|
+
export const MAX_ATTEMPTS = 3
|
|
9
|
+
|
|
10
|
+
/** Safe default for `download_cleanup`. */
|
|
11
|
+
export const DEFAULT_SCOPE = 'allWithoutFiles'
|
|
12
|
+
|
|
13
|
+
export const CLEANUP_SCOPES = ['records', 'cache', 'allWithoutFiles', 'all']
|
|
14
|
+
|
|
15
|
+
/** Drop undefined properties: the tool registry requires lossless JSON. */
|
|
16
|
+
function compact(object) {
|
|
17
|
+
const out = {}
|
|
18
|
+
for (const [key, value] of Object.entries(object)) {
|
|
19
|
+
if (value !== undefined) out[key] = value
|
|
20
|
+
}
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `J#1042` → `j1042`: the path-safe key used by the per-record storage layout. */
|
|
25
|
+
function storageKeyOf(id) {
|
|
26
|
+
const seq = parseJobId(id)
|
|
27
|
+
return seq === undefined ? id : `j${seq}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class QueueError extends Error {
|
|
31
|
+
/** @param {string} code @param {string} message */
|
|
32
|
+
constructor(code, message) {
|
|
33
|
+
super(message)
|
|
34
|
+
this.code = code
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} Caller
|
|
40
|
+
* @property {string} workspace WorkspaceId
|
|
41
|
+
* @property {string} session SessionId of the parent session
|
|
42
|
+
* @property {string} [origin] subagent name, when the call came from one
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The queue engine. Owns scheduling, state transitions and checkpoints; the
|
|
47
|
+
* transport owns bytes and the domain owns durability.
|
|
48
|
+
*/
|
|
49
|
+
export class Queue {
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} deps
|
|
52
|
+
* @param {any} deps.domain opened DLS domain
|
|
53
|
+
* @param {import('./transport.js').Transport} deps.transport
|
|
54
|
+
* @param {object} deps.config
|
|
55
|
+
* @param {(level: string, message: string) => void} [deps.log]
|
|
56
|
+
* @param {() => number} [deps.now]
|
|
57
|
+
* @param {(sessionId: string) => boolean | Promise<boolean>} [deps.isOwnerAlive]
|
|
58
|
+
*/
|
|
59
|
+
constructor({ domain, transport, config, log = () => {}, now = Date.now, isOwnerAlive }) {
|
|
60
|
+
// Per-record keys must match /^[a-zA-Z0-9_-]+$/ while the public id is
|
|
61
|
+
// `J#1042`; translate at the storage boundary so callers stay on job ids.
|
|
62
|
+
const rawJobs = domain.table('jobs')
|
|
63
|
+
this.jobs = {
|
|
64
|
+
entries: () => rawJobs.entries(),
|
|
65
|
+
get: (id) => rawJobs.get(storageKeyOf(id)),
|
|
66
|
+
put: (id, record) => rawJobs.put(storageKeyOf(id), record),
|
|
67
|
+
// `deleteMany` only exists from 0.2.2; loop over `delete` so the plugin
|
|
68
|
+
// also runs on 0.2.1 profiles.
|
|
69
|
+
deleteMany: async (ids) => {
|
|
70
|
+
let removed = 0
|
|
71
|
+
for (const id of ids) {
|
|
72
|
+
if (await rawJobs.delete(storageKeyOf(id))) removed += 1
|
|
73
|
+
}
|
|
74
|
+
return removed
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
this.domain = domain
|
|
78
|
+
this.transport = transport
|
|
79
|
+
this.config = config
|
|
80
|
+
this.log = log
|
|
81
|
+
this.now = now
|
|
82
|
+
this.isOwnerAlive = isOwnerAlive
|
|
83
|
+
|
|
84
|
+
/** @type {Map<string, { controller: AbortController, startedAt: number }>} */
|
|
85
|
+
this.active = new Map()
|
|
86
|
+
/** @type {Map<string, { done: number, total: number, speed: number, lastDone: number, lastAt: number }>} */
|
|
87
|
+
this.progress = new Map()
|
|
88
|
+
this.closed = false
|
|
89
|
+
this.timer = undefined
|
|
90
|
+
this.checkpointTimer = undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------- lifecycle
|
|
94
|
+
|
|
95
|
+
/** Reconcile persisted state with reality. Call once before serving. */
|
|
96
|
+
async recover() {
|
|
97
|
+
const resumable = []
|
|
98
|
+
|
|
99
|
+
for (const [, record] of this.jobs.entries()) {
|
|
100
|
+
if (record.state === 'running') {
|
|
101
|
+
record.state = 'paused'
|
|
102
|
+
record.pauseReason = 'host_restart'
|
|
103
|
+
record.updatedAt = this.now()
|
|
104
|
+
await this.jobs.put(record.id, record)
|
|
105
|
+
resumable.push(record)
|
|
106
|
+
} else if (record.state === 'paused' && record.pauseReason === 'host_restart') {
|
|
107
|
+
// A previous shutdown may persist the pause before the process exits; such a
|
|
108
|
+
// record still deserves the same resume treatment as a freshly paused one.
|
|
109
|
+
resumable.push(record)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (resumable.length > 0) {
|
|
114
|
+
this.log('info', `${resumable.length} job(s) interrupted by a restart -> paused(host_restart)`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (this.config.autoResumeOnHostRestart) {
|
|
118
|
+
for (const record of resumable) {
|
|
119
|
+
await this.resume(record.id, { workspace: record.ownerWorkspace, session: record.ownerSession }, { internal: true })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await this.#cancelOrphans()
|
|
124
|
+
|
|
125
|
+
this.checkpointTimer = setInterval(() => void this.#checkpointAll(), this.config.checkpointSeconds * 1000)
|
|
126
|
+
this.checkpointTimer.unref?.()
|
|
127
|
+
this.#schedule()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async close() {
|
|
131
|
+
this.closed = true
|
|
132
|
+
clearTimeout(this.timer)
|
|
133
|
+
if (this.checkpointTimer) clearInterval(this.checkpointTimer)
|
|
134
|
+
|
|
135
|
+
const pending = []
|
|
136
|
+
for (const [id, entry] of this.active) {
|
|
137
|
+
entry.controller.abort()
|
|
138
|
+
pending.push(this.#stopAs(id, 'host_restart'))
|
|
139
|
+
}
|
|
140
|
+
await Promise.allSettled(pending)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ------------------------------------------------------------------- reading
|
|
144
|
+
|
|
145
|
+
/** Every session sees every job. */
|
|
146
|
+
list() {
|
|
147
|
+
return [...this.jobs.entries()].map(([, record]) => this.#view(record))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @param {string} id */
|
|
151
|
+
get(id) {
|
|
152
|
+
const record = this.jobs.get(id)
|
|
153
|
+
return record ? this.#view(record) : undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Block until a job reaches a terminal state or the timeout expires.
|
|
158
|
+
* @param {string} id
|
|
159
|
+
* @param {number} timeoutMs
|
|
160
|
+
*/
|
|
161
|
+
async wait(id, timeoutMs = 60_000) {
|
|
162
|
+
const deadline = this.now() + timeoutMs
|
|
163
|
+
for (;;) {
|
|
164
|
+
const record = this.jobs.get(id)
|
|
165
|
+
if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
|
|
166
|
+
if (isTerminal(record.state)) return this.#view(record)
|
|
167
|
+
if (this.now() >= deadline) return this.#view(record)
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ------------------------------------------------------------------- writing
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Add a job. Returns immediately; the scheduler picks it up.
|
|
176
|
+
* @param {{ urls: string[], destPath: string, expectSha256?: string }} spec
|
|
177
|
+
* @param {Caller} caller
|
|
178
|
+
*/
|
|
179
|
+
async enqueue(spec, caller) {
|
|
180
|
+
if (!Array.isArray(spec.urls) || spec.urls.length === 0) {
|
|
181
|
+
throw new QueueError('url-invalid', 'at least one URL is required')
|
|
182
|
+
}
|
|
183
|
+
for (const url of spec.urls) {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = new URL(url)
|
|
186
|
+
if (!/^https?:$/.test(parsed.protocol)) throw new Error('scheme')
|
|
187
|
+
} catch {
|
|
188
|
+
throw new QueueError('url-invalid', `not an http(s) URL: ${url}`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await this.#assertDiskSpace(spec.destPath)
|
|
193
|
+
|
|
194
|
+
const seq = await this.#nextSeq()
|
|
195
|
+
const id = formatJobId(seq)
|
|
196
|
+
const stamp = this.now()
|
|
197
|
+
|
|
198
|
+
/** @type {import('./types.js').JobRecord} */
|
|
199
|
+
const record = {
|
|
200
|
+
id,
|
|
201
|
+
seq,
|
|
202
|
+
ownerWorkspace: caller.workspace,
|
|
203
|
+
ownerSession: caller.session,
|
|
204
|
+
origin: caller.origin,
|
|
205
|
+
urls: [...spec.urls],
|
|
206
|
+
destPath: spec.destPath,
|
|
207
|
+
state: 'queued',
|
|
208
|
+
createdAt: stamp,
|
|
209
|
+
updatedAt: stamp,
|
|
210
|
+
bytesTotal: -1,
|
|
211
|
+
bytesCheckpoint: 0,
|
|
212
|
+
attempts: 0,
|
|
213
|
+
expectSha256: spec.expectSha256,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await this.jobs.put(id, record)
|
|
217
|
+
this.log('info', `${id} enqueued by ${caller.workspace}#${caller.session.slice(0, 8)}`)
|
|
218
|
+
this.#schedule()
|
|
219
|
+
return this.#view(record)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {string} id @param {Caller} caller */
|
|
223
|
+
async pause(id, caller) {
|
|
224
|
+
const record = this.#writable(id, caller)
|
|
225
|
+
if (record.state !== 'running' && record.state !== 'queued') {
|
|
226
|
+
throw new QueueError('bad-state', `${id} is ${record.state}, cannot pause`)
|
|
227
|
+
}
|
|
228
|
+
if (record.state === 'queued') {
|
|
229
|
+
record.state = 'paused'
|
|
230
|
+
record.pauseReason = 'user'
|
|
231
|
+
record.updatedAt = this.now()
|
|
232
|
+
await this.jobs.put(id, record)
|
|
233
|
+
return this.#view(record)
|
|
234
|
+
}
|
|
235
|
+
this.active.get(id)?.controller.abort()
|
|
236
|
+
await this.#stopAs(id, 'user')
|
|
237
|
+
return this.#view(this.jobs.get(id))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** @param {string} id @param {Caller} caller @param {{ internal?: boolean }} [options] */
|
|
241
|
+
async resume(id, caller, options = {}) {
|
|
242
|
+
const record = options.internal ? this.#must(id) : this.#writable(id, caller)
|
|
243
|
+
if (record.state !== 'paused' && record.state !== 'queued') {
|
|
244
|
+
throw new QueueError('bad-state', `${id} is ${record.state}, cannot resume`)
|
|
245
|
+
}
|
|
246
|
+
record.state = 'queued'
|
|
247
|
+
delete record.pauseReason
|
|
248
|
+
record.updatedAt = this.now()
|
|
249
|
+
await this.jobs.put(id, record)
|
|
250
|
+
this.#schedule()
|
|
251
|
+
return this.#view(record)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** @param {string} id @param {Caller} caller */
|
|
255
|
+
async cancel(id, caller) {
|
|
256
|
+
const record = this.#must(id)
|
|
257
|
+
if (!this.#canWrite(record, caller)) {
|
|
258
|
+
throw new QueueError('not-owner', `${id} belongs to another session`)
|
|
259
|
+
}
|
|
260
|
+
if (isTerminal(record.state)) {
|
|
261
|
+
throw new QueueError('bad-state', `${id} is already ${record.state}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const controller = this.active.get(id)?.controller
|
|
265
|
+
controller?.abort()
|
|
266
|
+
|
|
267
|
+
record.state = 'canceled'
|
|
268
|
+
record.updatedAt = this.now()
|
|
269
|
+
await this.jobs.put(id, record)
|
|
270
|
+
this.progress.delete(id)
|
|
271
|
+
|
|
272
|
+
await this.#dropPartialFiles(record)
|
|
273
|
+
this.log('info', `${id} canceled`)
|
|
274
|
+
return this.#view(record)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @param {string} scope
|
|
279
|
+
* @param {Caller} caller
|
|
280
|
+
*/
|
|
281
|
+
async cleanup(scope, caller) {
|
|
282
|
+
if (!CLEANUP_SCOPES.includes(scope)) {
|
|
283
|
+
throw new QueueError('bad-state', `unknown scope: ${scope}`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const mine = [...this.jobs.entries()]
|
|
287
|
+
.map(([, record]) => record)
|
|
288
|
+
.filter((record) => record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session)
|
|
289
|
+
|
|
290
|
+
let removedRecords = 0
|
|
291
|
+
let removedCacheFiles = 0
|
|
292
|
+
let removedFiles = 0
|
|
293
|
+
let freedBytes = 0
|
|
294
|
+
|
|
295
|
+
const touch = async (file) => {
|
|
296
|
+
try {
|
|
297
|
+
const info = await fs.stat(file)
|
|
298
|
+
await fs.rm(file, { force: true })
|
|
299
|
+
return info.size
|
|
300
|
+
} catch {
|
|
301
|
+
return undefined
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (scope === 'records' || scope === 'allWithoutFiles' || scope === 'all') {
|
|
306
|
+
const removable = mine.filter((record) => isTerminal(record.state)).map((record) => record.id)
|
|
307
|
+
if (removable.length > 0) {
|
|
308
|
+
removedRecords = await this.jobs.deleteMany(removable)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (scope === 'cache' || scope === 'allWithoutFiles' || scope === 'all') {
|
|
313
|
+
for (const record of mine) {
|
|
314
|
+
for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
|
|
315
|
+
const freed = await touch(file)
|
|
316
|
+
if (freed !== undefined) removedCacheFiles += 1
|
|
317
|
+
freedBytes += freed ?? 0
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (scope === 'all') {
|
|
323
|
+
for (const record of mine) {
|
|
324
|
+
if (!isTerminal(record.state)) continue
|
|
325
|
+
const freed = await touch(record.destPath)
|
|
326
|
+
if (freed !== undefined) removedFiles += 1
|
|
327
|
+
freedBytes += freed ?? 0
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { scope, removedRecords, removedCacheFiles, removedFiles, freedBytes }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ------------------------------------------------------------------ internals
|
|
335
|
+
|
|
336
|
+
/** @param {string} id */
|
|
337
|
+
#must(id) {
|
|
338
|
+
const record = this.jobs.get(id)
|
|
339
|
+
if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
|
|
340
|
+
return record
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** @param {string} id @param {Caller} caller */
|
|
344
|
+
#writable(id, caller) {
|
|
345
|
+
const record = this.#must(id)
|
|
346
|
+
if (!this.#canWrite(record, caller)) {
|
|
347
|
+
throw new QueueError('not-owner', `${id} belongs to another session`)
|
|
348
|
+
}
|
|
349
|
+
return record
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** @param {import('./types.js').JobRecord} record @param {Caller} caller */
|
|
353
|
+
#canWrite(record, caller) {
|
|
354
|
+
if (isTerminal(record.state)) return true
|
|
355
|
+
return record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async #nextSeq() {
|
|
359
|
+
const state = this.domain.global.get()
|
|
360
|
+
const seq = state.nextSeq
|
|
361
|
+
await this.domain.global.set({ nextSeq: seq + 1 })
|
|
362
|
+
return seq
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** @param {import('./types.js').JobRecord} record */
|
|
366
|
+
#view(record) {
|
|
367
|
+
const live = this.progress.get(record.id)
|
|
368
|
+
const total = live?.total && live.total > 0 ? live.total : record.bytesTotal
|
|
369
|
+
const done = isTerminal(record.state)
|
|
370
|
+
? (record.finalBytes ?? record.bytesCheckpoint)
|
|
371
|
+
: (live?.done ?? record.bytesCheckpoint)
|
|
372
|
+
|
|
373
|
+
return compact({
|
|
374
|
+
id: record.id,
|
|
375
|
+
state: record.state,
|
|
376
|
+
pauseReason: record.pauseReason,
|
|
377
|
+
owner: { workspace: record.ownerWorkspace, session: record.ownerSession },
|
|
378
|
+
origin: record.origin,
|
|
379
|
+
urls: record.urls,
|
|
380
|
+
dest: record.destPath,
|
|
381
|
+
bytesTotal: total,
|
|
382
|
+
bytesDone: done,
|
|
383
|
+
speedBps: live?.speed ?? 0,
|
|
384
|
+
etaSeconds: live && live.speed > 0 && total > 0 ? Math.max(0, Math.round((total - done) / live.speed)) : undefined,
|
|
385
|
+
attempts: record.attempts,
|
|
386
|
+
lastError: record.lastError,
|
|
387
|
+
createdAt: record.createdAt,
|
|
388
|
+
updatedAt: record.updatedAt,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#schedule() {
|
|
393
|
+
if (this.closed || this.timer) return
|
|
394
|
+
this.timer = setTimeout(() => {
|
|
395
|
+
this.timer = undefined
|
|
396
|
+
void this.#pump()
|
|
397
|
+
}, 0)
|
|
398
|
+
this.timer.unref?.()
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async #pump() {
|
|
402
|
+
if (this.closed) return
|
|
403
|
+
const queued = [...this.jobs.entries()]
|
|
404
|
+
.map(([, record]) => record)
|
|
405
|
+
.filter((record) => record.state === 'queued')
|
|
406
|
+
.sort((a, b) => a.seq - b.seq)
|
|
407
|
+
|
|
408
|
+
for (const record of queued) {
|
|
409
|
+
if (this.active.size >= this.config.maxConcurrentJobs) break
|
|
410
|
+
if (this.active.has(record.id)) continue
|
|
411
|
+
void this.#run(record)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @param {import('./types.js').JobRecord} record */
|
|
416
|
+
async #run(record) {
|
|
417
|
+
const id = record.id
|
|
418
|
+
const controller = new AbortController()
|
|
419
|
+
this.active.set(id, { controller, startedAt: this.now() })
|
|
420
|
+
|
|
421
|
+
record.state = 'running'
|
|
422
|
+
record.updatedAt = this.now()
|
|
423
|
+
await this.jobs.put(id, record)
|
|
424
|
+
|
|
425
|
+
const stats = { done: record.bytesCheckpoint, total: record.bytesTotal, speed: 0, lastDone: record.bytesCheckpoint, lastAt: this.now() }
|
|
426
|
+
this.progress.set(id, stats)
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const result = await this.transport.transfer({
|
|
430
|
+
urls: record.urls,
|
|
431
|
+
destPath: record.destPath,
|
|
432
|
+
connections: this.config.connectionsPerJob,
|
|
433
|
+
speedLimitKbps: this.config.speedLimitKbps,
|
|
434
|
+
bytesDone: record.bytesCheckpoint,
|
|
435
|
+
signal: controller.signal,
|
|
436
|
+
onProgress: (done, total) => {
|
|
437
|
+
const stamp = this.now()
|
|
438
|
+
const elapsed = (stamp - stats.lastAt) / 1000
|
|
439
|
+
if (elapsed >= 0.4) {
|
|
440
|
+
stats.speed = Math.max(0, (done - stats.lastDone) / elapsed)
|
|
441
|
+
stats.lastDone = done
|
|
442
|
+
stats.lastAt = stamp
|
|
443
|
+
}
|
|
444
|
+
stats.done = done
|
|
445
|
+
if (total > 0) stats.total = total
|
|
446
|
+
},
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
if (record.expectSha256) {
|
|
450
|
+
const actual = await sha256File(record.destPath)
|
|
451
|
+
if (actual.toLowerCase() !== record.expectSha256.toLowerCase()) {
|
|
452
|
+
throw new Error(`sha256 mismatch: expected ${record.expectSha256}, got ${actual}`)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await this.#finish(id, 'completed', {
|
|
457
|
+
bytesTotal: result.total > 0 ? result.total : stats.total,
|
|
458
|
+
bytesCheckpoint: result.bytes,
|
|
459
|
+
finalBytes: result.bytes,
|
|
460
|
+
})
|
|
461
|
+
} catch (error) {
|
|
462
|
+
const aborted = controller.signal.aborted
|
|
463
|
+
if (aborted) {
|
|
464
|
+
// pause / cancel / close already recorded the target state
|
|
465
|
+
if (!isTerminal(record.state)) {
|
|
466
|
+
await this.#stopAs(id, 'host_restart')
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
await this.#fail(id, error)
|
|
470
|
+
}
|
|
471
|
+
} finally {
|
|
472
|
+
this.active.delete(id)
|
|
473
|
+
this.progress.delete(id)
|
|
474
|
+
this.#schedule()
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** @param {string} id @param {Error} error */
|
|
479
|
+
async #fail(id, error) {
|
|
480
|
+
const record = this.jobs.get(id)
|
|
481
|
+
if (!record || isTerminal(record.state)) return
|
|
482
|
+
|
|
483
|
+
record.attempts += 1
|
|
484
|
+
record.lastError = error?.message ?? String(error)
|
|
485
|
+
record.updatedAt = this.now()
|
|
486
|
+
|
|
487
|
+
if (record.attempts < MAX_ATTEMPTS) {
|
|
488
|
+
record.state = 'queued'
|
|
489
|
+
record.pauseReason = undefined
|
|
490
|
+
this.log('warn', `${id} failed (attempt ${record.attempts}/${MAX_ATTEMPTS}), retrying: ${record.lastError}`)
|
|
491
|
+
} else {
|
|
492
|
+
record.state = 'failed'
|
|
493
|
+
this.log('warn', `${id} failed permanently: ${record.lastError}`)
|
|
494
|
+
}
|
|
495
|
+
await this.jobs.put(id, record)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** @param {string} id @param {'user'|'session_closed'|'host_restart'} reason */
|
|
499
|
+
async #stopAs(id, reason) {
|
|
500
|
+
const record = this.jobs.get(id)
|
|
501
|
+
if (!record || isTerminal(record.state)) return
|
|
502
|
+
const stats = this.progress.get(id)
|
|
503
|
+
record.state = 'paused'
|
|
504
|
+
record.pauseReason = reason
|
|
505
|
+
if (stats) record.bytesCheckpoint = stats.done
|
|
506
|
+
record.updatedAt = this.now()
|
|
507
|
+
await this.jobs.put(id, record)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* @param {string} id
|
|
512
|
+
* @param {'completed'} state
|
|
513
|
+
* @param {Partial<import('./types.js').JobRecord>} patch
|
|
514
|
+
*/
|
|
515
|
+
async #finish(id, state, patch) {
|
|
516
|
+
const record = this.jobs.get(id)
|
|
517
|
+
if (!record) return
|
|
518
|
+
Object.assign(record, patch, { state, updatedAt: this.now() })
|
|
519
|
+
delete record.pauseReason
|
|
520
|
+
await this.jobs.put(id, record)
|
|
521
|
+
this.log('info', `${id} ${state} (${record.finalBytes ?? record.bytesCheckpoint} bytes)`)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async #checkpointAll() {
|
|
525
|
+
for (const [id] of this.active) {
|
|
526
|
+
const stats = this.progress.get(id)
|
|
527
|
+
const record = this.jobs.get(id)
|
|
528
|
+
if (!stats || !record || record.state !== 'running') continue
|
|
529
|
+
if (stats.done <= record.bytesCheckpoint) continue
|
|
530
|
+
record.bytesCheckpoint = stats.done
|
|
531
|
+
record.bytesTotal = stats.total > 0 ? stats.total : record.bytesTotal
|
|
532
|
+
record.updatedAt = this.now()
|
|
533
|
+
await this.jobs.put(id, record).catch(() => {})
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** @param {import('./types.js').JobRecord} record */
|
|
538
|
+
async #dropPartialFiles(record) {
|
|
539
|
+
for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
|
|
540
|
+
await fs.rm(file, { force: true }).catch(() => {})
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async #cancelOrphans() {
|
|
545
|
+
if (!this.isOwnerAlive) {
|
|
546
|
+
this.log('info', 'orphan scan skipped (no session probe wired in yet)')
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
for (const [, record] of [...this.jobs.entries()]) {
|
|
550
|
+
if (isTerminal(record.state)) continue
|
|
551
|
+
if (await this.isOwnerAlive(record.ownerSession)) continue
|
|
552
|
+
record.state = 'canceled'
|
|
553
|
+
record.lastError = 'owner session no longer exists'
|
|
554
|
+
record.updatedAt = this.now()
|
|
555
|
+
await this.jobs.put(record.id, record)
|
|
556
|
+
await this.#dropPartialFiles(record)
|
|
557
|
+
this.log('info', `${record.id} canceled: owner session is gone`)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Cheap guard against enqueueing onto a full disk. The real file size is not
|
|
563
|
+
* known yet, so this only catches the obvious case.
|
|
564
|
+
* @param {string} destPath
|
|
565
|
+
*/
|
|
566
|
+
async #assertDiskSpace(destPath) {
|
|
567
|
+
const minimumFree = 512 * 1024 * 1024
|
|
568
|
+
try {
|
|
569
|
+
const stats = await fs.statfs(path.dirname(destPath))
|
|
570
|
+
const free = Number(stats.bavail) * Number(stats.bsize)
|
|
571
|
+
if (Number.isFinite(free) && free < minimumFree) {
|
|
572
|
+
throw new QueueError('disk-full', `less than 512 MB free for ${path.dirname(destPath)}`)
|
|
573
|
+
}
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (error instanceof QueueError) throw error
|
|
576
|
+
/* statfs is unavailable on some platforms; do not block the enqueue */
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** @param {string} file */
|
|
582
|
+
async function sha256File(file) {
|
|
583
|
+
const hash = createHash('sha256')
|
|
584
|
+
await new Promise((resolve, reject) => {
|
|
585
|
+
createReadStream(file)
|
|
586
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
587
|
+
.on('end', resolve)
|
|
588
|
+
.on('error', reject)
|
|
589
|
+
})
|
|
590
|
+
return hash.digest('hex')
|
|
591
|
+
}
|