UNPKG

@dudousxd/nestjs-telescope

Version:

Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.

541 lines 27.9 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var TelescopePruner_1; // packages/core/src/nest/telescope-pruner.service.ts import { Inject, Injectable, Logger, } from '@nestjs/common'; import { PRUNE_LOCK_KEY, StorageLeasePruneLock, pruneLockUnavailable, } from '../prune/prune-lock.js'; import { isLeaseCapableStorage } from '../storage/storage-provider.js'; import { TELESCOPE_CONFIG, TELESCOPE_STORAGE } from './telescope.options.js'; /** Ring-buffer cap for recent prune runs (newest-first). */ const MAX_PRUNE_RUNS = 100; let TelescopePruner = TelescopePruner_1 = class TelescopePruner { config; storage; logger = new Logger(TelescopePruner_1.name); timer = null; /** * Latches once after the FIRST time we fall back from a missing * `pruneScoped` to the global `prune`, so a third-party provider without * per-type support logs the capability warning a single time, not every tick. */ warnedNoScopedPrune = false; /** * The cycle currently running, or `null` when idle. A cycle can easily outlive * its own interval on a large store — the bulk delete is one unbounded * `DELETE`, and on a store whose retention predicate is not indexable it * degrades to a full scan — while the timer below is fire-and-forget. Without * this handle the ticks STACK: the process accumulates one more concurrent * delete per interval, every one of them contending for the same rows, and * the pile never drains. Scheduled ticks are dropped while a cycle is in * flight; a manual {@link pruneNow} joins the in-flight cycle instead of * adding a second one. */ inFlight = null; /** * Scheduled ticks dropped during the cycle that is currently in flight. Reset * at the end of every cycle, so it measures the CURRENT cycle's overrun, not * a lifetime total. */ skippedThisCycle = 0; /** * Latches while ticks are being dropped so a persistently slow store logs the * overlap warning once per streak rather than once per interval forever. * Cleared by the first cycle that completes without dropping a tick behind it. */ warnedOverlap = false; /** * Latches once after the first scope that falls back to an UNBOUNDED delete * because the provider has no `pruneScopedBatch`, so a legacy/third-party * provider says so once rather than every tick. */ warnedUnboundedDelete = false; /** * Latches while cycles keep hitting `maxBatchesPerCycle`, so a store with a * real backlog logs once per streak instead of once per cycle forever. * Cleared by the first cycle that drains every scope inside the ceiling. */ warnedBatchCeiling = false; /** Batch ceilings hit during the CURRENT cycle; re-arms the warning at zero. */ ceilingHitsThisCycle = 0; /** Latches while the prune lock's BACKEND is broken (not merely held). */ warnedLockUnavailable = false; /** Recent prune cycles, newest-first, capped at {@link MAX_PRUNE_RUNS}. */ runs = []; /** Start time (epoch ms) of the most recent SCHEDULED cycle, for nextRunAt. */ lastScheduledRunAtMs = null; /** * The cross-process lock, or `null` when this deployment prunes unlocked (the * host set `prune.lock: false`, or supplied nothing and the provider has no * lease SPI). Resolved ONCE in the constructor: the host's own implementation * wins, else the database lease, else nothing. */ lock; /** * This process's identity as a lease holder. `instanceId` is the pod name * under Kubernetes, which is unique per replica but NOT per process on a host * running two of them, so the pid is appended — two pruners must never be able * to mistake each other's lease for a re-entrant refresh of their own. */ lockOwner; constructor(config, storage) { this.config = config; this.storage = storage; this.lockOwner = `${config.instanceId}#${process.pid}`; this.lock = resolvePruneLock(config, storage); } onApplicationBootstrap() { const prune = this.config.prune; if (!this.config.enabled || !prune) return; this.timer = setInterval(() => { // The whole cycle is fire-and-forget on an unref'd timer: a rejection here // must never become an unhandled rejection or crash the host, so we always // catch. Individual sub-steps already swallow their own failures so one bad // type can't abort the rest of the cycle; this is the final backstop. this.runGuardedCycle(prune, 'scheduled').catch((error) => { this.logger.warn(`Telescope prune failed: ${asError(error).message}`); }); }, prune.intervalMs); this.timer.unref?.(); } /** * Run ONE prune cycle on demand (the dashboard's "Prune now" button → the * controller's `retention/prune` route), recording it as a `manual` run. * Returns the total number of entries deleted. Throws only if `prune` is * unconfigured — the caller (controller) gates that and the mutation guard. * * When a cycle is already running this JOINS it (resolving with its deleted * count) rather than starting a competing one, so hammering the button cannot * pile deletes onto a store that is already struggling. */ async pruneNow() { const prune = this.config.prune; if (!prune) return 0; return this.runGuardedCycle(prune, 'manual'); } /** Recent prune runs (newest-first), copied so callers can't mutate the ring. */ getRuns() { return [...this.runs]; } /** * Predicted next SCHEDULED prune time (epoch ms), or null when no `prune` * window is configured. Derived from the last scheduled run's start + the * interval, falling back to now + interval before the first cycle has run. */ getNextRunAtMs() { const prune = this.config.prune; if (!prune) return null; return (this.lastScheduledRunAtMs ?? Date.now()) + prune.intervalMs; } onApplicationShutdown() { if (this.timer) { clearInterval(this.timer); this.timer = null; } } /** * Serializes prune cycles WITHIN THIS PROCESS: at most one runs at a time. * A scheduled tick that lands on a busy pruner is dropped; a manual one joins * the cycle already running. Without this, a cycle slower than `intervalMs` * lets the timer queue a second, then a third, each holding write locks on * overlapping rows in the same store, and the backlog only grows. * * This guard sees only THIS process. Bounding the FLEET is the job of the * cross-process lock layered on top of it in {@link runLockedCycle}. */ runGuardedCycle(prune, trigger) { const running = this.inFlight; if (running !== null) { // A manual request waits for the cycle already doing the work. if (trigger === 'manual') return running; this.skippedThisCycle += 1; if (!this.warnedOverlap) { this.warnedOverlap = true; this.logger.warn(`Telescope prune is still running when the next tick fired (interval ${prune.intervalMs}ms); dropping ticks until it finishes. Raise prune.intervalMs, widen prune.after, or index the store for the retention predicate.`); } // Nothing is lost by dropping the tick: a cycle is idempotent and its // cutoffs are computed from `Date.now()` at start, so the next one that // actually runs simply deletes at a fresher cutoff. return Promise.resolve(0); } const cycle = this.runLockedCycle(prune, trigger).finally(() => { this.inFlight = null; // A clean cycle re-arms the warnings, so a store that degrades again later // says so instead of staying silent for the life of the process. if (this.skippedThisCycle === 0) this.warnedOverlap = false; if (this.ceilingHitsThisCycle === 0) this.warnedBatchCeiling = false; this.skippedThisCycle = 0; this.ceilingHitsThisCycle = 0; }); this.inFlight = cycle; return cycle; } /** * Serializes prune cycles ACROSS PROCESSES, when a lock is available. * * The per-process guard above bounds one pod to one cycle; a fleet of eight * still put eight concurrent deletes on the same table, doing the same work * eight times. This takes the advisory lease first and stands down when * somebody else already has it. * * Three outcomes, all deliberate: * - no lock configured → prune, exactly as before; * - lease HELD by another replica → skip the cycle entirely and return 0. No * `PruneRun` is recorded, because this pod did not prune — reporting a * zero-deletion run would read as "nothing to delete", which is a different * and much more alarming statement; * - lock backend UNAVAILABLE (or it threw) → warn once per streak and prune * ANYWAY. A broken lock must never be able to stop retention: failing open * is at worst the behaviour that existed before the lock did, whereas * failing closed silently lets the table grow without bound. */ async runLockedCycle(prune, trigger) { const lock = this.lock; if (lock === null) return this.runCycle(prune, trigger); // The seam's contract says acquire never throws, but a host implementation // that breaks that promise must not take retention down with it. const result = await lock .acquire({ key: PRUNE_LOCK_KEY, owner: this.lockOwner, ttlMs: prune.lockTtlMs }) .catch((error) => pruneLockUnavailable(asError(error).message)); if (!result.acquired) { if (result.reason === 'held') { // The healthy path in a multi-replica deployment: most pods take this // branch on most ticks. Debug, never warn — it is the feature working. this.logger.debug(`Telescope prune skipped: ${PRUNE_LOCK_KEY} held by another instance.`); return 0; } if (!this.warnedLockUnavailable) { this.warnedLockUnavailable = true; const detail = result.detail === undefined ? '' : `: ${result.detail}`; this.logger.warn(`Telescope prune lock is unavailable${detail}; pruning WITHOUT cross-process exclusion until it recovers. Replicas may prune concurrently (wasteful, not unsafe).`); } return this.runCycle(prune, trigger); } this.warnedLockUnavailable = false; try { return await this.runCycle(prune, trigger); } finally { // Released whether the cycle succeeded, failed, or threw. `release` is // contractually non-throwing, but a host that breaks that must not turn a // successful prune into a rejected cycle. await result.lease.release().catch((error) => { this.logger.warn(`Telescope prune lock release failed (the lease TTL will reclaim it): ${asError(error).message}`); }); } } /** * One prune tick, unguarded — every caller goes through * {@link runGuardedCycle}, so at most one of these is in flight per process. * The retention model is: * - Each type that needs INDIVIDUAL handling — one with a `perType` override * OR an archived type (which must be exported before its own delete) — is * pruned in its OWN scope, at its own cutoff (its `perType` value, else the * global `after`), with archiving (when configured) first. * - Every OTHER type is pruned in a single bulk delete at the global cutoff, * with the individually-handled types carved out. * * Archived types are ALWAYS carved out of the bulk delete even with no `perType` * override, so a failed sink can spare them (the bulk delete would otherwise * wipe entries the sink never saw). With no overrides and no archive (the common * case) the individual set is empty and this collapses to exactly one global * `prune(cutoff, keepLast)` — identical to the historical behaviour. */ async runCycle(prune, trigger) { const startedAtMs = Date.now(); const archivedTypes = this.config.archive?.types ?? new Set(); // Types needing their own scope: per-type overrides ∪ archived types. const individualTypes = new Set([...Object.keys(prune.perTypeMs), ...archivedTypes]); const deletedByType = {}; let deletedTotal = 0; let archivedTotal = 0; let error; // 1) Bulk prune for every type NOT handled individually, at the global cutoff. // With an empty individual set this is a plain global prune, so legacy // providers that only have `prune` behave exactly as before. The bulk // delete spans many types and returns a single aggregate count, so it // contributes to `deletedTotal` but is not attributed to any type key. const globalCutoff = new Date(startedAtMs - prune.afterMs); const bulk = await this.pruneArchivedThenDelete(prune, globalCutoff, prune.keepLast, { before: globalCutoff, ...(individualTypes.size > 0 ? { excludeTypes: [...individualTypes] } : {}), ...(prune.keepLast !== undefined ? { keepLast: prune.keepLast } : {}), }); deletedTotal += bulk.deleted; archivedTotal += bulk.archived; if (bulk.error !== undefined && error === undefined) error = bulk.error; // 2) One scoped prune per individually-handled type, at its own cutoff (its // perType override when present, else the global cutoff), archiving first. // These scopes ARE type-attributable, so their counts populate // `deletedByType` with real per-type numbers. for (const type of individualTypes) { const afterMs = prune.perTypeMs[type] ?? prune.afterMs; const cutoff = new Date(startedAtMs - afterMs); const step = await this.pruneArchivedThenDelete(prune, cutoff, prune.keepLast, { before: cutoff, type, ...(prune.keepLast !== undefined ? { keepLast: prune.keepLast } : {}), }, type); if (step.deleted > 0) deletedByType[type] = (deletedByType[type] ?? 0) + step.deleted; deletedTotal += step.deleted; archivedTotal += step.archived; if (step.error !== undefined && error === undefined) error = step.error; } if (trigger === 'scheduled') this.lastScheduledRunAtMs = startedAtMs; this.recordRun({ at: new Date(startedAtMs).toISOString(), trigger, durationMs: Date.now() - startedAtMs, deletedTotal, deletedByType, ...(archivedTotal > 0 ? { archivedTotal } : {}), ...(error !== undefined ? { error } : {}), }); return deletedTotal; } /** * Append a run to the newest-first ring buffer, evicting the oldest past the * cap. Recording must NEVER throw into the prune path (a bad ISO/serialization * would otherwise turn observability into an outage), so it is fully guarded. */ recordRun(run) { try { this.runs.unshift(run); while (this.runs.length > MAX_PRUNE_RUNS) this.runs.pop(); } catch (error) { this.logger.warn(`Telescope failed to record prune run: ${asError(error).message}`); } } /** * Archives (if configured) the entries this `scope` is about to delete, then * deletes them. When the scope targets a single archived `type` whose sink * fails, the delete is SKIPPED (entries survive to retry next cycle) but the * caller's other scopes are unaffected. Errors never propagate out of here. * * `fallbackOlderThan`/`fallbackKeepLast` are used only by the legacy global * fallback path when the provider lacks `pruneScoped`. */ async pruneArchivedThenDelete(prune, fallbackOlderThan, fallbackKeepLast, scope, archivableType) { try { // Archive must complete before the matching delete. If it throws for a // single-type scope, bail WITHOUT deleting so the data survives. const { proceed, archived } = await this.archiveScope(scope, archivableType); if (!proceed) return { deleted: 0, archived }; const deleted = await this.deleteScope(prune, scope, fallbackOlderThan, fallbackKeepLast); return { deleted, archived }; } catch (error) { const message = asError(error).message; this.logger.warn(`Telescope prune step failed: ${message}`); return { deleted: 0, archived: 0, error: message }; } } /** * Exports the doomed entries for an archived single-type scope to the sink in * bounded batches. `proceed` is `true` when it is safe to delete (nothing to * archive, archiving succeeded, or this type/scope is not archived) and * `false` when the sink failed (skip the delete this cycle); `archived` is the * number of entries actually handed to the sink. */ async archiveScope(scope, archivableType) { const archive = this.config.archive; // Only single-type scopes that are in the archive set are exported. The // global bulk scope (excludeTypes) is never archived: archived types always // get their own per-type scope, so they are never part of the bulk delete. if (archive === undefined || archivableType === undefined || !archive.types.has(archivableType)) { return { proceed: true, archived: 0 }; } let archived = 0; try { let batchesDone = 0; let cursor; while (batchesDone < archive.maxBatchesPerCycle) { const page = await this.storage.get({ type: archivableType, before: scope.before, limit: archive.batchSize, ...(cursor !== undefined ? { cursor } : {}), }); if (page.data.length === 0) break; await archive.sink(page.data); archived += page.data.length; batchesDone += 1; if (page.nextCursor === null) break; cursor = page.nextCursor; if (batchesDone === archive.maxBatchesPerCycle && page.nextCursor !== null) { // Backlog exceeds this cycle's cap. Stop here; the remainder is // archived next tick. We deliberately do NOT delete this type now, // because the unarchived remainder is exactly what we must keep — the // delete cutoff would otherwise wipe entries the sink never saw. this.logger.warn(`Telescope archive for type "${archivableType}" hit the per-cycle batch cap ` + `(${archive.maxBatchesPerCycle}); remaining entries deferred to next cycle.`); return { proceed: false, archived }; } } return { proceed: true, archived }; } catch (error) { // Rate-limited to once per cycle (this method runs once per archived type // per cycle). Skip the delete so the doomed entries survive for a retry. this.logger.warn(`Telescope archive sink failed for type "${archivableType}"; ` + `skipping its prune this cycle: ${asError(error).message}`); return { proceed: false, archived }; } } /** * Deletes the scope, preferring BOUNDED BATCHES. * * Order of preference: * 1. `pruneScopedBatch` — a loop of short, individually-committed deletes. * This is the whole point: the unbounded form is a single statement that, * on a large table, holds row locks for as long as it takes to scan the * table, and every other writer on that database waits behind it. Batching * deletes the same rows while giving the locks up between batches. * 2. `pruneScoped` — one unbounded delete, per-type-aware. * 3. the legacy global `prune` — the global cutoff for ALL types, the best a * provider without per-type support can do. Run only for the global scope, * to avoid deleting more than intended on a per-type scope. * * A `keepLast` scope always takes the unbounded path: "keep the newest N of * the doomed rows" is a whole-set property that a bounded batch cannot express * (hence `keepLast` is absent from {@link BoundedPruneScope}). `keepLast` is * off by default, so the common configuration batches. */ async deleteScope(prune, scope, fallbackOlderThan, fallbackKeepLast) { const pruneScopedBatch = this.storage.pruneScopedBatch; if (pruneScopedBatch !== undefined && scope.keepLast === undefined) { return this.deleteScopeInBatches(prune, scope); } if (this.storage.pruneScoped !== undefined) { if (pruneScopedBatch === undefined && !this.warnedUnboundedDelete) { this.warnedUnboundedDelete = true; this.logger.warn('Storage provider does not implement pruneScopedBatch(); prune deletes are ' + 'UNBOUNDED. On a large store one such delete can hold row locks for minutes ' + 'and block every other writer.'); } return this.storage.pruneScoped(scope); } if (!this.warnedNoScopedPrune) { this.warnedNoScopedPrune = true; this.logger.warn('Storage provider does not implement pruneScoped(); per-type retention ' + 'is unavailable. Falling back to the global prune cutoff for all types.'); } // Run the legacy global prune ONLY for the global (non-type) scope, so the // fallback prunes the whole store once at the global cutoff rather than // re-running per overridden type (which the global prune can't scope). if (scope.type === undefined) { return this.storage.prune(fallbackOlderThan, fallbackKeepLast); } return 0; } /** * Drains one scope in bounded deletes: at most `batchSize` rows per statement, * at most `maxBatchesPerCycle` statements, pausing `batchPauseMs` between * them, stopping as soon as the provider says the scope is drained. * * The ceiling is not a nicety. Without it, a table far enough behind turns one * tick into an unbounded loop — the same "one prune runs for an hour" failure * this replaces, only now spelled as a thousand statements instead of one. With * it, every cycle has a known worst case and a backlog drains over several * cycles instead of monopolising one. * * The pause is only ever paid BETWEEN batches, so the healthy case — one batch, * `hasMore: false` — never waits at all. It exists for the unhealthy case, * where a tight delete loop can starve co-tenants of a small instance's IOPS * budget even though no individual statement holds locks for long. */ async deleteScopeInBatches(prune, scope) { // Rebuilt field-by-field rather than spread: `keepLast` must not reach a // bounded scope (it is not in the type, and a spread of a wider object would // smuggle it through at runtime), and `exactOptionalPropertyTypes` forbids // writing `type: undefined`. const bounded = { before: scope.before, limit: prune.batchSize, ...(scope.type !== undefined ? { type: scope.type } : {}), ...(scope.excludeTypes !== undefined ? { excludeTypes: scope.excludeTypes } : {}), }; let deletedTotal = 0; for (let batch = 0; batch < prune.maxBatchesPerCycle; batch += 1) { if (batch > 0 && prune.batchPauseMs > 0) await sleep(prune.batchPauseMs); // Called off `this.storage` so a provider implemented with `this` keeps it. const result = await this.storage.pruneScopedBatch?.(bounded); // Cannot happen — `deleteScope` checked the method exists — but reading it // back off the provider means TypeScript wants the guard, and a provider // that mutates its own methods at runtime gets a clean exit instead of a // TypeError inside the retention path. if (result === undefined) return deletedTotal; deletedTotal += result.deleted; if (!result.hasMore) return deletedTotal; } this.ceilingHitsThisCycle += 1; if (!this.warnedBatchCeiling) { this.warnedBatchCeiling = true; this.logger.warn(`Telescope prune hit its per-cycle batch ceiling (${prune.maxBatchesPerCycle} × ${prune.batchSize} rows); entries older than the retention window remain and will be deleted over the next cycles. Raise prune.maxBatchesPerCycle or prune.batchSize, or shorten prune.intervalMs, if the backlog is not shrinking.`); } return deletedTotal; } }; TelescopePruner = TelescopePruner_1 = __decorate([ Injectable(), __param(0, Inject(TELESCOPE_CONFIG)), __param(1, Inject(TELESCOPE_STORAGE)), __metadata("design:paramtypes", [Object, Object]) ], TelescopePruner); export { TelescopePruner }; /** * Picks the cross-process lock for this deployment, once, at construction: * the host's own implementation if it supplied one, else a lease in the store * Telescope is already writing to, else nothing (prune unlocked, as before). */ function resolvePruneLock(config, storage) { const prune = config.prune; if (prune === undefined || !prune.lockEnabled) return null; if (prune.lock !== undefined) return prune.lock; if (isLeaseCapableStorage(storage)) return new StorageLeasePruneLock(storage); return null; } /** Unref'd so a pending inter-batch pause can never hold a shutting-down process open. */ function sleep(ms) { return new Promise((resolve) => { const timer = setTimeout(resolve, ms); timer.unref?.(); }); } function asError(error) { return error instanceof Error ? error : new Error(String(error)); } //# sourceMappingURL=telescope-pruner.service.js.map