dsh-dl-server 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/README.zh.md +15 -5
- package/lib/queue.js +893 -892
- package/lib/transport.js +44 -1
- package/package.json +1 -1
package/lib/queue.js
CHANGED
|
@@ -1,893 +1,894 @@
|
|
|
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'
|
|
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
5
|
import { expandMirrors, hostOf } from './mirrors.js'
|
|
6
|
-
import { headerNames, normalizeHeaders } from './headers.js'
|
|
7
|
-
import { formatJobId, isTerminal, parseJobId } from './types.js'
|
|
8
|
-
|
|
9
|
-
/** How many times one job may fail before it is parked as `failed`. */
|
|
10
|
-
export const MAX_ATTEMPTS = 3
|
|
11
|
-
|
|
12
|
-
/** Safe default for `download_cleanup`. */
|
|
13
|
-
export const DEFAULT_SCOPE = 'allWithoutFiles'
|
|
14
|
-
|
|
15
|
-
export const CLEANUP_SCOPES = ['records', 'cache', 'allWithoutFiles', 'all']
|
|
16
|
-
|
|
17
|
-
/** Free space always kept in reserve, on top of what queued jobs will consume. */
|
|
18
|
-
const MIN_FREE_BYTES = 512 * 1024 * 1024
|
|
19
|
-
|
|
20
|
-
/** Assumed size of a queued job whose total is not known yet. */
|
|
21
|
-
const UNKNOWN_SIZE_RESERVE = 256 * 1024 * 1024
|
|
22
|
-
|
|
23
|
-
/** A mirror host that fails this many times in a row is skipped for COOLDOWN_MS. */
|
|
24
|
-
const MIRROR_FAIL_THRESHOLD = 2
|
|
25
|
-
const MIRROR_COOLDOWN_MS = 10 * 60 * 1000
|
|
26
|
-
|
|
27
|
-
/** Drop undefined properties: the tool registry requires lossless JSON. */
|
|
28
|
-
function compact(object) {
|
|
29
|
-
const out = {}
|
|
30
|
-
for (const [key, value] of Object.entries(object)) {
|
|
31
|
-
if (value !== undefined) out[key] = value
|
|
32
|
-
}
|
|
33
|
-
return out
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** `J#1042` → `j1042`: the path-safe key used by the per-record storage layout. */
|
|
37
|
-
function storageKeyOf(id) {
|
|
38
|
-
const seq = parseJobId(id)
|
|
39
|
-
return seq === undefined ? id : `j${seq}`
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Comparison key for a destination path. Windows folds case and accepts both
|
|
44
|
-
* separators, so `D:\A\b.bin` and `d:/a/B.bin` are the same file there.
|
|
45
|
-
* @param {string} destPath
|
|
46
|
-
*/
|
|
47
|
-
function destKeyOf(destPath) {
|
|
48
|
-
const resolved = path.resolve(destPath).replace(/[\\/]+$/, '')
|
|
49
|
-
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** @param {string} file */
|
|
53
|
-
async function fileExists(file) {
|
|
54
|
-
try {
|
|
55
|
-
const info = await fs.stat(file)
|
|
56
|
-
return info.isFile() && info.size > 0
|
|
57
|
-
} catch {
|
|
58
|
-
return false
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export class QueueError extends Error {
|
|
63
|
-
/** @param {string} code @param {string} message */
|
|
64
|
-
constructor(code, message) {
|
|
65
|
-
super(message)
|
|
66
|
-
this.code = code
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* @typedef {object} Caller
|
|
72
|
-
* @property {string} workspace WorkspaceId
|
|
73
|
-
* @property {string} session SessionId of the parent session
|
|
74
|
-
* @property {string} [origin] subagent name, when the call came from one
|
|
75
|
-
*/
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The queue engine. Owns scheduling, state transitions and checkpoints; the
|
|
79
|
-
* transport owns bytes and the domain owns durability.
|
|
80
|
-
*/
|
|
81
|
-
export class Queue {
|
|
82
|
-
/**
|
|
83
|
-
* @param {object} deps
|
|
84
|
-
* @param {any} deps.domain opened DLS domain
|
|
85
|
-
* @param {import('./transport.js').Transport} deps.transport
|
|
86
|
-
* @param {object} deps.config
|
|
87
|
-
* @param {(level: string, message: string) => void} [deps.log]
|
|
88
|
-
* @param {() => number} [deps.now]
|
|
89
|
-
* @param {(sessionId: string) => boolean | Promise<boolean>} [deps.isOwnerAlive]
|
|
90
|
-
*/
|
|
91
|
-
constructor({ domain, transport, config, log = () => {}, now = Date.now, isOwnerAlive }) {
|
|
92
|
-
// Per-record keys must match /^[a-zA-Z0-9_-]+$/ while the public id is
|
|
93
|
-
// `J#1042`; translate at the storage boundary so callers stay on job ids.
|
|
94
|
-
const rawJobs = domain.table('jobs')
|
|
95
|
-
this.jobs = {
|
|
96
|
-
entries: () => rawJobs.entries(),
|
|
97
|
-
get: (id) => rawJobs.get(storageKeyOf(id)),
|
|
98
|
-
put: (id, record) => rawJobs.put(storageKeyOf(id), record),
|
|
99
|
-
// `deleteMany` only exists from 0.2.2; loop over `delete` so the plugin
|
|
100
|
-
// also runs on 0.2.1 profiles.
|
|
101
|
-
deleteMany: async (ids) => {
|
|
102
|
-
let removed = 0
|
|
103
|
-
for (const id of ids) {
|
|
104
|
-
if (await rawJobs.delete(storageKeyOf(id))) removed += 1
|
|
105
|
-
}
|
|
106
|
-
return removed
|
|
107
|
-
},
|
|
108
|
-
}
|
|
109
|
-
this.domain = domain
|
|
110
|
-
this.transport = transport
|
|
111
|
-
this.config = config
|
|
112
|
-
this.log = log
|
|
113
|
-
this.now = now
|
|
114
|
-
this.isOwnerAlive = isOwnerAlive
|
|
115
|
-
|
|
116
|
-
/** @type {Map<string, { controller: AbortController, startedAt: number }>} */
|
|
117
|
-
this.active = new Map()
|
|
118
|
-
/** @type {Map<string, { done: number, total: number, speed: number, lastDone: number, lastAt: number }>} */
|
|
119
|
-
this.progress = new Map()
|
|
120
|
-
this.closed = false
|
|
121
|
-
this.timer = undefined
|
|
122
|
-
this.checkpointTimer = undefined
|
|
123
|
-
/** Session served last, so the next pass starts with a different one. */
|
|
124
|
-
this.lastServed = undefined
|
|
125
|
-
|
|
126
|
-
/** @type {{ errors: number, lastError?: string, lastAt?: number }} */
|
|
127
|
-
this.storage = { errors: 0 }
|
|
128
|
-
/** @type {Map<string, { fails: number, cooldownUntil: number }>} */
|
|
129
|
-
this.mirrorHealth = new Map()
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ---------------------------------------------------------------- lifecycle
|
|
133
|
-
|
|
134
|
-
/** Reconcile persisted state with reality. Call once before serving. */
|
|
135
|
-
async recover() {
|
|
136
|
-
const resumable = []
|
|
137
|
-
|
|
138
|
-
for (const [, record] of this.jobs.entries()) {
|
|
139
|
-
if (record.state === 'running') {
|
|
140
|
-
record.state = 'paused'
|
|
141
|
-
record.pauseReason = 'host_restart'
|
|
142
|
-
record.updatedAt = this.now()
|
|
143
|
-
await this.jobs.put(record.id, record)
|
|
144
|
-
resumable.push(record)
|
|
145
|
-
} else if (record.state === 'paused' && record.pauseReason === 'host_restart') {
|
|
146
|
-
// A previous shutdown may persist the pause before the process exits; such a
|
|
147
|
-
// record still deserves the same resume treatment as a freshly paused one.
|
|
148
|
-
resumable.push(record)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (resumable.length > 0) {
|
|
153
|
-
this.log('info', `${resumable.length} job(s) interrupted by a restart -> paused(host_restart)`)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (this.config.autoResumeOnHostRestart) {
|
|
157
|
-
for (const record of resumable) {
|
|
158
|
-
await this.resume(record.id, { workspace: record.ownerWorkspace, session: record.ownerSession }, { internal: true })
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
await this.#cancelOrphans()
|
|
163
|
-
|
|
164
|
-
this.checkpointTimer = setInterval(() => void this.#checkpointAll(), this.config.checkpointSeconds * 1000)
|
|
165
|
-
this.checkpointTimer.unref?.()
|
|
166
|
-
this.#schedule()
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async close() {
|
|
170
|
-
this.closed = true
|
|
171
|
-
clearTimeout(this.timer)
|
|
172
|
-
if (this.checkpointTimer) clearInterval(this.checkpointTimer)
|
|
173
|
-
|
|
174
|
-
const pending = []
|
|
175
|
-
for (const [id, entry] of this.active) {
|
|
176
|
-
entry.controller.abort()
|
|
177
|
-
pending.push(this.#stopAs(id, 'host_restart'))
|
|
178
|
-
}
|
|
179
|
-
await Promise.allSettled(pending)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ------------------------------------------------------------------- reading
|
|
183
|
-
|
|
184
|
-
/** Every session sees every job. */
|
|
185
|
-
list() {
|
|
186
|
-
return [...this.jobs.entries()].map(([, record]) => this.#view(record))
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** @param {string} id */
|
|
190
|
-
get(id) {
|
|
191
|
-
const record = this.jobs.get(id)
|
|
192
|
-
return record ? this.#view(record) : undefined
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Block until a job reaches a terminal state or the timeout expires.
|
|
197
|
-
* @param {string} id
|
|
198
|
-
* @param {number} timeoutMs
|
|
199
|
-
*/
|
|
200
|
-
async wait(id, timeoutMs = 60_000) {
|
|
201
|
-
const deadline = this.now() + timeoutMs
|
|
202
|
-
for (;;) {
|
|
203
|
-
const record = this.jobs.get(id)
|
|
204
|
-
if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
|
|
205
|
-
if (isTerminal(record.state)) return this.#view(record)
|
|
206
|
-
if (this.now() >= deadline) return this.#view(record)
|
|
207
|
-
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// ------------------------------------------------------------------- writing
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Add a job. Returns immediately; the scheduler picks it up.
|
|
215
|
-
* @param {{ urls: string[], destPath: string, expectSha256?: string, expectedBytes?: number, headers?: Record<string, string> }} spec
|
|
216
|
-
* @param {Caller} caller
|
|
217
|
-
*/
|
|
218
|
-
async enqueue(spec, caller) {
|
|
219
|
-
if (!Array.isArray(spec.urls) || spec.urls.length === 0) {
|
|
220
|
-
throw new QueueError('url-invalid', 'at least one URL is required')
|
|
221
|
-
}
|
|
222
|
-
for (const url of spec.urls) {
|
|
223
|
-
try {
|
|
224
|
-
const parsed = new URL(url)
|
|
225
|
-
if (!/^https?:$/.test(parsed.protocol)) throw new Error('scheme')
|
|
226
|
-
} catch {
|
|
227
|
-
throw new QueueError('url-invalid', `not an http(s) URL: ${url}`)
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Bad headers must fail here, at enqueue time, not halfway through a
|
|
232
|
-
// transfer with three mirrors already tried.
|
|
233
|
-
let requestHeaders
|
|
234
|
-
try {
|
|
235
|
-
requestHeaders = normalizeHeaders(spec.headers)
|
|
236
|
-
} catch (error) {
|
|
237
|
-
throw new QueueError(error.code ?? 'header-invalid', error.message)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const expanded = expandMirrors(spec.urls)
|
|
241
|
-
const destPath = path.resolve(spec.destPath)
|
|
242
|
-
const key = destKeyOf(destPath)
|
|
243
|
-
|
|
244
|
-
// Two jobs writing the same path is a conflict no matter which URL each one
|
|
245
|
-
// uses: aria2 would fight over the same .aria2 control file. Dedupe on the
|
|
246
|
-
// destination, not on the URL — one file can legitimately have many URLs.
|
|
247
|
-
const sameDest = [...this.jobs.entries()].map(([, record]) => record).filter((record) => destKeyOf(record.destPath) === key)
|
|
248
|
-
|
|
249
|
-
const active = sameDest.find((record) => !isTerminal(record.state))
|
|
250
|
-
if (active) {
|
|
251
|
-
if (active.urls[0] === expanded.urls[0]) {
|
|
252
|
-
this.log('info', `${active.id} reused by ${caller.session.slice(0, 8)} (same destination and source)`)
|
|
253
|
-
return { ...this.#view(active), deduplicated: true }
|
|
254
|
-
}
|
|
255
|
-
throw new QueueError(
|
|
256
|
-
'dest-in-use',
|
|
257
|
-
`${destPath} is already being downloaded as ${active.id} from ${active.urls[0]}; cancel it first or choose another destination`,
|
|
258
|
-
)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// A finished job for the same file is what the caller wants far more often
|
|
262
|
-
// than a second download. Only reuse it while the file is still on disk —
|
|
263
|
-
// a deleted file must be fetched again.
|
|
264
|
-
const finished = sameDest.find((record) => record.state === 'completed' && record.urls[0] === expanded.urls[0])
|
|
265
|
-
if (finished && (await fileExists(finished.destPath))) {
|
|
266
|
-
this.log('info', `${finished.id} reused by ${caller.session.slice(0, 8)} (already downloaded)`)
|
|
267
|
-
return { ...this.#view(finished), deduplicated: true }
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
await this.#assertDestWritable(destPath)
|
|
271
|
-
await this.#assertDiskSpace(destPath, spec.expectedBytes)
|
|
272
|
-
|
|
273
|
-
const seq = await this.#nextSeq()
|
|
274
|
-
const id = formatJobId(seq)
|
|
275
|
-
const stamp = this.now()
|
|
276
|
-
|
|
277
|
-
/** @type {import('./types.js').JobRecord} */
|
|
278
|
-
const record = {
|
|
279
|
-
id,
|
|
280
|
-
seq,
|
|
281
|
-
ownerWorkspace: caller.workspace,
|
|
282
|
-
ownerSession: caller.session,
|
|
283
|
-
origin: caller.origin,
|
|
284
|
-
urls: expanded.urls,
|
|
285
|
-
mirrors: expanded.applied.length > 0 ? expanded.applied : undefined,
|
|
286
|
-
destPath,
|
|
287
|
-
// Persisted so a resume after a DSH restart still sends them: a signed
|
|
288
|
-
// URL is worthless without its Referer. Already validated above.
|
|
289
|
-
headers: Object.keys(requestHeaders).length > 0 ? requestHeaders : undefined,
|
|
290
|
-
state: 'queued',
|
|
291
|
-
createdAt: stamp,
|
|
292
|
-
updatedAt: stamp,
|
|
293
|
-
bytesTotal: -1,
|
|
294
|
-
bytesCheckpoint: 0,
|
|
295
|
-
attempts: 0,
|
|
296
|
-
expectSha256: spec.expectSha256,
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
await this.jobs.put(id, record)
|
|
300
|
-
this.log('info', `${id} enqueued by ${caller.workspace}#${caller.session.slice(0, 8)}`)
|
|
301
|
-
this.#schedule()
|
|
302
|
-
return this.#view(record)
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** @param {string} id @param {Caller} caller */
|
|
306
|
-
async pause(id, caller) {
|
|
307
|
-
const record = this.#writable(id, caller)
|
|
308
|
-
if (record.state !== 'running' && record.state !== 'queued') {
|
|
309
|
-
throw new QueueError('bad-state', `${id} is ${record.state}, cannot pause`)
|
|
310
|
-
}
|
|
311
|
-
if (record.state === 'queued') {
|
|
312
|
-
record.state = 'paused'
|
|
313
|
-
record.pauseReason = 'user'
|
|
314
|
-
record.updatedAt = this.now()
|
|
315
|
-
await this.jobs.put(id, record)
|
|
316
|
-
return this.#view(record)
|
|
317
|
-
}
|
|
318
|
-
this.active.get(id)?.controller.abort()
|
|
319
|
-
await this.#stopAs(id, 'user')
|
|
320
|
-
return this.#view(this.jobs.get(id))
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** @param {string} id @param {Caller} caller @param {{ internal?: boolean }} [options] */
|
|
324
|
-
async resume(id, caller, options = {}) {
|
|
325
|
-
const record = options.internal ? this.#must(id) : this.#writable(id, caller)
|
|
326
|
-
if (record.state !== 'paused' && record.state !== 'queued') {
|
|
327
|
-
throw new QueueError('bad-state', `${id} is ${record.state}, cannot resume`)
|
|
328
|
-
}
|
|
329
|
-
record.state = 'queued'
|
|
330
|
-
delete record.pauseReason
|
|
331
|
-
record.updatedAt = this.now()
|
|
332
|
-
await this.jobs.put(id, record)
|
|
333
|
-
this.#schedule()
|
|
334
|
-
return this.#view(record)
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** @param {string} id @param {Caller} caller */
|
|
338
|
-
async cancel(id, caller) {
|
|
339
|
-
const record = this.#must(id)
|
|
340
|
-
if (!this.#canWrite(record, caller)) {
|
|
341
|
-
throw new QueueError('not-owner', `${id} belongs to another session`)
|
|
342
|
-
}
|
|
343
|
-
if (isTerminal(record.state)) {
|
|
344
|
-
throw new QueueError('bad-state', `${id} is already ${record.state}`)
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const controller = this.active.get(id)?.controller
|
|
348
|
-
controller?.abort()
|
|
349
|
-
|
|
350
|
-
record.state = 'canceled'
|
|
351
|
-
record.updatedAt = this.now()
|
|
352
|
-
await this.jobs.put(id, record)
|
|
353
|
-
this.progress.delete(id)
|
|
354
|
-
|
|
355
|
-
await this.#dropPartialFiles(record)
|
|
356
|
-
this.log('info', `${id} canceled`)
|
|
357
|
-
return this.#view(record)
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* @param {string} scope
|
|
362
|
-
* @param {Caller} caller
|
|
363
|
-
*/
|
|
364
|
-
async cleanup(scope, caller) {
|
|
365
|
-
if (!CLEANUP_SCOPES.includes(scope)) {
|
|
366
|
-
throw new QueueError('bad-state', `unknown scope: ${scope}`)
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const mine = [...this.jobs.entries()]
|
|
370
|
-
.map(([, record]) => record)
|
|
371
|
-
.filter((record) => record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session)
|
|
372
|
-
|
|
373
|
-
let removedRecords = 0
|
|
374
|
-
let removedCacheFiles = 0
|
|
375
|
-
let removedFiles = 0
|
|
376
|
-
let freedBytes = 0
|
|
377
|
-
|
|
378
|
-
const touch = async (file) => {
|
|
379
|
-
try {
|
|
380
|
-
const info = await fs.stat(file)
|
|
381
|
-
await fs.rm(file, { force: true })
|
|
382
|
-
return info.size
|
|
383
|
-
} catch {
|
|
384
|
-
return undefined
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
if (scope === 'records' || scope === 'allWithoutFiles' || scope === 'all') {
|
|
389
|
-
const removable = mine.filter((record) => isTerminal(record.state)).map((record) => record.id)
|
|
390
|
-
if (removable.length > 0) {
|
|
391
|
-
removedRecords = await this.jobs.deleteMany(removable)
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
if (scope === 'cache' || scope === 'allWithoutFiles' || scope === 'all') {
|
|
396
|
-
for (const record of mine) {
|
|
397
|
-
for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
|
|
398
|
-
const freed = await touch(file)
|
|
399
|
-
if (freed !== undefined) removedCacheFiles += 1
|
|
400
|
-
freedBytes += freed ?? 0
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (scope === 'all') {
|
|
406
|
-
for (const record of mine) {
|
|
407
|
-
if (!isTerminal(record.state)) continue
|
|
408
|
-
const freed = await touch(record.destPath)
|
|
409
|
-
if (freed !== undefined) removedFiles += 1
|
|
410
|
-
freedBytes += freed ?? 0
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return { scope, removedRecords, removedCacheFiles, removedFiles, freedBytes }
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// ------------------------------------------------------------------ internals
|
|
418
|
-
|
|
419
|
-
/** @param {string} id */
|
|
420
|
-
#must(id) {
|
|
421
|
-
const record = this.jobs.get(id)
|
|
422
|
-
if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
|
|
423
|
-
return record
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/** @param {string} id @param {Caller} caller */
|
|
427
|
-
#writable(id, caller) {
|
|
428
|
-
const record = this.#must(id)
|
|
429
|
-
if (!this.#canWrite(record, caller)) {
|
|
430
|
-
throw new QueueError('not-owner', `${id} belongs to another session`)
|
|
431
|
-
}
|
|
432
|
-
return record
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** @param {import('./types.js').JobRecord} record @param {Caller} caller */
|
|
436
|
-
#canWrite(record, caller) {
|
|
437
|
-
if (isTerminal(record.state)) return true
|
|
438
|
-
return record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
async #nextSeq() {
|
|
442
|
-
const state = this.domain.global.get()
|
|
443
|
-
const seq = state.nextSeq
|
|
444
|
-
await this.domain.global.set({ nextSeq: seq + 1 })
|
|
445
|
-
return seq
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/** @param {import('./types.js').JobRecord} record */
|
|
449
|
-
#view(record) {
|
|
450
|
-
const live = this.progress.get(record.id)
|
|
451
|
-
const total = live?.total && live.total > 0 ? live.total : record.bytesTotal
|
|
452
|
-
const done = isTerminal(record.state)
|
|
453
|
-
? (record.finalBytes ?? record.bytesCheckpoint)
|
|
454
|
-
: (live?.done ?? record.bytesCheckpoint)
|
|
455
|
-
|
|
456
|
-
return compact({
|
|
457
|
-
id: record.id,
|
|
458
|
-
state: record.state,
|
|
459
|
-
pauseReason: record.pauseReason,
|
|
460
|
-
owner: { workspace: record.ownerWorkspace, session: record.ownerSession },
|
|
461
|
-
origin: record.origin,
|
|
462
|
-
urls: record.urls,
|
|
463
|
-
mirrors: this.#mirrorView(record),
|
|
464
|
-
// Names only. A header value may be a session cookie or an API token, and
|
|
465
|
-
// this view is what every session and every model gets to read.
|
|
466
|
-
requestHeaders: headerNames(record.headers),
|
|
467
|
-
dest: record.destPath,
|
|
468
|
-
bytesTotal: total,
|
|
469
|
-
bytesDone: done,
|
|
470
|
-
speedBps: live?.speed ?? 0,
|
|
471
|
-
etaSeconds: live && live.speed > 0 && total > 0 ? Math.max(0, Math.round((total - done) / live.speed)) : undefined,
|
|
472
|
-
attempts: record.attempts,
|
|
473
|
-
lastError: record.lastError,
|
|
474
|
-
createdAt: record.createdAt,
|
|
475
|
-
updatedAt: record.updatedAt,
|
|
476
|
-
})
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** Storage-write and mirror health, surfaced through `download_status`. */
|
|
480
|
-
health() {
|
|
481
|
-
const mirrors = this.mirrorStatus()
|
|
482
|
-
return compact({
|
|
483
|
-
storage: this.storage.errors > 0 ? { ...this.storage } : { errors: 0 },
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
this.
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
.
|
|
517
|
-
.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
record.
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
stats.
|
|
604
|
-
stats.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
this.
|
|
638
|
-
this
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
record.
|
|
649
|
-
record.
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
record.
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
record.
|
|
673
|
-
|
|
674
|
-
record.
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
* @param {
|
|
681
|
-
* @param {
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
this
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
if (stats
|
|
698
|
-
record.bytesCheckpoint
|
|
699
|
-
record.
|
|
700
|
-
record.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
//
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
this.storage.
|
|
716
|
-
this.storage.
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
if (
|
|
753
|
-
record.
|
|
754
|
-
record.
|
|
755
|
-
record.
|
|
756
|
-
|
|
757
|
-
await this.#
|
|
758
|
-
this
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
//
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
dir
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
* @param {
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
.
|
|
804
|
-
.
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
`
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
entry.fails
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
this.
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
const
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
.on('
|
|
890
|
-
.on('
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
6
|
+
import { headerNames, normalizeHeaders } from './headers.js'
|
|
7
|
+
import { formatJobId, isTerminal, parseJobId } from './types.js'
|
|
8
|
+
|
|
9
|
+
/** How many times one job may fail before it is parked as `failed`. */
|
|
10
|
+
export const MAX_ATTEMPTS = 3
|
|
11
|
+
|
|
12
|
+
/** Safe default for `download_cleanup`. */
|
|
13
|
+
export const DEFAULT_SCOPE = 'allWithoutFiles'
|
|
14
|
+
|
|
15
|
+
export const CLEANUP_SCOPES = ['records', 'cache', 'allWithoutFiles', 'all']
|
|
16
|
+
|
|
17
|
+
/** Free space always kept in reserve, on top of what queued jobs will consume. */
|
|
18
|
+
const MIN_FREE_BYTES = 512 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
/** Assumed size of a queued job whose total is not known yet. */
|
|
21
|
+
const UNKNOWN_SIZE_RESERVE = 256 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
/** A mirror host that fails this many times in a row is skipped for COOLDOWN_MS. */
|
|
24
|
+
const MIRROR_FAIL_THRESHOLD = 2
|
|
25
|
+
const MIRROR_COOLDOWN_MS = 10 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
/** Drop undefined properties: the tool registry requires lossless JSON. */
|
|
28
|
+
function compact(object) {
|
|
29
|
+
const out = {}
|
|
30
|
+
for (const [key, value] of Object.entries(object)) {
|
|
31
|
+
if (value !== undefined) out[key] = value
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `J#1042` → `j1042`: the path-safe key used by the per-record storage layout. */
|
|
37
|
+
function storageKeyOf(id) {
|
|
38
|
+
const seq = parseJobId(id)
|
|
39
|
+
return seq === undefined ? id : `j${seq}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Comparison key for a destination path. Windows folds case and accepts both
|
|
44
|
+
* separators, so `D:\A\b.bin` and `d:/a/B.bin` are the same file there.
|
|
45
|
+
* @param {string} destPath
|
|
46
|
+
*/
|
|
47
|
+
function destKeyOf(destPath) {
|
|
48
|
+
const resolved = path.resolve(destPath).replace(/[\\/]+$/, '')
|
|
49
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {string} file */
|
|
53
|
+
async function fileExists(file) {
|
|
54
|
+
try {
|
|
55
|
+
const info = await fs.stat(file)
|
|
56
|
+
return info.isFile() && info.size > 0
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class QueueError extends Error {
|
|
63
|
+
/** @param {string} code @param {string} message */
|
|
64
|
+
constructor(code, message) {
|
|
65
|
+
super(message)
|
|
66
|
+
this.code = code
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} Caller
|
|
72
|
+
* @property {string} workspace WorkspaceId
|
|
73
|
+
* @property {string} session SessionId of the parent session
|
|
74
|
+
* @property {string} [origin] subagent name, when the call came from one
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The queue engine. Owns scheduling, state transitions and checkpoints; the
|
|
79
|
+
* transport owns bytes and the domain owns durability.
|
|
80
|
+
*/
|
|
81
|
+
export class Queue {
|
|
82
|
+
/**
|
|
83
|
+
* @param {object} deps
|
|
84
|
+
* @param {any} deps.domain opened DLS domain
|
|
85
|
+
* @param {import('./transport.js').Transport} deps.transport
|
|
86
|
+
* @param {object} deps.config
|
|
87
|
+
* @param {(level: string, message: string) => void} [deps.log]
|
|
88
|
+
* @param {() => number} [deps.now]
|
|
89
|
+
* @param {(sessionId: string) => boolean | Promise<boolean>} [deps.isOwnerAlive]
|
|
90
|
+
*/
|
|
91
|
+
constructor({ domain, transport, config, log = () => {}, now = Date.now, isOwnerAlive }) {
|
|
92
|
+
// Per-record keys must match /^[a-zA-Z0-9_-]+$/ while the public id is
|
|
93
|
+
// `J#1042`; translate at the storage boundary so callers stay on job ids.
|
|
94
|
+
const rawJobs = domain.table('jobs')
|
|
95
|
+
this.jobs = {
|
|
96
|
+
entries: () => rawJobs.entries(),
|
|
97
|
+
get: (id) => rawJobs.get(storageKeyOf(id)),
|
|
98
|
+
put: (id, record) => rawJobs.put(storageKeyOf(id), record),
|
|
99
|
+
// `deleteMany` only exists from 0.2.2; loop over `delete` so the plugin
|
|
100
|
+
// also runs on 0.2.1 profiles.
|
|
101
|
+
deleteMany: async (ids) => {
|
|
102
|
+
let removed = 0
|
|
103
|
+
for (const id of ids) {
|
|
104
|
+
if (await rawJobs.delete(storageKeyOf(id))) removed += 1
|
|
105
|
+
}
|
|
106
|
+
return removed
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
this.domain = domain
|
|
110
|
+
this.transport = transport
|
|
111
|
+
this.config = config
|
|
112
|
+
this.log = log
|
|
113
|
+
this.now = now
|
|
114
|
+
this.isOwnerAlive = isOwnerAlive
|
|
115
|
+
|
|
116
|
+
/** @type {Map<string, { controller: AbortController, startedAt: number }>} */
|
|
117
|
+
this.active = new Map()
|
|
118
|
+
/** @type {Map<string, { done: number, total: number, speed: number, lastDone: number, lastAt: number }>} */
|
|
119
|
+
this.progress = new Map()
|
|
120
|
+
this.closed = false
|
|
121
|
+
this.timer = undefined
|
|
122
|
+
this.checkpointTimer = undefined
|
|
123
|
+
/** Session served last, so the next pass starts with a different one. */
|
|
124
|
+
this.lastServed = undefined
|
|
125
|
+
|
|
126
|
+
/** @type {{ errors: number, lastError?: string, lastAt?: number }} */
|
|
127
|
+
this.storage = { errors: 0 }
|
|
128
|
+
/** @type {Map<string, { fails: number, cooldownUntil: number }>} */
|
|
129
|
+
this.mirrorHealth = new Map()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------- lifecycle
|
|
133
|
+
|
|
134
|
+
/** Reconcile persisted state with reality. Call once before serving. */
|
|
135
|
+
async recover() {
|
|
136
|
+
const resumable = []
|
|
137
|
+
|
|
138
|
+
for (const [, record] of this.jobs.entries()) {
|
|
139
|
+
if (record.state === 'running') {
|
|
140
|
+
record.state = 'paused'
|
|
141
|
+
record.pauseReason = 'host_restart'
|
|
142
|
+
record.updatedAt = this.now()
|
|
143
|
+
await this.jobs.put(record.id, record)
|
|
144
|
+
resumable.push(record)
|
|
145
|
+
} else if (record.state === 'paused' && record.pauseReason === 'host_restart') {
|
|
146
|
+
// A previous shutdown may persist the pause before the process exits; such a
|
|
147
|
+
// record still deserves the same resume treatment as a freshly paused one.
|
|
148
|
+
resumable.push(record)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (resumable.length > 0) {
|
|
153
|
+
this.log('info', `${resumable.length} job(s) interrupted by a restart -> paused(host_restart)`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (this.config.autoResumeOnHostRestart) {
|
|
157
|
+
for (const record of resumable) {
|
|
158
|
+
await this.resume(record.id, { workspace: record.ownerWorkspace, session: record.ownerSession }, { internal: true })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await this.#cancelOrphans()
|
|
163
|
+
|
|
164
|
+
this.checkpointTimer = setInterval(() => void this.#checkpointAll(), this.config.checkpointSeconds * 1000)
|
|
165
|
+
this.checkpointTimer.unref?.()
|
|
166
|
+
this.#schedule()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async close() {
|
|
170
|
+
this.closed = true
|
|
171
|
+
clearTimeout(this.timer)
|
|
172
|
+
if (this.checkpointTimer) clearInterval(this.checkpointTimer)
|
|
173
|
+
|
|
174
|
+
const pending = []
|
|
175
|
+
for (const [id, entry] of this.active) {
|
|
176
|
+
entry.controller.abort()
|
|
177
|
+
pending.push(this.#stopAs(id, 'host_restart'))
|
|
178
|
+
}
|
|
179
|
+
await Promise.allSettled(pending)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ------------------------------------------------------------------- reading
|
|
183
|
+
|
|
184
|
+
/** Every session sees every job. */
|
|
185
|
+
list() {
|
|
186
|
+
return [...this.jobs.entries()].map(([, record]) => this.#view(record))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @param {string} id */
|
|
190
|
+
get(id) {
|
|
191
|
+
const record = this.jobs.get(id)
|
|
192
|
+
return record ? this.#view(record) : undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Block until a job reaches a terminal state or the timeout expires.
|
|
197
|
+
* @param {string} id
|
|
198
|
+
* @param {number} timeoutMs
|
|
199
|
+
*/
|
|
200
|
+
async wait(id, timeoutMs = 60_000) {
|
|
201
|
+
const deadline = this.now() + timeoutMs
|
|
202
|
+
for (;;) {
|
|
203
|
+
const record = this.jobs.get(id)
|
|
204
|
+
if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
|
|
205
|
+
if (isTerminal(record.state)) return this.#view(record)
|
|
206
|
+
if (this.now() >= deadline) return this.#view(record)
|
|
207
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ------------------------------------------------------------------- writing
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Add a job. Returns immediately; the scheduler picks it up.
|
|
215
|
+
* @param {{ urls: string[], destPath: string, expectSha256?: string, expectedBytes?: number, headers?: Record<string, string> }} spec
|
|
216
|
+
* @param {Caller} caller
|
|
217
|
+
*/
|
|
218
|
+
async enqueue(spec, caller) {
|
|
219
|
+
if (!Array.isArray(spec.urls) || spec.urls.length === 0) {
|
|
220
|
+
throw new QueueError('url-invalid', 'at least one URL is required')
|
|
221
|
+
}
|
|
222
|
+
for (const url of spec.urls) {
|
|
223
|
+
try {
|
|
224
|
+
const parsed = new URL(url)
|
|
225
|
+
if (!/^https?:$/.test(parsed.protocol)) throw new Error('scheme')
|
|
226
|
+
} catch {
|
|
227
|
+
throw new QueueError('url-invalid', `not an http(s) URL: ${url}`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Bad headers must fail here, at enqueue time, not halfway through a
|
|
232
|
+
// transfer with three mirrors already tried.
|
|
233
|
+
let requestHeaders
|
|
234
|
+
try {
|
|
235
|
+
requestHeaders = normalizeHeaders(spec.headers)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new QueueError(error.code ?? 'header-invalid', error.message)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const expanded = expandMirrors(spec.urls)
|
|
241
|
+
const destPath = path.resolve(spec.destPath)
|
|
242
|
+
const key = destKeyOf(destPath)
|
|
243
|
+
|
|
244
|
+
// Two jobs writing the same path is a conflict no matter which URL each one
|
|
245
|
+
// uses: aria2 would fight over the same .aria2 control file. Dedupe on the
|
|
246
|
+
// destination, not on the URL — one file can legitimately have many URLs.
|
|
247
|
+
const sameDest = [...this.jobs.entries()].map(([, record]) => record).filter((record) => destKeyOf(record.destPath) === key)
|
|
248
|
+
|
|
249
|
+
const active = sameDest.find((record) => !isTerminal(record.state))
|
|
250
|
+
if (active) {
|
|
251
|
+
if (active.urls[0] === expanded.urls[0]) {
|
|
252
|
+
this.log('info', `${active.id} reused by ${caller.session.slice(0, 8)} (same destination and source)`)
|
|
253
|
+
return { ...this.#view(active), deduplicated: true }
|
|
254
|
+
}
|
|
255
|
+
throw new QueueError(
|
|
256
|
+
'dest-in-use',
|
|
257
|
+
`${destPath} is already being downloaded as ${active.id} from ${active.urls[0]}; cancel it first or choose another destination`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A finished job for the same file is what the caller wants far more often
|
|
262
|
+
// than a second download. Only reuse it while the file is still on disk —
|
|
263
|
+
// a deleted file must be fetched again.
|
|
264
|
+
const finished = sameDest.find((record) => record.state === 'completed' && record.urls[0] === expanded.urls[0])
|
|
265
|
+
if (finished && (await fileExists(finished.destPath))) {
|
|
266
|
+
this.log('info', `${finished.id} reused by ${caller.session.slice(0, 8)} (already downloaded)`)
|
|
267
|
+
return { ...this.#view(finished), deduplicated: true }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await this.#assertDestWritable(destPath)
|
|
271
|
+
await this.#assertDiskSpace(destPath, spec.expectedBytes)
|
|
272
|
+
|
|
273
|
+
const seq = await this.#nextSeq()
|
|
274
|
+
const id = formatJobId(seq)
|
|
275
|
+
const stamp = this.now()
|
|
276
|
+
|
|
277
|
+
/** @type {import('./types.js').JobRecord} */
|
|
278
|
+
const record = {
|
|
279
|
+
id,
|
|
280
|
+
seq,
|
|
281
|
+
ownerWorkspace: caller.workspace,
|
|
282
|
+
ownerSession: caller.session,
|
|
283
|
+
origin: caller.origin,
|
|
284
|
+
urls: expanded.urls,
|
|
285
|
+
mirrors: expanded.applied.length > 0 ? expanded.applied : undefined,
|
|
286
|
+
destPath,
|
|
287
|
+
// Persisted so a resume after a DSH restart still sends them: a signed
|
|
288
|
+
// URL is worthless without its Referer. Already validated above.
|
|
289
|
+
headers: Object.keys(requestHeaders).length > 0 ? requestHeaders : undefined,
|
|
290
|
+
state: 'queued',
|
|
291
|
+
createdAt: stamp,
|
|
292
|
+
updatedAt: stamp,
|
|
293
|
+
bytesTotal: -1,
|
|
294
|
+
bytesCheckpoint: 0,
|
|
295
|
+
attempts: 0,
|
|
296
|
+
expectSha256: spec.expectSha256,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await this.jobs.put(id, record)
|
|
300
|
+
this.log('info', `${id} enqueued by ${caller.workspace}#${caller.session.slice(0, 8)}`)
|
|
301
|
+
this.#schedule()
|
|
302
|
+
return this.#view(record)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** @param {string} id @param {Caller} caller */
|
|
306
|
+
async pause(id, caller) {
|
|
307
|
+
const record = this.#writable(id, caller)
|
|
308
|
+
if (record.state !== 'running' && record.state !== 'queued') {
|
|
309
|
+
throw new QueueError('bad-state', `${id} is ${record.state}, cannot pause`)
|
|
310
|
+
}
|
|
311
|
+
if (record.state === 'queued') {
|
|
312
|
+
record.state = 'paused'
|
|
313
|
+
record.pauseReason = 'user'
|
|
314
|
+
record.updatedAt = this.now()
|
|
315
|
+
await this.jobs.put(id, record)
|
|
316
|
+
return this.#view(record)
|
|
317
|
+
}
|
|
318
|
+
this.active.get(id)?.controller.abort()
|
|
319
|
+
await this.#stopAs(id, 'user')
|
|
320
|
+
return this.#view(this.jobs.get(id))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @param {string} id @param {Caller} caller @param {{ internal?: boolean }} [options] */
|
|
324
|
+
async resume(id, caller, options = {}) {
|
|
325
|
+
const record = options.internal ? this.#must(id) : this.#writable(id, caller)
|
|
326
|
+
if (record.state !== 'paused' && record.state !== 'queued') {
|
|
327
|
+
throw new QueueError('bad-state', `${id} is ${record.state}, cannot resume`)
|
|
328
|
+
}
|
|
329
|
+
record.state = 'queued'
|
|
330
|
+
delete record.pauseReason
|
|
331
|
+
record.updatedAt = this.now()
|
|
332
|
+
await this.jobs.put(id, record)
|
|
333
|
+
this.#schedule()
|
|
334
|
+
return this.#view(record)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** @param {string} id @param {Caller} caller */
|
|
338
|
+
async cancel(id, caller) {
|
|
339
|
+
const record = this.#must(id)
|
|
340
|
+
if (!this.#canWrite(record, caller)) {
|
|
341
|
+
throw new QueueError('not-owner', `${id} belongs to another session`)
|
|
342
|
+
}
|
|
343
|
+
if (isTerminal(record.state)) {
|
|
344
|
+
throw new QueueError('bad-state', `${id} is already ${record.state}`)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const controller = this.active.get(id)?.controller
|
|
348
|
+
controller?.abort()
|
|
349
|
+
|
|
350
|
+
record.state = 'canceled'
|
|
351
|
+
record.updatedAt = this.now()
|
|
352
|
+
await this.jobs.put(id, record)
|
|
353
|
+
this.progress.delete(id)
|
|
354
|
+
|
|
355
|
+
await this.#dropPartialFiles(record)
|
|
356
|
+
this.log('info', `${id} canceled`)
|
|
357
|
+
return this.#view(record)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {string} scope
|
|
362
|
+
* @param {Caller} caller
|
|
363
|
+
*/
|
|
364
|
+
async cleanup(scope, caller) {
|
|
365
|
+
if (!CLEANUP_SCOPES.includes(scope)) {
|
|
366
|
+
throw new QueueError('bad-state', `unknown scope: ${scope}`)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const mine = [...this.jobs.entries()]
|
|
370
|
+
.map(([, record]) => record)
|
|
371
|
+
.filter((record) => record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session)
|
|
372
|
+
|
|
373
|
+
let removedRecords = 0
|
|
374
|
+
let removedCacheFiles = 0
|
|
375
|
+
let removedFiles = 0
|
|
376
|
+
let freedBytes = 0
|
|
377
|
+
|
|
378
|
+
const touch = async (file) => {
|
|
379
|
+
try {
|
|
380
|
+
const info = await fs.stat(file)
|
|
381
|
+
await fs.rm(file, { force: true })
|
|
382
|
+
return info.size
|
|
383
|
+
} catch {
|
|
384
|
+
return undefined
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (scope === 'records' || scope === 'allWithoutFiles' || scope === 'all') {
|
|
389
|
+
const removable = mine.filter((record) => isTerminal(record.state)).map((record) => record.id)
|
|
390
|
+
if (removable.length > 0) {
|
|
391
|
+
removedRecords = await this.jobs.deleteMany(removable)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (scope === 'cache' || scope === 'allWithoutFiles' || scope === 'all') {
|
|
396
|
+
for (const record of mine) {
|
|
397
|
+
for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
|
|
398
|
+
const freed = await touch(file)
|
|
399
|
+
if (freed !== undefined) removedCacheFiles += 1
|
|
400
|
+
freedBytes += freed ?? 0
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (scope === 'all') {
|
|
406
|
+
for (const record of mine) {
|
|
407
|
+
if (!isTerminal(record.state)) continue
|
|
408
|
+
const freed = await touch(record.destPath)
|
|
409
|
+
if (freed !== undefined) removedFiles += 1
|
|
410
|
+
freedBytes += freed ?? 0
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return { scope, removedRecords, removedCacheFiles, removedFiles, freedBytes }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ------------------------------------------------------------------ internals
|
|
418
|
+
|
|
419
|
+
/** @param {string} id */
|
|
420
|
+
#must(id) {
|
|
421
|
+
const record = this.jobs.get(id)
|
|
422
|
+
if (!record) throw new QueueError('not-found', `job ${id} does not exist`)
|
|
423
|
+
return record
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @param {string} id @param {Caller} caller */
|
|
427
|
+
#writable(id, caller) {
|
|
428
|
+
const record = this.#must(id)
|
|
429
|
+
if (!this.#canWrite(record, caller)) {
|
|
430
|
+
throw new QueueError('not-owner', `${id} belongs to another session`)
|
|
431
|
+
}
|
|
432
|
+
return record
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** @param {import('./types.js').JobRecord} record @param {Caller} caller */
|
|
436
|
+
#canWrite(record, caller) {
|
|
437
|
+
if (isTerminal(record.state)) return true
|
|
438
|
+
return record.ownerWorkspace === caller.workspace && record.ownerSession === caller.session
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async #nextSeq() {
|
|
442
|
+
const state = this.domain.global.get()
|
|
443
|
+
const seq = state.nextSeq
|
|
444
|
+
await this.domain.global.set({ nextSeq: seq + 1 })
|
|
445
|
+
return seq
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** @param {import('./types.js').JobRecord} record */
|
|
449
|
+
#view(record) {
|
|
450
|
+
const live = this.progress.get(record.id)
|
|
451
|
+
const total = live?.total && live.total > 0 ? live.total : record.bytesTotal
|
|
452
|
+
const done = isTerminal(record.state)
|
|
453
|
+
? (record.finalBytes ?? record.bytesCheckpoint)
|
|
454
|
+
: (live?.done ?? record.bytesCheckpoint)
|
|
455
|
+
|
|
456
|
+
return compact({
|
|
457
|
+
id: record.id,
|
|
458
|
+
state: record.state,
|
|
459
|
+
pauseReason: record.pauseReason,
|
|
460
|
+
owner: { workspace: record.ownerWorkspace, session: record.ownerSession },
|
|
461
|
+
origin: record.origin,
|
|
462
|
+
urls: record.urls,
|
|
463
|
+
mirrors: this.#mirrorView(record),
|
|
464
|
+
// Names only. A header value may be a session cookie or an API token, and
|
|
465
|
+
// this view is what every session and every model gets to read.
|
|
466
|
+
requestHeaders: headerNames(record.headers),
|
|
467
|
+
dest: record.destPath,
|
|
468
|
+
bytesTotal: total,
|
|
469
|
+
bytesDone: done,
|
|
470
|
+
speedBps: live?.speed ?? 0,
|
|
471
|
+
etaSeconds: live && live.speed > 0 && total > 0 ? Math.max(0, Math.round((total - done) / live.speed)) : undefined,
|
|
472
|
+
attempts: record.attempts,
|
|
473
|
+
lastError: record.lastError,
|
|
474
|
+
createdAt: record.createdAt,
|
|
475
|
+
updatedAt: record.updatedAt,
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Storage-write, transport channel and mirror health, surfaced through `download_status`. */
|
|
480
|
+
health() {
|
|
481
|
+
const mirrors = this.mirrorStatus()
|
|
482
|
+
return compact({
|
|
483
|
+
storage: this.storage.errors > 0 ? { ...this.storage } : { errors: 0 },
|
|
484
|
+
transport: this.transport?.modeInfo?.(),
|
|
485
|
+
mirrorHealth: mirrors.length > 0 ? mirrors : undefined,
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Candidates were recorded at enqueue time; the transport only learns which of
|
|
491
|
+
* them actually proved to serve the same bytes once the transfer starts.
|
|
492
|
+
* @param {import('./types.js').JobRecord} record
|
|
493
|
+
*/
|
|
494
|
+
#mirrorView(record) {
|
|
495
|
+
if (!record.mirrors) return undefined
|
|
496
|
+
const report = this.transport?.mirrorReports?.get(record.urls[0])
|
|
497
|
+
return {
|
|
498
|
+
candidates: record.mirrors,
|
|
499
|
+
verified: report?.verified ?? [],
|
|
500
|
+
rejected: report?.rejected ?? [],
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#schedule() {
|
|
505
|
+
if (this.closed || this.timer) return
|
|
506
|
+
this.timer = setTimeout(() => {
|
|
507
|
+
this.timer = undefined
|
|
508
|
+
void this.#pump()
|
|
509
|
+
}, 0)
|
|
510
|
+
this.timer.unref?.()
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async #pump() {
|
|
514
|
+
if (this.closed) return
|
|
515
|
+
const queued = [...this.jobs.entries()]
|
|
516
|
+
.map(([, record]) => record)
|
|
517
|
+
.filter((record) => record.state === 'queued')
|
|
518
|
+
.sort((a, b) => a.seq - b.seq)
|
|
519
|
+
|
|
520
|
+
if (queued.length === 0) return
|
|
521
|
+
|
|
522
|
+
// One session hogging every slot starves the others. Only enforce a cap
|
|
523
|
+
// when at least two sessions are actually competing: a lone session keeps
|
|
524
|
+
// the full concurrency it would get anyway.
|
|
525
|
+
const sessionKeyOf = (record) => `${record.ownerWorkspace}::${record.ownerSession}`
|
|
526
|
+
const sessions = new Set(queued.map(sessionKeyOf))
|
|
527
|
+
const cap = sessions.size >= 2 ? Math.max(1, Math.floor(this.config.maxConcurrentJobs / 2)) : Infinity
|
|
528
|
+
|
|
529
|
+
/** @type {Map<string, number>} */
|
|
530
|
+
const running = new Map()
|
|
531
|
+
for (const [id] of this.active) {
|
|
532
|
+
const record = this.jobs.get(id)
|
|
533
|
+
if (!record) continue
|
|
534
|
+
const key = sessionKeyOf(record)
|
|
535
|
+
running.set(key, (running.get(key) ?? 0) + 1)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** @type {Map<string, import('./types.js').JobRecord[]>} */
|
|
539
|
+
const bySession = new Map()
|
|
540
|
+
for (const record of queued) {
|
|
541
|
+
const key = sessionKeyOf(record)
|
|
542
|
+
const list = bySession.get(key)
|
|
543
|
+
if (list) list.push(record)
|
|
544
|
+
else bySession.set(key, [record])
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Start each pass after the session that was served last, otherwise the
|
|
548
|
+
// first session in iteration order wins every tie and round-robin is a lie.
|
|
549
|
+
const order = [...bySession.keys()]
|
|
550
|
+
const startAt = this.lastServed && order.includes(this.lastServed) ? (order.indexOf(this.lastServed) + 1) % order.length : 0
|
|
551
|
+
const rotated = [...order.slice(startAt), ...order.slice(0, startAt)]
|
|
552
|
+
|
|
553
|
+
let progressed = true
|
|
554
|
+
while (progressed && this.active.size < this.config.maxConcurrentJobs) {
|
|
555
|
+
progressed = false
|
|
556
|
+
for (const key of rotated) {
|
|
557
|
+
if (this.active.size >= this.config.maxConcurrentJobs) break
|
|
558
|
+
const list = bySession.get(key)
|
|
559
|
+
if (!list || list.length === 0) continue
|
|
560
|
+
if ((running.get(key) ?? 0) >= cap) continue
|
|
561
|
+
const record = list.shift()
|
|
562
|
+
if (this.active.has(record.id)) continue
|
|
563
|
+
running.set(key, (running.get(key) ?? 0) + 1)
|
|
564
|
+
this.lastServed = key
|
|
565
|
+
void this.#run(record)
|
|
566
|
+
progressed = true
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** @param {import('./types.js').JobRecord} record */
|
|
572
|
+
async #run(record) {
|
|
573
|
+
const id = record.id
|
|
574
|
+
const controller = new AbortController()
|
|
575
|
+
this.active.set(id, { controller, startedAt: this.now() })
|
|
576
|
+
|
|
577
|
+
record.state = 'running'
|
|
578
|
+
record.updatedAt = this.now()
|
|
579
|
+
await this.#persist(id, record)
|
|
580
|
+
|
|
581
|
+
const stats = { done: record.bytesCheckpoint, total: record.bytesTotal, speed: 0, lastDone: record.bytesCheckpoint, lastAt: this.now() }
|
|
582
|
+
this.progress.set(id, stats)
|
|
583
|
+
|
|
584
|
+
const urls = this.#orderUrls(record.urls)
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
const result = await this.transport.transfer({
|
|
588
|
+
urls,
|
|
589
|
+
destPath: record.destPath,
|
|
590
|
+
headers: record.headers,
|
|
591
|
+
connectionsCap: this.config.connectionsPerJob,
|
|
592
|
+
speedLimitKbps: this.config.speedLimitKbps,
|
|
593
|
+
bytesDone: record.bytesCheckpoint,
|
|
594
|
+
signal: controller.signal,
|
|
595
|
+
onUrlResult: (url, ok) => {
|
|
596
|
+
if (ok) this.noteUrlSuccess(url)
|
|
597
|
+
else this.noteUrlFailure(url)
|
|
598
|
+
},
|
|
599
|
+
onProgress: (done, total) => {
|
|
600
|
+
const stamp = this.now()
|
|
601
|
+
const elapsed = (stamp - stats.lastAt) / 1000
|
|
602
|
+
if (elapsed >= 0.4) {
|
|
603
|
+
stats.speed = Math.max(0, (done - stats.lastDone) / elapsed)
|
|
604
|
+
stats.lastDone = done
|
|
605
|
+
stats.lastAt = stamp
|
|
606
|
+
}
|
|
607
|
+
stats.done = done
|
|
608
|
+
if (total > 0) stats.total = total
|
|
609
|
+
},
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
if (record.expectSha256) {
|
|
613
|
+
const actual = await sha256File(record.destPath)
|
|
614
|
+
if (actual.toLowerCase() !== record.expectSha256.toLowerCase()) {
|
|
615
|
+
throw new Error(`sha256 mismatch: expected ${record.expectSha256}, got ${actual}`)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
await this.#finish(id, 'completed', {
|
|
620
|
+
bytesTotal: result.total > 0 ? result.total : stats.total,
|
|
621
|
+
bytesCheckpoint: result.bytes,
|
|
622
|
+
finalBytes: result.bytes,
|
|
623
|
+
})
|
|
624
|
+
} catch (error) {
|
|
625
|
+
const aborted = controller.signal.aborted
|
|
626
|
+
if (aborted) {
|
|
627
|
+
// pause / cancel / close already recorded the target state. Only park it
|
|
628
|
+
// here when nobody else transitioned the job — a resume that landed
|
|
629
|
+
// while this transfer was unwinding must not be undone.
|
|
630
|
+
if (record.state === 'running') {
|
|
631
|
+
await this.#stopAs(id, 'host_restart')
|
|
632
|
+
}
|
|
633
|
+
} else {
|
|
634
|
+
await this.#fail(id, error)
|
|
635
|
+
}
|
|
636
|
+
} finally {
|
|
637
|
+
this.active.delete(id)
|
|
638
|
+
this.progress.delete(id)
|
|
639
|
+
this.#schedule()
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** @param {string} id @param {Error} error */
|
|
644
|
+
async #fail(id, error) {
|
|
645
|
+
const record = this.jobs.get(id)
|
|
646
|
+
if (!record || isTerminal(record.state)) return
|
|
647
|
+
|
|
648
|
+
record.attempts += 1
|
|
649
|
+
record.lastError = error?.message ?? String(error)
|
|
650
|
+
record.updatedAt = this.now()
|
|
651
|
+
|
|
652
|
+
if (record.attempts < MAX_ATTEMPTS) {
|
|
653
|
+
record.state = 'queued'
|
|
654
|
+
record.pauseReason = undefined
|
|
655
|
+
this.log('warn', `${id} failed (attempt ${record.attempts}/${MAX_ATTEMPTS}), retrying: ${record.lastError}`)
|
|
656
|
+
} else {
|
|
657
|
+
record.state = 'failed'
|
|
658
|
+
this.log('warn', `${id} failed permanently: ${record.lastError}`)
|
|
659
|
+
}
|
|
660
|
+
await this.#persist(id, record)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** @param {string} id @param {'user'|'session_closed'|'host_restart'} reason */
|
|
664
|
+
async #stopAs(id, reason) {
|
|
665
|
+
const record = this.jobs.get(id)
|
|
666
|
+
if (!record || isTerminal(record.state)) return
|
|
667
|
+
// Only an in-flight job may be stopped. A pause already parked it, and a
|
|
668
|
+
// resume may have re-queued it while the aborted transfer was still
|
|
669
|
+
// unwinding — overwriting that would leave the job stuck in `paused`.
|
|
670
|
+
if (record.state !== 'running' && record.state !== 'queued') return
|
|
671
|
+
const stats = this.progress.get(id)
|
|
672
|
+
record.state = 'paused'
|
|
673
|
+
record.pauseReason = reason
|
|
674
|
+
if (stats) record.bytesCheckpoint = stats.done
|
|
675
|
+
record.updatedAt = this.now()
|
|
676
|
+
await this.#persist(id, record)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* @param {string} id
|
|
681
|
+
* @param {'completed'} state
|
|
682
|
+
* @param {Partial<import('./types.js').JobRecord>} patch
|
|
683
|
+
*/
|
|
684
|
+
async #finish(id, state, patch) {
|
|
685
|
+
const record = this.jobs.get(id)
|
|
686
|
+
if (!record) return
|
|
687
|
+
Object.assign(record, patch, { state, updatedAt: this.now() })
|
|
688
|
+
delete record.pauseReason
|
|
689
|
+
await this.#persist(id, record)
|
|
690
|
+
this.log('info', `${id} ${state} (${record.finalBytes ?? record.bytesCheckpoint} bytes)`)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async #checkpointAll() {
|
|
694
|
+
for (const [id] of this.active) {
|
|
695
|
+
const stats = this.progress.get(id)
|
|
696
|
+
const record = this.jobs.get(id)
|
|
697
|
+
if (!stats || !record || record.state !== 'running') continue
|
|
698
|
+
if (stats.done <= record.bytesCheckpoint) continue
|
|
699
|
+
record.bytesCheckpoint = stats.done
|
|
700
|
+
record.bytesTotal = stats.total > 0 ? stats.total : record.bytesTotal
|
|
701
|
+
record.updatedAt = this.now()
|
|
702
|
+
try {
|
|
703
|
+
await this.#persist(id, record)
|
|
704
|
+
} catch (error) {
|
|
705
|
+
// A failed checkpoint means the next resume restarts from a stale
|
|
706
|
+
// offset, which corrupts the file. Report it instead of swallowing it.
|
|
707
|
+
this.#noteStorageFailure(id, error)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** @param {string} id @param {unknown} error */
|
|
713
|
+
#noteStorageFailure(id, error) {
|
|
714
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
715
|
+
this.storage.errors += 1
|
|
716
|
+
this.storage.lastError = `${id}: ${message}`
|
|
717
|
+
this.storage.lastAt = this.now()
|
|
718
|
+
if (this.storage.errors === 3) {
|
|
719
|
+
this.log('error', `checkpoint writes are failing (${message}); resume offsets may be stale`)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Persist a lifecycle transition. Unlike the user-facing mutations, these run
|
|
725
|
+
* on the scheduler and during shutdown: a broken storage must not turn into an
|
|
726
|
+
* unhandled rejection that takes the whole plugin down with it.
|
|
727
|
+
* @param {string} id @param {import('./types.js').JobRecord} record
|
|
728
|
+
*/
|
|
729
|
+
async #persist(id, record) {
|
|
730
|
+
try {
|
|
731
|
+
await this.jobs.put(id, record)
|
|
732
|
+
return true
|
|
733
|
+
} catch (error) {
|
|
734
|
+
this.#noteStorageFailure(id, error)
|
|
735
|
+
return false
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** @param {import('./types.js').JobRecord} record */
|
|
740
|
+
async #dropPartialFiles(record) {
|
|
741
|
+
for (const file of [`${record.destPath}.part`, `${record.destPath}.aria2`]) {
|
|
742
|
+
await fs.rm(file, { force: true }).catch(() => {})
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async #cancelOrphans() {
|
|
747
|
+
if (!this.isOwnerAlive) {
|
|
748
|
+
this.log('info', 'orphan scan skipped (no session probe wired in yet)')
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
for (const [, record] of [...this.jobs.entries()]) {
|
|
752
|
+
if (isTerminal(record.state)) continue
|
|
753
|
+
if (await this.isOwnerAlive(record.ownerSession)) continue
|
|
754
|
+
record.state = 'canceled'
|
|
755
|
+
record.lastError = 'owner session no longer exists'
|
|
756
|
+
record.updatedAt = this.now()
|
|
757
|
+
await this.#persist(record.id, record)
|
|
758
|
+
await this.#dropPartialFiles(record)
|
|
759
|
+
this.log('info', `${record.id} canceled: owner session is gone`)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Refuse a destination whose directory we cannot write to, before the download
|
|
765
|
+
* starts. A sandbox can make a path readable but not writable; discovering that
|
|
766
|
+
* at the first byte wastes the whole queue slot.
|
|
767
|
+
* @param {string} destPath
|
|
768
|
+
*/
|
|
769
|
+
async #assertDestWritable(destPath) {
|
|
770
|
+
let dir = path.dirname(destPath)
|
|
771
|
+
// Walk up to the nearest directory that exists: an absent leaf is fine,
|
|
772
|
+
// it gets created at transfer time.
|
|
773
|
+
for (;;) {
|
|
774
|
+
try {
|
|
775
|
+
await fs.access(dir, fs.constants?.W_OK ?? 2)
|
|
776
|
+
return
|
|
777
|
+
} catch (error) {
|
|
778
|
+
const code = error?.code
|
|
779
|
+
if (code === 'ENOENT') {
|
|
780
|
+
const parent = path.dirname(dir)
|
|
781
|
+
if (parent === dir) return
|
|
782
|
+
dir = parent
|
|
783
|
+
continue
|
|
784
|
+
}
|
|
785
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
786
|
+
throw new QueueError('permission-denied', `cannot write to ${path.dirname(destPath)} (${code})`)
|
|
787
|
+
}
|
|
788
|
+
// Anything else (e.g. an exotic filesystem) is not a reason to block.
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Cheap guard against enqueueing onto a full disk. Sums what already-queued
|
|
796
|
+
* jobs are expected to consume, so twenty small enqueues cannot add up to a
|
|
797
|
+
* full disk one "looks fine" at a time.
|
|
798
|
+
* @param {string} destPath
|
|
799
|
+
* @param {number} [expectedBytes]
|
|
800
|
+
*/
|
|
801
|
+
async #assertDiskSpace(destPath, expectedBytes) {
|
|
802
|
+
const committed = [...this.jobs.entries()]
|
|
803
|
+
.map(([, record]) => record)
|
|
804
|
+
.filter((record) => !isTerminal(record.state))
|
|
805
|
+
.reduce((sum, record) => {
|
|
806
|
+
const remaining = record.bytesTotal > 0 ? record.bytesTotal - record.bytesCheckpoint : UNKNOWN_SIZE_RESERVE
|
|
807
|
+
return sum + Math.max(0, remaining)
|
|
808
|
+
}, 0)
|
|
809
|
+
|
|
810
|
+
const reserve = Number.isFinite(expectedBytes) && expectedBytes > 0 ? expectedBytes : UNKNOWN_SIZE_RESERVE
|
|
811
|
+
const needed = MIN_FREE_BYTES + committed + reserve
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
const stats = await fs.statfs(path.dirname(destPath))
|
|
815
|
+
const free = Number(stats.bavail) * Number(stats.bsize)
|
|
816
|
+
if (Number.isFinite(free) && free < needed) {
|
|
817
|
+
const shortfall = needed - free
|
|
818
|
+
throw new QueueError(
|
|
819
|
+
'disk-full',
|
|
820
|
+
`need ${Math.ceil(needed / 1048576)} MB free for ${path.dirname(destPath)} ` +
|
|
821
|
+
`(512 MB reserve + ${Math.ceil(committed / 1048576)} MB queued + ${Math.ceil(reserve / 1048576)} MB this job), ` +
|
|
822
|
+
`only ${Math.floor(free / 1048576)} MB available, short by ${Math.ceil(shortfall / 1048576)} MB`,
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
} catch (error) {
|
|
826
|
+
if (error instanceof QueueError) throw error
|
|
827
|
+
/* statfs is unavailable on some platforms; do not block the enqueue */
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ------------------------------------------------------------------- mirrors
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Mark a URL host as failed. Two consecutive failures park it for a while so a
|
|
835
|
+
* dead mirror is not retried on every job; the cooldown is in memory only, so
|
|
836
|
+
* a restart clears it rather than turning a blip into a permanent ban.
|
|
837
|
+
* @param {string} url
|
|
838
|
+
*/
|
|
839
|
+
noteUrlFailure(url) {
|
|
840
|
+
const host = hostOf(url)
|
|
841
|
+
if (!host) return
|
|
842
|
+
const entry = this.mirrorHealth.get(host) ?? { fails: 0, cooldownUntil: 0 }
|
|
843
|
+
entry.fails += 1
|
|
844
|
+
if (entry.fails >= MIRROR_FAIL_THRESHOLD) {
|
|
845
|
+
entry.cooldownUntil = this.now() + MIRROR_COOLDOWN_MS
|
|
846
|
+
this.log('warn', `mirror ${host} cooling down for ${MIRROR_COOLDOWN_MS / 60000} min after ${entry.fails} failures`)
|
|
847
|
+
}
|
|
848
|
+
this.mirrorHealth.set(host, entry)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** @param {string} url */
|
|
852
|
+
noteUrlSuccess(url) {
|
|
853
|
+
const host = hostOf(url)
|
|
854
|
+
if (!host) return
|
|
855
|
+
this.mirrorHealth.delete(host)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Hosts currently in cooldown, for `download_status`. */
|
|
859
|
+
mirrorStatus() {
|
|
860
|
+
const out = []
|
|
861
|
+
for (const [host, entry] of this.mirrorHealth) {
|
|
862
|
+
if (entry.cooldownUntil > this.now()) {
|
|
863
|
+
out.push({ host, fails: entry.fails, cooldownUntil: entry.cooldownUntil })
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return out
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Reorder URLs so cooling hosts are tried last. @param {string[]} urls */
|
|
870
|
+
#orderUrls(urls) {
|
|
871
|
+
const stamp = this.now()
|
|
872
|
+
const warm = []
|
|
873
|
+
const cold = []
|
|
874
|
+
for (const url of urls) {
|
|
875
|
+
const host = hostOf(url)
|
|
876
|
+
const entry = host ? this.mirrorHealth.get(host) : undefined
|
|
877
|
+
if (entry && entry.cooldownUntil > stamp) cold.push(url)
|
|
878
|
+
else warm.push(url)
|
|
879
|
+
}
|
|
880
|
+
return cold.length === 0 ? urls : [...warm, ...cold]
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** @param {string} file */
|
|
885
|
+
async function sha256File(file) {
|
|
886
|
+
const hash = createHash('sha256')
|
|
887
|
+
await new Promise((resolve, reject) => {
|
|
888
|
+
createReadStream(file)
|
|
889
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
890
|
+
.on('end', resolve)
|
|
891
|
+
.on('error', reject)
|
|
892
|
+
})
|
|
893
|
+
return hash.digest('hex')
|
|
894
|
+
}
|