Files
SolidScribe/server/helpers/HostQueue.js
T
2026-11-03 11:20:03 -07:00

222 lines
5.8 KiB
JavaScript

//
// Per-host politeness scheduler. Knows nothing about scraping.
//
// This is the piece that stops the scraper looking like a crawler. The old code
// did foundUrls.forEach(fetch) - a note with 40 links opened 40 simultaneous
// connections, which is the most bot-like traffic shape available. Here, each
// host gets one request at a time with jittered spacing between them, and a
// global cap bounds total concurrency.
//
const config = require('@config/scrape')
let HostQueue = module.exports = {}
const jitter = (min, max) => Math.round(min + Math.random() * Math.max(0, max - min))
class Queue {
constructor(options = {}) {
this.globalConcurrency = options.globalConcurrency != null
? options.globalConcurrency : config.maxConcurrentFetches
this.perHostConcurrency = options.perHostConcurrency != null
? options.perHostConcurrency : config.perHostConcurrency
this.minSpacingMs = options.minSpacingMs != null
? options.minSpacingMs : config.perHostMinSpacingMs
this.maxSpacingMs = options.maxSpacingMs != null
? options.maxSpacingMs : config.perHostMaxSpacingMs
this.now = options.now || (() => Date.now())
// Injectable so tests with a controlled clock don't spend real
// wall-time waiting out politeness spacing.
this.setTimeoutFn = options.setTimeoutFn || ((fn, ms) => setTimeout(fn, ms))
this.clearTimeoutFn = options.clearTimeoutFn || ((handle) => clearTimeout(handle))
this.pending = [] // FIFO
this.hosts = new Map() // hostKey -> { running, nextAllowedAt }
this.running = 0
this.seq = 0
this.timer = null
this.stopped = false
this.drainWaiters = []
}
hostState(hostKey) {
if (!this.hosts.has(hostKey)) {
this.hosts.set(hostKey, { running: 0, nextAllowedAt: 0 })
}
return this.hosts.get(hostKey)
}
//
// Queue a task for a host. Resolves/rejects with the task's own outcome.
// spacingMs overrides the jitter floor (robots.txt Crawl-delay).
//
schedule(hostKey, taskFn, options = {}) {
if (this.stopped) {
return Promise.reject(new Error('HostQueue stopped'))
}
return new Promise((resolve, reject) => {
this.pending.push({
hostKey: hostKey || '(none)',
taskFn,
resolve,
reject,
spacingMs: options.spacingMs != null ? options.spacingMs : null,
seq: this.seq++,
})
this.pump()
})
}
//
// Run whatever is eligible. FIFO scan rather than per-host round-robin, so
// no host can starve another and order stays predictable.
//
pump() {
if (this.stopped) { return }
this.clearTimer()
while (this.running < this.globalConcurrency) {
const at = this.now()
let index = -1
let earliest = Infinity
for (let i = 0; i < this.pending.length; i++) {
const job = this.pending[i]
const host = this.hostState(job.hostKey)
if (host.running >= this.perHostConcurrency) { continue }
if (host.nextAllowedAt > at) {
earliest = Math.min(earliest, host.nextAllowedAt)
continue
}
index = i
break
}
if (index === -1) {
// Nothing runnable now. Wake up when the earliest host is due.
if (this.pending.length && earliest !== Infinity) {
this.armTimer(earliest - at)
}
break
}
this.run(this.pending.splice(index, 1)[0])
}
this.checkDrained()
}
run(job) {
const host = this.hostState(job.hostKey)
host.running++
this.running++
const settle = () => {
host.running--
this.running--
// Space the NEXT request to this host from the end of this one.
const spacing = job.spacingMs != null
? job.spacingMs
: jitter(this.minSpacingMs, this.maxSpacingMs)
host.nextAllowedAt = this.now() + spacing
this.pump()
}
// A throwing taskFn must not wedge the pump, so the call itself is
// wrapped rather than only its promise.
let result = null
try {
result = Promise.resolve(job.taskFn())
} catch (error) {
result = Promise.reject(error)
}
result.then(
value => { settle(); job.resolve(value) },
error => { settle(); job.reject(error) }
)
}
armTimer(delay) {
this.clearTimer()
this.timer = this.setTimeoutFn(() => {
this.timer = null
this.pump()
}, Math.max(1, delay))
//
// Deliberately NOT unref'd.
//
// This timer is the only thing that will resume a host which is inside
// its politeness gap. unref'ing it means that if nothing else holds the
// event loop open, Node exits and every queued job is silently
// abandoned with its promise never settling. Inside the server that is
// masked by the listening socket, but it makes the queue unusable from
// a standalone script and is a footgun either way.
//
// Shutdown is handled explicitly instead: stop() clears this timer and
// rejects whatever is still queued, and it is wired to SIGINT/SIGTERM
// in server/index.js.
//
}
clearTimer() {
if (this.timer) {
this.clearTimeoutFn(this.timer)
this.timer = null
}
}
size() { return this.pending.length }
inflight() { return this.running }
stats() {
return {
pending: this.pending.length,
running: this.running,
hosts: this.hosts.size,
}
}
checkDrained() {
if (this.pending.length || this.running) { return }
const waiters = this.drainWaiters
this.drainWaiters = []
waiters.forEach(resolve => resolve())
}
// Resolves once the queue is empty and nothing is in flight.
drain() {
if (!this.pending.length && !this.running) { return Promise.resolve() }
return new Promise(resolve => this.drainWaiters.push(resolve))
}
//
// Reject everything still queued. In-flight tasks are not cancelled here -
// their own AbortControllers handle that.
//
stop() {
this.stopped = true
this.clearTimer()
const pending = this.pending
this.pending = []
pending.forEach(job => job.reject(new Error('HostQueue stopped')))
this.checkDrained()
}
}
HostQueue.Queue = Queue
// Shared instance used by the scrape worker. Tests build their own.
HostQueue.shared = new Queue()