muaddib-scanner 2.11.124 → 2.11.125
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/package.json
CHANGED
package/src/monitor/ingestion.js
CHANGED
|
@@ -115,7 +115,7 @@ function httpsGet(url, timeoutMs = 30_000, deadlineMs = Math.max(timeoutMs * 2,
|
|
|
115
115
|
// Coordinated backoff: drain the SHARED token bucket so every in-flight registry fetch
|
|
116
116
|
// slows together. This high-volume packument/changes path must signal 429 like the
|
|
117
117
|
// metadata path (npm-registry.js) does — not just acquire a slot (CLAUDE.md 429 storm).
|
|
118
|
-
try { require('../shared/http-limiter.js').signal429(); } catch { /* limiter best-effort */ }
|
|
118
|
+
try { const _l = require('../shared/http-limiter.js'); _l.signal429(_l.hostForUrl(url)); } catch { /* limiter best-effort */ }
|
|
119
119
|
return done(new Error(`HTTP 429 rate limited for ${url}`));
|
|
120
120
|
}
|
|
121
121
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
@@ -177,7 +177,7 @@ function httpsPost(url, body, headers = {}, timeoutMs = 30_000, deadlineMs = Mat
|
|
|
177
177
|
req = _deps.https.request(options, (res) => {
|
|
178
178
|
if (res.statusCode === 429) {
|
|
179
179
|
res.resume();
|
|
180
|
-
try { require('../shared/http-limiter.js').signal429(); } catch { /* limiter best-effort */ }
|
|
180
|
+
try { const _l = require('../shared/http-limiter.js'); _l.signal429(_l.hostForUrl(url)); } catch { /* limiter best-effort */ }
|
|
181
181
|
return done(new Error(`HTTP 429 rate limited for POST ${url}`));
|
|
182
182
|
}
|
|
183
183
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
package/src/monitor/queue.js
CHANGED
|
@@ -84,7 +84,7 @@ const {
|
|
|
84
84
|
|
|
85
85
|
// From ./ingestion.js
|
|
86
86
|
const { getNpmLatestTarball, getPyPITarballUrl } = require('./ingestion.js');
|
|
87
|
-
const { enqueueScan, dequeueScan } = require('./scan-queue.js');
|
|
87
|
+
const { enqueueScan, dequeueScan, shouldPullNewest } = require('./scan-queue.js');
|
|
88
88
|
|
|
89
89
|
// From ./tarball-archive.js
|
|
90
90
|
const { archiveSuspectTarball } = require('./tarball-archive.js');
|
|
@@ -98,6 +98,7 @@ const { BASE_CONCURRENCY, MIN_CONCURRENCY, MAX_CONCURRENCY } = require('./adapti
|
|
|
98
98
|
|
|
99
99
|
// SCAN_CONCURRENCY kept as getter for backward compatibility (tests, logging)
|
|
100
100
|
let _targetConcurrency = BASE_CONCURRENCY;
|
|
101
|
+
let _dequeueSeq = 0; // shared monotonic cursor for fresh-first round-robin (Phase 2)
|
|
101
102
|
const SCAN_CONCURRENCY = BASE_CONCURRENCY; // legacy export — tests check this value
|
|
102
103
|
let _activeWorkers = 0;
|
|
103
104
|
const _workerPromises = new Set();
|
|
@@ -1780,7 +1781,10 @@ async function _spawnWorker(scanQueue, stats, dailyAlerts, recentlyScanned, down
|
|
|
1780
1781
|
// admission (1 scan when nothing is in flight) can still flow.
|
|
1781
1782
|
if (isGovernorFrozen() && (getGovernorState().outstandingCount > 0 || _activeWorkers > 1)) break;
|
|
1782
1783
|
// AUDIT A2: FIFO by default; priority dequeue when MUADDIB_PRIORITY_DEQUEUE=1.
|
|
1783
|
-
|
|
1784
|
+
// Phase 2: when MUADDIB_FRESH_FIRST=1 and a backlog exists, most lanes take the
|
|
1785
|
+
// newest item (fresh-first) while a reserved few keep draining the oldest.
|
|
1786
|
+
const newest = shouldPullNewest(_dequeueSeq++, scanQueue.length, _targetConcurrency);
|
|
1787
|
+
const item = dequeueScan(scanQueue, { newest });
|
|
1784
1788
|
if (!item) break;
|
|
1785
1789
|
_inFlightItems.add(item);
|
|
1786
1790
|
try {
|
|
@@ -201,6 +201,26 @@ const PRIORITY_DEQUEUE_WINDOW = (() => {
|
|
|
201
201
|
return Number.isFinite(v) && v > 0 ? v : 2048;
|
|
202
202
|
})();
|
|
203
203
|
|
|
204
|
+
// ── Fresh-first scheduling (throughput plan Phase 2; gated OFF by default) ───
|
|
205
|
+
// When MUADDIB_FRESH_FIRST=1 AND a real backlog exists (queue > split threshold),
|
|
206
|
+
// most worker lanes dequeue the NEWEST item (tail) so a freshly-published package
|
|
207
|
+
// is scanned in minutes instead of aging ~hours behind a FIFO backlog, while a
|
|
208
|
+
// reserved `drainLanes` keep draining the OLDEST (anti-starvation). Below the
|
|
209
|
+
// threshold all lanes stay strict FIFO ("back to the normal flow" once drained).
|
|
210
|
+
// Pure ordering change — every item is still scanned; inert until the flag is on.
|
|
211
|
+
const FRESH_FIRST = (() => {
|
|
212
|
+
const v = process.env.MUADDIB_FRESH_FIRST;
|
|
213
|
+
return v === '1' || v === 'true';
|
|
214
|
+
})();
|
|
215
|
+
const BACKLOG_DRAIN_LANES = (() => {
|
|
216
|
+
const v = parseInt(process.env.MUADDIB_BACKLOG_DRAIN_LANES, 10);
|
|
217
|
+
return Number.isFinite(v) && v > 0 ? v : 2;
|
|
218
|
+
})();
|
|
219
|
+
const BACKLOG_SPLIT_THRESHOLD = (() => {
|
|
220
|
+
const v = parseInt(process.env.MUADDIB_BACKLOG_SPLIT_THRESHOLD, 10);
|
|
221
|
+
return Number.isFinite(v) && v > 0 ? v : 5000;
|
|
222
|
+
})();
|
|
223
|
+
|
|
204
224
|
function _isPriority(item) {
|
|
205
225
|
return !!(item && (item.firstPublish || item.isIOCMatch || (item.isBurst && !item.isATOBurstExtra)));
|
|
206
226
|
}
|
|
@@ -213,8 +233,12 @@ function _isPriority(item) {
|
|
|
213
233
|
* @param {{priority?: boolean, window?: number}} [opts] test overrides
|
|
214
234
|
*/
|
|
215
235
|
function dequeueScan(scanQueue, opts = {}) {
|
|
236
|
+
if (scanQueue.length === 0) return scanQueue.shift();
|
|
237
|
+
// Fresh-first lanes take the NEWEST item (tail). Takes precedence over priority:
|
|
238
|
+
// the freshest publish is the one we most want to be first on.
|
|
239
|
+
if (opts.newest) return scanQueue.pop();
|
|
216
240
|
const priority = opts.priority !== undefined ? opts.priority : PRIORITY_DEQUEUE;
|
|
217
|
-
if (!priority
|
|
241
|
+
if (!priority) return scanQueue.shift();
|
|
218
242
|
const win = Math.min(scanQueue.length, opts.window || PRIORITY_DEQUEUE_WINDOW);
|
|
219
243
|
for (let i = 0; i < win; i++) {
|
|
220
244
|
if (_isPriority(scanQueue[i])) return i === 0 ? scanQueue.shift() : scanQueue.splice(i, 1)[0];
|
|
@@ -222,4 +246,23 @@ function dequeueScan(scanQueue, opts = {}) {
|
|
|
222
246
|
return scanQueue.shift();
|
|
223
247
|
}
|
|
224
248
|
|
|
225
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Fresh-first dequeue decision (Phase 2, gated by MUADDIB_FRESH_FIRST). True → this
|
|
251
|
+
* dequeue should take the NEWEST item (tail); false → oldest (FIFO/priority head).
|
|
252
|
+
* Splits only above BACKLOG_SPLIT_THRESHOLD; below it everything stays FIFO. Of
|
|
253
|
+
* `target` concurrent lanes, `drainLanes` keep pulling oldest (anti-starvation), the
|
|
254
|
+
* rest go fresh. `seq` is a shared monotonic dequeue counter (single-threaded →
|
|
255
|
+
* consistent). Pure; `opts` override the env gates for tests.
|
|
256
|
+
*/
|
|
257
|
+
function shouldPullNewest(seq, queueLen, target, opts = {}) {
|
|
258
|
+
const enabled = opts.enabled !== undefined ? opts.enabled : FRESH_FIRST;
|
|
259
|
+
if (!enabled) return false;
|
|
260
|
+
const threshold = opts.threshold !== undefined ? opts.threshold : BACKLOG_SPLIT_THRESHOLD;
|
|
261
|
+
if (queueLen <= threshold) return false;
|
|
262
|
+
const drainLanes = opts.drainLanes !== undefined ? opts.drainLanes : BACKLOG_DRAIN_LANES;
|
|
263
|
+
const lanes = Math.max(2, target || 2);
|
|
264
|
+
const drain = Math.min(drainLanes, lanes - 1); // always leave ≥1 fresh lane
|
|
265
|
+
return (seq % lanes) >= drain; // first `drain` slots → oldest; rest → newest
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = { enqueueScan, evictFromScanQueueBulk, dequeueScan, shouldPullNewest, isProtected: _isProtected, MAX_SCAN_QUEUE };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
|
|
2
2
|
const { debugLog } = require('../utils.js');
|
|
3
|
-
const { acquireRegistrySlot, releaseRegistrySlot, awaitRateToken, signal429, hostForUrl } = require('../shared/http-limiter.js');
|
|
3
|
+
const { acquireRegistrySlot, releaseRegistrySlot, awaitRateToken, signal429, hostForUrl, isHostBackedOff } = require('../shared/http-limiter.js');
|
|
4
4
|
const { registryAuthHeaders } = require('../shared/registry-auth.js');
|
|
5
5
|
const { computeAdvancedRegistrySignals } = require('../integrations/registry-signals.js');
|
|
6
6
|
|
|
@@ -223,8 +223,30 @@ async function getPackageMetadata(packageName) {
|
|
|
223
223
|
return count;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// Downloads-count (api.npmjs.org) is a SEPARATE, aggressively rate-limited host and
|
|
227
|
+
// is OFF the detection path — it only feeds the reputation/ML weekly_downloads
|
|
228
|
+
// feature. The ingest pre-resolve already fetches + 24h-caches it (classify.js
|
|
229
|
+
// downloadsCache). So prefer the warm cache; if cold AND api.npmjs.org is in 429
|
|
230
|
+
// backoff, SKIP rather than park the scan on the token gate (a level-12 60s pause
|
|
231
|
+
// was stalling every cold scan ~20s). Same graceful degradation as the author-count
|
|
232
|
+
// kill-switch: weekly_downloads stays absent, never blocks throughput.
|
|
233
|
+
let cachedDownloads;
|
|
234
|
+
try {
|
|
235
|
+
const { downloadsCache, DOWNLOADS_CACHE_TTL } = require('../monitor/classify.js');
|
|
236
|
+
const hit = downloadsCache.get(packageName);
|
|
237
|
+
if (hit && (Date.now() - hit.fetchedAt) < DOWNLOADS_CACHE_TTL && hit.downloads >= 0) {
|
|
238
|
+
cachedDownloads = hit.downloads;
|
|
239
|
+
}
|
|
240
|
+
} catch { /* classify unavailable (scanner-only context) — fall through to a guarded fetch */ }
|
|
241
|
+
|
|
242
|
+
const downloadsPromise = (typeof cachedDownloads === 'number')
|
|
243
|
+
? Promise.resolve({ downloads: cachedDownloads })
|
|
244
|
+
: (isHostBackedOff(hostForUrl(downloadsUrl))
|
|
245
|
+
? Promise.resolve(null) // host hammered → skip; do not stall the scan
|
|
246
|
+
: fetchWithRetry(downloadsUrl, { noRetryOn429: true }));
|
|
247
|
+
|
|
226
248
|
const [downloadsData, authorPackageCount] = await Promise.all([
|
|
227
|
-
|
|
249
|
+
downloadsPromise, // api.npmjs.org — cached / skipped-under-backoff / best-effort
|
|
228
250
|
getAuthorPackageCount() // registry.npmjs.org search — cached + kill-switchable
|
|
229
251
|
]);
|
|
230
252
|
|
|
@@ -162,6 +162,19 @@ function hostForUrl(url) {
|
|
|
162
162
|
try { return new URL(url).hostname || DEFAULT_HOST; } catch { return DEFAULT_HOST; }
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* True when a host is in (or near) a 429 backoff — fetching it now would park on
|
|
167
|
+
* the token gate. Lets best-effort callers (downloads-count) SKIP the fetch under a
|
|
168
|
+
* storm instead of stalling the scan. "Near" = an active pause OR a high backoff
|
|
169
|
+
* level (the host is 429ing essentially every probe). Pure read: never allocates a
|
|
170
|
+
* bucket (an unseen host is, by definition, not backed off).
|
|
171
|
+
*/
|
|
172
|
+
function isHostBackedOff(host, levelThreshold = 6) {
|
|
173
|
+
const b = _buckets.get(host || DEFAULT_HOST);
|
|
174
|
+
if (!b) return false;
|
|
175
|
+
return Date.now() < b.bo.pauseUntil || b.bo.level >= levelThreshold;
|
|
176
|
+
}
|
|
177
|
+
|
|
165
178
|
function _effectiveRate(level = 0) {
|
|
166
179
|
let rate = RATE_LIMIT_PER_SEC;
|
|
167
180
|
if (BOOT_SLOWSTART_MS > 0 && Date.now() - _bootAt < BOOT_SLOWSTART_MS) {
|
|
@@ -463,6 +476,7 @@ module.exports = {
|
|
|
463
476
|
getBrainState,
|
|
464
477
|
computeBackoffTransition,
|
|
465
478
|
hostForUrl,
|
|
479
|
+
isHostBackedOff,
|
|
466
480
|
resetLimiter,
|
|
467
481
|
_restartBootSlowStartForTests,
|
|
468
482
|
getActiveSemaphore
|