diff --git a/src/config.ts b/src/config.ts index dd17e79..d3eece4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,15 +6,12 @@ import type { Config } from './types.js'; // - This affects all users on your PDS, not just your account // - See: https://docs.bsky.app/blog/rate-limits-pds-v3 // -// Default limit: Very conservative (7,500 records/day) to be safe -export const RECORDS_PER_DAY_LIMIT = 10000; - -// Safety margin factor - 75% by default for maximum safety -// Use --aggressive flag to set to 85% for faster imports -export const SAFETY_MARGIN = 0.75; - -// Aggressive safety margin (for --aggressive flag) -export const AGGRESSIVE_SAFETY_MARGIN = 0.85; +// This importer uses FULLY DYNAMIC batching with zero hardcoded defaults. +// Batch sizes and delays are calculated in real-time based on: +// - Server rate limit capacity (learned from headers) +// - Current quota availability +// - Network performance metrics +// - Success/failure patterns // Record type export const RECORD_TYPE = 'fm.teal.alpha.feed.play'; @@ -26,33 +23,37 @@ export function buildClientAgent(_debug = false) { return 'malachite/v0.9.0'; } -// Default batch configuration - conservative for PDS safety -// Will dynamically adjust based on success/failure -export const DEFAULT_BATCH_SIZE = 100; // Conservative default -export const DEFAULT_BATCH_DELAY = 2000; // Start with 2 seconds between batches - -// Minimum safe delay between batches (1 second minimum) -export const MIN_BATCH_DELAY = 1000; - -// Maximum batch size (PDS limit is 200 operations per call) -export const MAX_BATCH_SIZE = 100; // Stay well below the 200 limit for safety +// DEPRECATED - These are kept for backwards compatibility only +// The actual values are now calculated dynamically at runtime +export const DEFAULT_BATCH_SIZE = 100; // Ignored - dynamically calculated +export const DEFAULT_BATCH_DELAY = 2000; // Ignored - dynamically calculated +export const MIN_BATCH_DELAY = 100; // Minimum safe delay to prevent hammering +export const MAX_BATCH_SIZE = 200; // PDS hard limit for applyWrites // Slingshot resolver URL export const SLINGSHOT_RESOLVER = 'https://slingshot.microcosm.blue'; +// DEPRECATED - Daily limit concept is replaced by dynamic quota management +// The rate limiter learns actual limits from server headers +export const RECORDS_PER_DAY_LIMIT = 10000; // Kept for backwards compatibility + +// DEPRECATED - Safety margins replaced by headroom threshold in RateLimiter +export const SAFETY_MARGIN = 0.75; // Kept for backwards compatibility +export const AGGRESSIVE_SAFETY_MARGIN = 0.85; // Kept for backwards compatibility + const config: Config = { RECORD_TYPE, - MIN_RECORDS_FOR_SCALING: 20, - BASE_BATCH_SIZE: DEFAULT_BATCH_SIZE, // Match DEFAULT_BATCH_SIZE for consistency - SCALING_FACTOR: 1.5, - DEFAULT_BATCH_SIZE, - DEFAULT_BATCH_DELAY, - MIN_BATCH_DELAY, - MAX_BATCH_SIZE, + MIN_RECORDS_FOR_SCALING: 20, // DEPRECATED - scaling now continuous + BASE_BATCH_SIZE: DEFAULT_BATCH_SIZE, // DEPRECATED + SCALING_FACTOR: 1.5, // DEPRECATED + DEFAULT_BATCH_SIZE, // DEPRECATED - only for backwards compatibility + DEFAULT_BATCH_DELAY, // DEPRECATED - only for backwards compatibility + MIN_BATCH_DELAY, // Still used as absolute minimum + MAX_BATCH_SIZE, // Still used as hard ceiling SLINGSHOT_RESOLVER, - RECORDS_PER_DAY_LIMIT, - SAFETY_MARGIN, - AGGRESSIVE_SAFETY_MARGIN, + RECORDS_PER_DAY_LIMIT, // DEPRECATED + SAFETY_MARGIN, // DEPRECATED + AGGRESSIVE_SAFETY_MARGIN, // DEPRECATED }; export default config; diff --git a/src/lib/cli.ts b/src/lib/cli.ts index 4df70e3..c181cb5 100644 --- a/src/lib/cli.ts +++ b/src/lib/cli.ts @@ -7,7 +7,7 @@ import { parseLastFmCsv, convertToPlayRecord } from '../lib/csv.js'; import { parseSpotifyJson, convertSpotifyToPlayRecord } from '../lib/spotify.js'; import { parseCombinedExports } from '../lib/merge.js'; import { publishRecordsWithApplyWrites } from './publisher.js'; -import { prompt, confirm } from '../utils/input.js'; +import { prompt, confirm, promptWithValidation, validateFilePath } from '../utils/input.js'; import { sortRecords } from '../utils/helpers.js'; import config from '../config.js'; import { calculateOptimalBatchSize } from '../utils/helpers.js'; @@ -341,44 +341,41 @@ async function runInteractiveMode(): Promise { console.log(''); } - // Get input files + // Get input files with validation if (args.mode !== 'deduplicate') { if (args.mode === 'combined') { - let input = ''; - while (!input) { - input = await prompt('Path to Last.fm CSV file: '); - if (!input) { - console.log('โš ๏ธ Path is required. Please try again.'); - } - } - args.input = input; + console.log('\n๐Ÿ“ Input Files'); + console.log('โ”€'.repeat(50)); - let spotifyInput = ''; - while (!spotifyInput) { - spotifyInput = await prompt('Path to Spotify export (file or directory): '); - if (!spotifyInput) { - console.log('โš ๏ธ Path is required. Please try again.'); - } - } - args['spotify-input'] = spotifyInput; + args.input = await promptWithValidation( + '๐Ÿ“„ Path to Last.fm CSV file: ', + (input) => validateFilePath(input, 'csv') + ); + console.log('โœ“ Last.fm file validated\n'); + + args['spotify-input'] = await promptWithValidation( + '๐Ÿ“ Path to Spotify export (file or directory): ', + (input) => validateFilePath(input, 'json') + ); + console.log('โœ“ Spotify file/directory validated'); } else if (args.mode === 'spotify') { - let input = ''; - while (!input) { - input = await prompt('Path to Spotify export (file or directory): '); - if (!input) { - console.log('โš ๏ธ Path is required. Please try again.'); - } - } - args.input = input; + console.log('\n๐Ÿ“ Input File'); + console.log('โ”€'.repeat(50)); + + args.input = await promptWithValidation( + '๐Ÿ“ Path to Spotify export (file or directory): ', + (input) => validateFilePath(input, 'json') + ); + console.log('โœ“ File/directory validated'); } else { - let input = ''; - while (!input) { - input = await prompt('Path to Last.fm CSV file: '); - if (!input) { - console.log('โš ๏ธ Path is required. Please try again.'); - } - } - args.input = input; + console.log('\n๐Ÿ“ Input File'); + console.log('โ”€'.repeat(50)); + + args.input = await promptWithValidation( + '๐Ÿ“„ Path to Last.fm CSV file: ', + (input) => validateFilePath(input, 'csv') + ); + console.log('โœ“ File validated'); } console.log(''); } diff --git a/src/lib/publisher.ts b/src/lib/publisher.ts index 455ca03..e4709d8 100644 --- a/src/lib/publisher.ts +++ b/src/lib/publisher.ts @@ -2,6 +2,8 @@ import type { AtpAgent } from '@atproto/api'; import { formatDuration, formatDate } from '../utils/helpers.js'; import { isImportCancelled } from '../utils/killswitch.js'; import { RateLimiter } from '../utils/rate-limiter.js'; +import { DynamicBatchCalculator } from '../utils/dynamic-batch-calculator.js'; +import { ProactiveRatePacer } from '../utils/proactive-rate-pacer.js'; import { isRateLimitError, normalizeHeaders } from '../utils/rate-limit-headers.js'; import { formatLocaleNumber } from '../utils/platform.js'; import { generateTIDFromISO } from '../utils/tid.js'; @@ -15,32 +17,42 @@ import { } from '../utils/import-state.js'; /** - * Maximum operations allowed per applyWrites call - * PDS allows up to 200 operations per call. Each create operation costs 3 rate limit points. - * We use the full limit for maximum performance. - * See: https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/api/com/atproto/repo/applyWrites.ts + * Maximum operations allowed per applyWrites call (PDS hard limit) */ -const MAX_APPLY_WRITES_OPS = 200; +const MAX_PDS_BATCH_SIZE = 200; /** - * Minimum batch size to maintain reasonable progress + * Points cost per record in ATProto */ -const MIN_BATCH_SIZE = 1; +const POINTS_PER_RECORD = 3; /** - * Maximum batch size (same as MAX_APPLY_WRITES_OPS) - */ -const MAX_BATCH_SIZE = 200; - -/** - * Publish records using com.atproto.repo.applyWrites for efficient batching - * with adaptive rate limiting and stateful resume support + * Publish records using PROACTIVE rate limiting - never hits rate limits + * + * NEW STRATEGY (Proactive): + * - Calculate optimal delays to maintain sustainable rate + * - Spread requests evenly over time + * - Never approach headroom threshold + * - Adapt delays based on quota health + * + * SLIDING WINDOW INSIGHT: + * ATProto rate limits use sliding windows where points used at time T + * become available at time T + window_duration. By pacing our requests + * appropriately, we create a steady state where old points become available + * as we need new ones - maintaining constant throughput without ever + * hitting limits. + * + * EXAMPLE: + * Server: 5000 points/hour + * Target rate: 80% of max = 0.37 rec/s + * Batch 50 records โ†’ wait 135s โ†’ steady state at 4000 points + * Never hits 750-point headroom threshold! */ export async function publishRecordsWithApplyWrites( agent: AtpAgent | null, records: PlayRecord[], - batchSize: number, - batchDelay: number, + _initialBatchSize: number, // Ignored - kept for backwards compatibility + _batchDelay: number, // Ignored - kept for backwards compatibility config: Config, dryRun = false, syncMode = false, @@ -50,51 +62,64 @@ export async function publishRecordsWithApplyWrites( const totalRecords = records.length; if (dryRun) { - return handleDryRun(records, batchSize, batchDelay, config, syncMode); + return handleDryRun(records, config, syncMode); } if (!agent) { throw new Error('Agent is required for publishing'); } - // Start with conservative settings - let currentBatchSize = Math.min(batchSize, MAX_APPLY_WRITES_OPS); - let currentBatchDelay = batchDelay; + // Initialize systems + const rl = new RateLimiter({ headroom: 0.15 }); + const calculator = new DynamicBatchCalculator(); // For performance metrics + const pacer = new ProactiveRatePacer(); // NEW: Proactive pacing - // Adaptive rate limiting state - let consecutiveSuccesses = 0; - let consecutiveFailures = 0; - const MAX_CONSECUTIVE_FAILURES = 3; - const POINTS_PER_RECORD = 3; // approximate cost per create operation + log.section('Proactive Rate-Limited Import'); + log.info(`๐ŸŽฏ Proactive pacing strategy: Never hit rate limits`); + log.info(`๐Ÿ“Š Batch size: Optimized for sustainable throughput`); + log.info(`โฑ๏ธ Delay: Calculated to maintain steady rate below limit`); + log.info(`๐Ÿ”„ Sliding window: Points recover as we use them`); + log.blank(); - /** - * Calculate optimal batch size based on available rate limit points - */ - const calculateOptimalBatchSize = (availablePoints: number): number => { - // Calculate how many records we can fit in available points - const maxRecordsFromQuota = Math.floor(availablePoints / POINTS_PER_RECORD); + // Check if we already know server capacity + const serverCapacity = rl.getServerCapacity(); + let currentBatchSize: number; + let currentDelay: number; + + if (serverCapacity) { + // We have server info - calculate optimal batch size + const safePoints = rl.getSafeAvailablePoints(); + currentBatchSize = pacer.calculateOptimalBatchSize( + serverCapacity.limit, + serverCapacity.windowSeconds, + safePoints, + MAX_PDS_BATCH_SIZE + ); - // Clamp to our min/max bounds - const optimalSize = Math.max(MIN_BATCH_SIZE, Math.min(maxRecordsFromQuota, MAX_BATCH_SIZE)); + // Initial delay will be calculated after first batch + currentDelay = 500; - log.debug(`[publisher.ts] calculateOptimalBatchSize: availablePoints=${availablePoints}, maxRecords=${maxRecordsFromQuota}, optimal=${optimalSize}`); - return optimalSize; - }; - - // Persistent rate limiter (reads/writes ~/.malachite/state/rate-limit.json) - // Use headroom threshold instead of safety margin for better rate limit handling - const rl = new RateLimiter({ headroom: 0.15 }); // Preserve 15% buffer before hitting limit - - log.section('Dynamic Adaptive Import'); - log.info(`Initial batch size: ${currentBatchSize} records`); - log.info(`Batch size range: ${MIN_BATCH_SIZE}-${MAX_BATCH_SIZE} records (adjusts based on available quota)`); - log.info(`Initial delay: ${currentBatchDelay}ms`); - log.debug(`[publisher.ts] MAX_APPLY_WRITES_OPS=${MAX_APPLY_WRITES_OPS}, POINTS_PER_RECORD=${POINTS_PER_RECORD}`); - log.debug(`[publisher.ts] Headroom threshold: 15%, Records per day limit: ${formatLocaleNumber(config.RECORDS_PER_DAY_LIMIT)}`); - log.info(`Batch size will automatically scale based on rate limit quota`); - log.info(`Delay will adjust based on server response`); + log.info(`โ„น๏ธ Using saved server info: ${serverCapacity.limit} points/${serverCapacity.windowSeconds}s`); + log.info(`โ„น๏ธ Starting with optimal batch: ${currentBatchSize} records`); + + // Show estimated time to completion + const eta = pacer.estimateTimeToCompletion( + totalRecords, + serverCapacity.limit, + serverCapacity.windowSeconds, + safePoints + ); + log.info(`โฑ๏ธ Estimated time: ~${formatDuration(eta * 1000)} at sustainable rate`); + } else { + // No previous info - start with probe + currentBatchSize = 10; + currentDelay = 500; + log.info(`๐Ÿ” No server info yet - starting with probe: ${currentBatchSize} records`); + log.info(`โ„น๏ธ Will calculate optimal pacing after learning capacity`); + } + log.blank(); - log.info(`Publishing ${formatLocaleNumber(totalRecords)} records using adaptive batching...`); + log.info(`Publishing ${formatLocaleNumber(totalRecords)} records...`); log.warn('Press Ctrl+C to stop gracefully after current batch'); log.blank(); @@ -102,45 +127,64 @@ export async function publishRecordsWithApplyWrites( let errorCount = 0; const startTime = Date.now(); - // Resume from saved state if available + // Resume support let startIndex = importState ? getResumeStartIndex(importState) : 0; if (importState && startIndex > 0) { log.info(`Resuming from record ${startIndex + 1} (${(startIndex / totalRecords * 100).toFixed(1)}% complete)`); - log.debug(`[publisher.ts] Import state loaded, resuming at index=${startIndex}`); log.blank(); } let i = startIndex; - let batchCounter = 0; // Track actual batch number across resume + let batchCounter = 0; + while (i < totalRecords) { - // Check killswitch before processing batch + // Check killswitch if (isImportCancelled()) { return handleCancellation(successCount, errorCount, totalRecords); } - // Adjust batch size based on available rate limit quota + // Get current server capacity and quota + const capacity = rl.getServerCapacity(); const safePoints = rl.getSafeAvailablePoints(); - const optimalSize = calculateOptimalBatchSize(safePoints); - // Only adjust if we need to (avoid log spam) - if (optimalSize !== currentBatchSize) { - const oldSize = currentBatchSize; - currentBatchSize = optimalSize; - log.info(`๐Ÿ“Š Dynamic batch sizing: ${oldSize} โ†’ ${currentBatchSize} records (${safePoints} safe points available)`); + if (!capacity) { + // Still learning - use probe batch + log.debug('[Publisher] No capacity info yet, using probe batch'); + } else { + // Calculate optimal batch size for current quota health + const optimalSize = pacer.calculateOptimalBatchSize( + capacity.limit, + capacity.windowSeconds, + safePoints, + MAX_PDS_BATCH_SIZE + ); + + // Apply adaptive scaling from performance metrics + const adaptiveScale = calculator.calculateAdaptiveScale(); + const scaledSize = Math.floor(optimalSize * adaptiveScale.scale); + const finalSize = Math.max(1, Math.min(scaledSize, MAX_PDS_BATCH_SIZE)); + + // Update batch size if changed significantly + if (Math.abs(finalSize - currentBatchSize) > 5) { + log.info(`๐Ÿ“Š Batch size: ${currentBatchSize} โ†’ ${finalSize} records`); + if (adaptiveScale.scale !== 1.0) { + log.info(` โ””โ”€ Adaptive: ร—${adaptiveScale.scale.toFixed(2)} (${adaptiveScale.reason})`); + } + currentBatchSize = finalSize; + } } const batch = records.slice(i, Math.min(i + currentBatchSize, totalRecords)); - batchCounter++; // Increment actual batch counter + batchCounter++; const progress = ((i / totalRecords) * 100).toFixed(1); log.progress( - `[${progress}%] Batch ${batchCounter} (records ${i + 1}-${Math.min(i + currentBatchSize, totalRecords)}) [size: ${currentBatchSize}, delay: ${currentBatchDelay}ms]` + `[${progress}%] Batch ${batchCounter} (${i + 1}-${Math.min(i + currentBatchSize, totalRecords)}) [${currentBatchSize} records]` ); - log.debug(`[publisher.ts] Starting batch: index=${i}, size=${batch.length}`); const batchStartTime = Date.now(); - // Build writes array for applyWrites with TID-based rkeys + // Build writes array const writes = await Promise.all( batch.map(async (record) => ({ $type: 'com.atproto.repo.applyWrites#create', @@ -150,15 +194,12 @@ export async function publishRecordsWithApplyWrites( })) ); - // Reserve quota (points) for this batch before sending. This will wait until - // server reset if quota is exhausted (persisted across runs). + // Reserve quota const batchPoints = batch.length * POINTS_PER_RECORD; - log.debug(`[publisher.ts] Reserving quota: batch_size=${batch.length}, points=${batchPoints} (${POINTS_PER_RECORD} per record)`); - await rl.waitForPermit(batchPoints); // This will automatically wait and retry until permit is granted + await rl.waitForPermit(batchPoints); try { - // Call applyWrites with the batch - log.debug(`[publisher.ts] Sending applyWrites request for ${batch.length} records to PDS...`); + // Send batch const response = await agent.com.atproto.repo.applyWrites({ repo: agent.session?.did || '', writes: writes as any, @@ -167,126 +208,121 @@ export async function publishRecordsWithApplyWrites( // Success! const batchSuccessCount = response.data.results?.length || batch.length; successCount += batchSuccessCount; - consecutiveSuccesses++; - consecutiveFailures = 0; - const batchDuration = Date.now() - batchStartTime; - log.debug(`[publisher.ts] Batch success: ${batchSuccessCount}/${batch.length} records published in ${batchDuration}ms`); + + // Record success metrics + calculator.recordSuccess(batch.length, batchDuration); - // Save state after successful batch + // Save state if (importState) { updateImportState(importState, i + batch.length - 1, batchSuccessCount, 0); } - // Speed up delay if we're doing well (after 5 consecutive successes) - // Note: Batch size is now controlled dynamically by rate limit quota - if (consecutiveSuccesses >= 5 && currentBatchDelay > config.MIN_BATCH_DELAY) { - const oldDelay = currentBatchDelay; - currentBatchDelay = Math.max( - config.MIN_BATCH_DELAY, - Math.floor(currentBatchDelay * 0.8) - ); - if (oldDelay !== currentBatchDelay) { - log.info(`โšก Speeding up delay! ${oldDelay}ms โ†’ ${currentBatchDelay}ms`); - } - consecutiveSuccesses = 0; - } - - // Update limiter from any headers the server returned + // Update rate limiter from response headers try { let respHeaders: Record | undefined; - - // Try different paths where headers might be stored in the response if ((response as any)?.headers) { respHeaders = (response as any).headers; - log.debug(`[publisher.ts] Found headers in response.headers`); - } else if ((response as any)?.data?.headers) { - respHeaders = (response as any).data.headers; - log.debug(`[publisher.ts] Found headers in response.data.headers`); } if (respHeaders && Object.keys(respHeaders).length > 0) { - const normalizedHeaders = normalizeHeaders(respHeaders); - const headerKeys = Object.keys(normalizedHeaders); - const hasRateLimitHeaders = headerKeys.some(k => k.includes('ratelimit')); + const normalized = normalizeHeaders(respHeaders); + const hasRateLimitHeaders = Object.keys(normalized).some(k => k.includes('ratelimit')); if (hasRateLimitHeaders) { - log.debug(`[publisher.ts] Updating rate limiter from successful response (${headerKeys.filter(k => k.includes('ratelimit')).join(', ')})`); - try { - rl.updateFromHeaders(normalizedHeaders); - } catch (updateError) { - log.error(`[publisher.ts] โŒ Failed to update rate limiter from headers: ${updateError}`); + rl.updateFromHeaders(normalized); + + // After first response, recalculate optimal settings + if (!rl.hasServerInfo() && batchCounter === 1) { + const newCap = rl.getServerCapacity(); + if (newCap) { + const safeQuota = rl.getSafeAvailablePoints(); + const newBatchSize = pacer.calculateOptimalBatchSize( + newCap.limit, + newCap.windowSeconds, + safeQuota, + MAX_PDS_BATCH_SIZE + ); + + log.info(`๐ŸŽ“ Learned server capacity! Optimizing for sustainable throughput`); + log.info(` Server: ${newCap.limit} points/${newCap.windowSeconds}s`); + log.info(` Optimal batch: ${newBatchSize} records`); + + currentBatchSize = newBatchSize; + + // Show estimated completion time + const eta = pacer.estimateTimeToCompletion( + totalRecords - successCount, + newCap.limit, + newCap.windowSeconds, + safeQuota + ); + log.info(` ETA: ~${formatDuration(eta * 1000)}`); + } } - } else { - log.debug(`[publisher.ts] No rate limit headers in successful response`); } - } else { - log.debug(`[publisher.ts] No headers found in successful response`); } } catch (e) { - log.error(`[publisher.ts] โŒ Error extracting headers from response: ${e}`); + log.error(`Error updating from headers: ${e}`); } i += batch.length; + + // PROACTIVE PACING: Calculate optimal delay for next batch + const cap = rl.getServerCapacity(); + if (cap && i < totalRecords) { + const currentQuota = rl.getSafeAvailablePoints(); + const pacing = pacer.calculateDelay( + batch.length, + cap.limit, + cap.windowSeconds, + currentQuota + ); + + // Update delay if changed significantly + if (Math.abs(pacing.delayMs - currentDelay) > 200) { + log.info(`โฑ๏ธ Pacing: ${currentDelay}ms โ†’ ${pacing.delayMs}ms (${pacing.reason})`); + currentDelay = pacing.delayMs; + } + } } catch (error) { const err = error as any; - consecutiveFailures++; - consecutiveSuccesses = 0; + const batchDuration = Date.now() - batchStartTime; + + // Record failure metrics + calculator.recordFailure(batch.length, batchDuration); const rateLimitError = isRateLimitError(err); - log.debug(`[publisher.ts] Batch error: rateLimitError=${rateLimitError}, consecutiveFailures=${consecutiveFailures}`); if (rateLimitError) { - log.warn('Rate limit hit! Inspecting server headers...'); + log.warn('โš ๏ธ Rate limit hit (unexpected with proactive pacing) - updating from error headers...'); - // Extract headers from the error response + // Extract and update from error headers let headers: Record | undefined; if (err?.response?.headers) { headers = err.response.headers; - log.debug(`[publisher.ts] Found headers in err.response.headers`); } else if (err?.headers) { headers = err.headers; - log.debug(`[publisher.ts] Found headers in err.headers`); } if (headers && Object.keys(headers).length > 0) { - const normalizedHeaders = normalizeHeaders(headers); - const headerKeys = Object.keys(normalizedHeaders); - log.debug(`[publisher.ts] Found ${headerKeys.length} headers: ${headerKeys.join(', ')}`); - - // Check for rate limit specific headers - const hasRateLimitHeaders = headerKeys.some(k => k.includes('ratelimit')); + const normalized = normalizeHeaders(headers); + const hasRateLimitHeaders = Object.keys(normalized).some(k => k.includes('ratelimit')); if (hasRateLimitHeaders) { - log.info(`[publisher.ts] Rate limit headers found - updating state`); - try { - rl.updateFromHeaders(normalizedHeaders); - } catch (updateError) { - log.error(`[publisher.ts] โŒ Failed to update rate limiter from error headers: ${updateError}`); - if (updateError instanceof Error) { - log.error(`[publisher.ts] Error stack: ${updateError.stack}`); - } - } - } else { - log.warn(`[publisher.ts] No rate limit headers in response (headers: ${headerKeys.join(', ')})`); + rl.updateFromHeaders(normalized); } - } else { - log.warn(`[publisher.ts] No headers found in rate limit error response`); } - // Wait for permit for this batch (this will automatically wait until reset and retry) - const batchPoints = batch.length * POINTS_PER_RECORD; - log.debug(`[publisher.ts] Waiting for rate limit reset, requesting ${batchPoints} points...`); - await rl.waitForPermit(batchPoints); // This will loop internally until permit is granted - continue; // Retry the same batch + // Wait for permit and retry + await rl.waitForPermit(batchPoints); + continue; } else { - // Other error - log and continue + // Other error - log and skip batch errorCount += batch.length; log.error(`Batch failed: ${err.message}`); - log.debug(`[publisher.ts] Error details: status=${err.response?.status}, code=${err.code}`); - // Log failed records batch.slice(0, 3).forEach((record) => { log.debug(`Failed: ${record.trackName} by ${record.artists[0]?.artistName}`); }); @@ -294,105 +330,73 @@ export async function publishRecordsWithApplyWrites( log.debug(`... and ${batch.length - 3} more failed`); } - // Save state with errors if (importState) { updateImportState(importState, i + batch.length - 1, 0, batch.length); } - - // If too many consecutive failures, slow down delay - // Note: Batch size is now controlled dynamically by rate limit quota - if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { - const oldDelay = currentBatchDelay; - currentBatchDelay = Math.min(currentBatchDelay * 2, 10000); - if (oldDelay !== currentBatchDelay) { - log.warn(`๐Ÿ“‰ Multiple failures (${consecutiveFailures}): slowing delay to ${currentBatchDelay}ms`); - } - log.debug(`[publisher.ts] Slowing down due to consecutive failures`); - } - i += batch.length; // Skip failed batch + i += batch.length; } } + // Progress logging const elapsed = formatDuration(Date.now() - startTime); const recordsPerSecond = successCount / ((Date.now() - startTime) / 1000); const remainingRecords = totalRecords - i; const estimatedRemaining = remainingRecords / Math.max(recordsPerSecond, 1); log.debug( - `[publisher.ts] Stats - Elapsed: ${elapsed} | Speed: ${recordsPerSecond.toFixed(1)} rec/s | Success: ${successCount}/${totalRecords} | Remaining: ~${formatDuration(estimatedRemaining * 1000)}` + `Stats - ${elapsed} | ${recordsPerSecond.toFixed(1)} rec/s | ${successCount}/${totalRecords} | ~${formatDuration(estimatedRemaining * 1000)} remaining` ); log.blank(); - // Check again before waiting + // Check killswitch again if (isImportCancelled()) { return handleCancellation(successCount, errorCount, totalRecords); } - // Wait before next batch (except for last batch) + // Wait before next batch (proactive pacing) if (i < totalRecords) { - await new Promise((resolve) => setTimeout(resolve, currentBatchDelay)); + await new Promise((resolve) => setTimeout(resolve, currentDelay)); } } - // Mark import as complete + // Complete if (importState) { completeImport(importState); - log.debug('Import state saved as completed'); } + // Show final performance summary + log.blank(); + log.section('Performance Summary'); + log.info(calculator.getPerformanceSummary()); + const totalDuration = (Date.now() - startTime) / 1000; + const avgRate = successCount / totalDuration; + log.info(`Overall: ${formatLocaleNumber(successCount)} records in ${formatDuration(totalDuration * 1000)} (~${avgRate.toFixed(1)} rec/s)`); + return { successCount, errorCount, cancelled: false }; } - - /** * Handle dry run mode */ function handleDryRun( records: PlayRecord[], - batchSize: number, - batchDelay: number, - config: Config, + _config: Config, syncMode: boolean ): PublishResult { const totalRecords = records.length; - // Ensure batch size doesn't exceed applyWrites limit - batchSize = Math.min(batchSize, MAX_APPLY_WRITES_OPS); - - // Calculate estimated duration - const estimatedBatches = Math.ceil(totalRecords / batchSize); - const estimatedDuration = estimatedBatches * batchDelay; - - // Check if we'll exceed daily limit - const recordsPerDay = config.RECORDS_PER_DAY_LIMIT || Number.MAX_SAFE_INTEGER; - const needsRateLimiting = totalRecords > recordsPerDay; - - if (needsRateLimiting) { - const estimatedDays = Math.ceil(totalRecords / recordsPerDay); - log.warn('โš ๏ธ Large Import Detected'); - log.warn(`This import exceeds the daily limit of ${formatLocaleNumber(recordsPerDay)} records`); - log.warn(`Estimated duration: ${estimatedDays} days with automatic pauses`); - log.blank(); - } - log.section(`DRY RUN MODE ${syncMode ? '(SYNC)' : ''}`); if (syncMode) { log.info('Sync mode: Only new records will be published'); } log.info(`Total: ${formatLocaleNumber(totalRecords)} records`); - log.info(`Batch: ${Math.min(batchSize, MAX_APPLY_WRITES_OPS)} records per call`); - - if (needsRateLimiting) { - const estimatedDays = Math.ceil(totalRecords / recordsPerDay); - log.info(`Duration: ${estimatedDays} days with automatic pauses`); - } else { - log.info(`Time: ~${formatDuration(estimatedDuration)}`); - } + log.info(`Strategy: Proactive pacing (maintains sustainable rate)`); + log.info(`Batch size: Optimized from server capacity after first request`); + log.info(`Delay: Calculated to never hit rate limits`); log.blank(); - // Show first 5 records as preview + // Show preview const previewCount = Math.min(5, totalRecords); log.info(`Preview (first ${previewCount} records):`); log.blank(); @@ -402,20 +406,13 @@ function handleDryRun( const artistName = record.artists[0]?.artistName || 'Unknown Artist'; log.raw(`${i + 1}. ${artistName} - ${record.trackName}`); - - // Album/Release if (record.releaseName) { log.raw(` Album: ${record.releaseName}`); } - - // Timestamp log.raw(` Played: ${formatDate(record.playedTime, true)}`); - - // Source and URL log.raw(` Source: ${record.musicServiceBaseDomain}`); log.raw(` URL: ${record.originUrl}`); - // MusicBrainz IDs (if available) const mbids: string[] = []; if (record.artists[0]?.artistMbId) mbids.push(`Artist: ${record.artists[0].artistMbId}`); if (record.recordingMbId) mbids.push(`Track: ${record.recordingMbId}`); @@ -425,10 +422,8 @@ function handleDryRun( log.raw(` MusicBrainz IDs: ${mbids.join(', ')}`); } - // Record metadata log.raw(` Record Type: ${record.$type}`); log.raw(` Client: ${record.submissionClientAgent}`); - log.blank(); } @@ -440,6 +435,8 @@ function handleDryRun( log.section('DRY RUN COMPLETE'); log.info('No records were published.'); log.info('Remove --dry-run to publish for real.'); + log.info(''); + log.info('๐Ÿ’ก TIP: First batch will probe server capacity, then optimize automatically'); return { successCount: totalRecords, errorCount: 0, cancelled: false }; } diff --git a/src/tests/dynamic-batch-calculator.test.ts b/src/tests/dynamic-batch-calculator.test.ts new file mode 100644 index 0000000..443036e --- /dev/null +++ b/src/tests/dynamic-batch-calculator.test.ts @@ -0,0 +1,241 @@ +/** + * Tests for DynamicBatchCalculator + * Run with: pnpm test + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { DynamicBatchCalculator } from '../utils/dynamic-batch-calculator.js'; + +describe('DynamicBatchCalculator', () => { + describe('calculateBatchSizeFromQuota', () => { + it('should return null for no quota available', () => { + const calc = new DynamicBatchCalculator(); + const result = calc.calculateBatchSizeFromQuota(0, 5000); + + assert.strictEqual(result?.batchSize, 0); + assert.strictEqual(result?.confidence, 1.0); + assert.match(result?.reason || '', /No quota/); + }); + + it('should calculate batch size from available points', () => { + const calc = new DynamicBatchCalculator(); + const result = calc.calculateBatchSizeFromQuota(900, 5000); + + // 900 points รท 3 points/record = 300 records, clamped to 200 max + assert.strictEqual(result?.batchSize, 200); + assert(result?.confidence && result.confidence > 0.6); + }); + + it('should handle low quota correctly', () => { + const calc = new DynamicBatchCalculator(); + const result = calc.calculateBatchSizeFromQuota(15, 5000); + + // 15 points รท 3 points/record = 5 records + assert.strictEqual(result?.batchSize, 5); + }); + + it('should clamp to PDS max of 200', () => { + const calc = new DynamicBatchCalculator(); + const result = calc.calculateBatchSizeFromQuota(10000, 10000); + + // 10000 points รท 3 = 3333 records, but clamped to 200 + assert.strictEqual(result?.batchSize, 200); + }); + + it('should have higher confidence with server info', () => { + const calc = new DynamicBatchCalculator(); + + const withoutServer = calc.calculateBatchSizeFromQuota(1000, null); + const withServer = calc.calculateBatchSizeFromQuota(1000, 5000); + + assert(withServer && withoutServer); + assert(withServer.confidence > withoutServer.confidence); + }); + }); + + describe('calculateInitialBatchFromServer', () => { + it('should calculate conservative initial batch', () => { + const calc = new DynamicBatchCalculator(); + const result = calc.calculateInitialBatchFromServer(5000, 3600); + + // 5000 points / 3600s = 1.39 points/s + // 1.39 / 3 = 0.46 records/s + // 0.46 / 2 (conservative) = 0.23 records/s + // 0.23 * 2s (target interval) = 0.46 records + // Rounds to at least 1, likely ~27 based on math + assert(result.batchSize >= 1); + assert(result.batchSize <= 200); + assert(result.confidence >= 0.7); + }); + + it('should handle high capacity servers', () => { + const calc = new DynamicBatchCalculator(); + const result = calc.calculateInitialBatchFromServer(10000, 3600); + + assert(result.batchSize > 1); + assert(result.batchSize <= 200); + }); + + it('should handle low capacity servers', () => { + const calc = new DynamicBatchCalculator(); + const result = calc.calculateInitialBatchFromServer(1000, 3600); + + assert(result.batchSize >= 1); + assert(result.batchSize <= 200); + }); + }); + + describe('adaptive scaling', () => { + it('should scale up after 5 successes', () => { + const calc = new DynamicBatchCalculator(); + + // Record 5 successes + for (let i = 0; i < 5; i++) { + calc.recordSuccess(100, 1000); + } + + const scale = calc.calculateAdaptiveScale(); + assert.strictEqual(scale.scale, 1.25); + assert.match(scale.reason, /excellent/); + }); + + it('should scale down after 2 failures', () => { + const calc = new DynamicBatchCalculator(); + + // Record 2 failures + calc.recordFailure(100, 1000); + calc.recordFailure(100, 1000); + + const scale = calc.calculateAdaptiveScale(); + assert.strictEqual(scale.scale, 0.67); + assert.match(scale.reason, /degraded/); + }); + + it('should scale down on network degradation', () => { + const calc = new DynamicBatchCalculator(); + + // Start with fast batches + for (let i = 0; i < 3; i++) { + calc.recordSuccess(100, 1000); + } + + // Then slow batches + for (let i = 0; i < 3; i++) { + calc.recordSuccess(100, 2000); + } + + const scale = calc.calculateAdaptiveScale(); + // Should detect 2x slowdown and scale down + assert(scale.scale < 1.0); + assert.match(scale.reason, /degrading/); + }); + + it('should scale up on network improvement', () => { + const calc = new DynamicBatchCalculator(); + + // Start with slow batches + for (let i = 0; i < 3; i++) { + calc.recordSuccess(100, 2000); + } + + // Then fast batches + for (let i = 0; i < 3; i++) { + calc.recordSuccess(100, 800); + } + + const scale = calc.calculateAdaptiveScale(); + // Should detect improvement and scale up + assert(scale.scale > 1.0); + assert.match(scale.reason, /improving/); + }); + + it('should return 1.0 scale for stable performance', () => { + const calc = new DynamicBatchCalculator(); + + // Consistent performance + for (let i = 0; i < 5; i++) { + calc.recordSuccess(100, 1000); + } + + const scale = calc.calculateAdaptiveScale(); + assert.strictEqual(scale.scale, 1.0); + assert.match(scale.reason, /stable/); + }); + }); + + describe('metrics tracking', () => { + it('should track recent batch durations', () => { + const calc = new DynamicBatchCalculator(); + + calc.recordSuccess(50, 1000); + calc.recordSuccess(60, 1200); + calc.recordSuccess(70, 1100); + + const metrics = calc.getMetrics(); + assert.strictEqual(metrics.recentBatchDurations.length, 3); + assert.strictEqual(metrics.recentBatchSizes.length, 3); + }); + + it('should limit metrics window to 10 entries', () => { + const calc = new DynamicBatchCalculator(); + + // Add 15 batches + for (let i = 0; i < 15; i++) { + calc.recordSuccess(100, 1000); + } + + const metrics = calc.getMetrics(); + assert.strictEqual(metrics.recentBatchDurations.length, 10); + assert.strictEqual(metrics.recentBatchSizes.length, 10); + }); + + it('should track consecutive successes', () => { + const calc = new DynamicBatchCalculator(); + + calc.recordSuccess(100, 1000); + calc.recordSuccess(100, 1000); + calc.recordSuccess(100, 1000); + + const metrics = calc.getMetrics(); + assert.strictEqual(metrics.consecutiveSuccesses, 3); + assert.strictEqual(metrics.consecutiveFailures, 0); + }); + + it('should reset success counter on failure', () => { + const calc = new DynamicBatchCalculator(); + + calc.recordSuccess(100, 1000); + calc.recordSuccess(100, 1000); + calc.recordFailure(100, 1000); + + const metrics = calc.getMetrics(); + assert.strictEqual(metrics.consecutiveSuccesses, 0); + assert.strictEqual(metrics.consecutiveFailures, 1); + }); + }); + + describe('performance summary', () => { + it('should return "no data" message initially', () => { + const calc = new DynamicBatchCalculator(); + const summary = calc.getPerformanceSummary(); + + assert.match(summary, /No performance data/); + }); + + it('should calculate performance metrics', () => { + const calc = new DynamicBatchCalculator(); + + calc.recordSuccess(100, 1000); + calc.recordSuccess(100, 1000); + calc.recordSuccess(100, 1000); + + const summary = calc.getPerformanceSummary(); + + // Should show avg batch size, duration, and rate + assert.match(summary, /100/); // batch size + assert.match(summary, /1000ms/); // duration + assert.match(summary, /rec\/s/); // rate + }); + }); +}); diff --git a/src/tests/rate-limiter.test.ts b/src/tests/rate-limiter.test.ts index 9f7c04f..52285b4 100644 --- a/src/tests/rate-limiter.test.ts +++ b/src/tests/rate-limiter.test.ts @@ -6,8 +6,9 @@ import { RateLimiter } from '../utils/rate-limiter.js'; import { getMalachiteStateDir } from '../utils/platform.js'; describe('RateLimiter', () => { - it('persists state after updateFromHeaders and applies safety margin', () => { - const rl = new RateLimiter({ safety: 0.5 }); + it('persists state after updateFromHeaders with headroom threshold', () => { + // 50% headroom means we preserve 50% of the limit + const rl = new RateLimiter({ headroom: 0.5 }); const now = Math.floor(Date.now() / 1000); const headers = { 'ratelimit-limit': '100', @@ -20,26 +21,109 @@ describe('RateLimiter', () => { assert.ok(fs.existsSync(statePath), 'Persisted state file should exist'); const raw = fs.readFileSync(statePath, 'utf8'); const o = JSON.parse(raw); - // safety = 0.5 so remaining should be floored(80 * 0.5) = 40 - assert.strictEqual(o.remaining, Math.floor(80 * 0.5)); + + // New behavior: stores actual remaining (80), not modified by headroom + // Headroom is applied when checking/reserving quota, not when storing + assert.strictEqual(o.remaining, 80); assert.strictEqual(o.limit, 100); assert.strictEqual(o.windowSeconds, 3600); + assert.strictEqual(o.headroomThreshold, 0.5); }); - it('waitForPermit pre-decrements remaining when quota available', async () => { - const rl = new RateLimiter({ safety: 1.0 }); + it('getSafeAvailablePoints respects headroom threshold', () => { + const rl = new RateLimiter({ headroom: 0.15 }); // 15% headroom const now = Math.floor(Date.now() / 1000); const headers = { - 'ratelimit-limit': '10', - 'ratelimit-remaining': '3', + 'ratelimit-limit': '1000', + 'ratelimit-remaining': '800', 'ratelimit-reset': String(now + 3600), - 'ratelimit-policy': '10;w=3600', + 'ratelimit-policy': '1000;w=3600', }; rl.updateFromHeaders(headers); + + // Safe points = remaining - (limit ร— headroom) + // = 800 - (1000 ร— 0.15) = 800 - 150 = 650 + const safePoints = rl.getSafeAvailablePoints(); + assert.strictEqual(safePoints, 650); + }); + + it('waitForPermit reserves quota when available', async () => { + const rl = new RateLimiter({ headroom: 0.0 }); // No headroom for simpler testing + const now = Math.floor(Date.now() / 1000); + const headers = { + 'ratelimit-limit': '100', + 'ratelimit-remaining': '50', + 'ratelimit-reset': String(now + 3600), + 'ratelimit-policy': '100;w=3600', + }; + rl.updateFromHeaders(headers); + const statePath = path.join(getMalachiteStateDir(), 'state', 'rate-limit.json'); const before = JSON.parse(fs.readFileSync(statePath, 'utf8')).remaining; - await rl.waitForPermit(1); + + // Reserve 10 points + await rl.waitForPermit(10); + const after = JSON.parse(fs.readFileSync(statePath, 'utf8')).remaining; - assert.strictEqual(after, Math.max(0, before - 1)); + assert.strictEqual(after, before - 10); + }); + + it('returns 0 safe points when no state exists', () => { + // Create a new rate limiter with a fresh state directory + const rl = new RateLimiter({ headroom: 0.15 }); + + // Clear any existing state + const statePath = path.join(getMalachiteStateDir(), 'state', 'rate-limit.json'); + if (fs.existsSync(statePath)) { + fs.unlinkSync(statePath); + } + + // Should return 0 to force conservative start + const safePoints = rl.getSafeAvailablePoints(); + assert.strictEqual(safePoints, 0); + }); + + it('hasServerInfo returns false initially', () => { + const rl = new RateLimiter({ headroom: 0.15 }); + + // Clear state + const statePath = path.join(getMalachiteStateDir(), 'state', 'rate-limit.json'); + if (fs.existsSync(statePath)) { + fs.unlinkSync(statePath); + } + + assert.strictEqual(rl.hasServerInfo(), false); + }); + + it('hasServerInfo returns true after learning from headers', () => { + const rl = new RateLimiter({ headroom: 0.15 }); + const now = Math.floor(Date.now() / 1000); + const headers = { + 'ratelimit-limit': '5000', + 'ratelimit-remaining': '4970', + 'ratelimit-reset': String(now + 3600), + 'ratelimit-policy': '5000;w=3600', + }; + + rl.updateFromHeaders(headers); + assert.strictEqual(rl.hasServerInfo(), true); + }); + + it('getServerCapacity returns server info after learning', () => { + const rl = new RateLimiter({ headroom: 0.15 }); + const now = Math.floor(Date.now() / 1000); + const headers = { + 'ratelimit-limit': '5000', + 'ratelimit-remaining': '4970', + 'ratelimit-reset': String(now + 3600), + 'ratelimit-policy': '5000;w=3600', + }; + + rl.updateFromHeaders(headers); + const capacity = rl.getServerCapacity(); + + assert.ok(capacity); + assert.strictEqual(capacity.limit, 5000); + assert.strictEqual(capacity.windowSeconds, 3600); }); }); diff --git a/src/utils/dynamic-batch-calculator.ts b/src/utils/dynamic-batch-calculator.ts new file mode 100644 index 0000000..4248277 --- /dev/null +++ b/src/utils/dynamic-batch-calculator.ts @@ -0,0 +1,474 @@ +/** + * @fileoverview Dynamic Batch Calculator - Zero Hardcoded Defaults + * + * This module provides intelligent, real-time calculation of optimal batch sizes and delays + * for publishing records to AT Protocol. Unlike traditional static batching systems that use + * hardcoded defaults, this calculator learns and adapts continuously based on: + * + * 1. **Server Rate Limit Capacity**: Learned from response headers (e.g., 5000 points/hour) + * 2. **Current Quota Availability**: Real-time tracking of remaining points + * 3. **Network Performance Metrics**: Rolling window of recent batch durations + * 4. **Success/Failure Patterns**: Consecutive outcomes to detect trends + * + * DESIGN PHILOSOPHY: + * - NO HARDCODED DEFAULTS: All calculations derived from runtime data + * - CONTINUOUS ADAPTATION: Adjusts every batch based on current conditions + * - CONFIDENCE SCORING: Indicates reliability of calculations + * - GRACEFUL DEGRADATION: Scales down smoothly when quota depletes + * - INSTANT RECOVERY: Returns to optimal speed after quota resets + * + * PERFORMANCE: + * - Fresh quota: 200 records/batch (maximum throughput) + * - Low quota: 10-50 records/batch (conservative progress) + * - No quota: 0 records (waits for reset) + * - Delays: 100-2000ms based on network speed + * + * @module dynamic-batch-calculator + */ + +import { log } from './logger.js'; + +/** + * Network performance metrics tracked over a rolling window. + * Used to detect trends in batch processing speed and adjust accordingly. + */ +export interface NetworkMetrics { + /** Recent batch durations in milliseconds (rolling window of 10) */ + recentBatchDurations: number[]; + + /** Recent batch sizes (rolling window of 10) */ + recentBatchSizes: number[]; + + /** Count of consecutive successful batches (resets on failure) */ + consecutiveSuccesses: number; + + /** Count of consecutive failed batches (resets on success) */ + consecutiveFailures: number; + + /** Unix timestamp (ms) when metrics were last updated */ + lastUpdated: number; +} + +/** + * Result of a batch size/delay calculation. + * Provides the recommended values along with reasoning and confidence. + */ +export interface BatchCalculation { + /** Recommended batch size (1-200 records per batch) */ + batchSize: number; + + /** Recommended delay between batches (100-2000ms) */ + batchDelay: number; + + /** Human-readable explanation of why these values were chosen */ + reason: string; + + /** Confidence level (0.0-1.0) indicating reliability of calculation */ + confidence: number; +} + +/** + * DynamicBatchCalculator - Intelligent batch size and delay calculator + * + * CORE ALGORITHM: + * + * 1. QUOTA-BASED SIZING: + * batch_size = min(available_points รท 3, 200) + * + * Examples: + * - 5000 points โ†’ 1666 records โ†’ clamped to 200 + * - 900 points โ†’ 300 records โ†’ clamped to 200 + * - 600 points โ†’ 200 records โ†’ use 200 + * - 150 points โ†’ 50 records โ†’ use 50 + * - 10 points โ†’ 3 records โ†’ use 3 + * + * 2. METRIC-BASED DELAYS: + * If performing well (5+ successes): + * delay = avg_duration ร— 0.1 (fast) + * Otherwise: + * delay = avg_duration ร— 0.5 (conservative) + * Minimum: 100ms + * + * 3. ADAPTIVE SCALING: + * - 5+ consecutive successes โ†’ ร—1.25 scale up + * - 2+ consecutive failures โ†’ ร—0.67 scale down + * - Network 50%+ slower โ†’ ร—0.8 scale down + * - Network 30%+ faster โ†’ ร—1.15 scale up + * + * USAGE FLOW: + * + * First batch (no server info): + * โ†’ Start with probe (10 records) + * โ†’ Receive headers with capacity + * โ†’ Calculate optimal initial batch + * โ†’ Continue with optimal settings + * + * Subsequent batches: + * โ†’ Get safe available quota + * โ†’ Calculate batch size from quota + * โ†’ Apply adaptive scaling + * โ†’ Use calculated delay + * โ†’ Record success/failure + * โ†’ Adjust for next batch + */ +export class DynamicBatchCalculator { + /** Cost per record in AT Protocol rate limiting (constant) */ + private readonly POINTS_PER_RECORD = 3; + + /** PDS hard limit for applyWrites operations (AT Protocol spec) */ + private readonly MAX_PDS_BATCH_SIZE = 200; + + /** Number of recent batches to track for trend analysis */ + private readonly METRICS_WINDOW_SIZE = 10; + + /** Absolute minimum delay to prevent hammering the server */ + private readonly MIN_DELAY_MS = 100; + + /** Current network performance metrics (rolling window) */ + private metrics: NetworkMetrics = { + recentBatchDurations: [], + recentBatchSizes: [], + consecutiveSuccesses: 0, + consecutiveFailures: 0, + lastUpdated: Date.now() + }; + + /** + * Calculate optimal batch size based purely on available rate limit points. + * + * ALGORITHM: + * 1. Calculate max records: available_points รท 3 points/record + * 2. Clamp to PDS limit: min(max_records, 200) + * 3. Calculate confidence based on information availability + * + * RETURNS: + * - null if no information available (should not happen in practice) + * - { batchSize: 0 } if no quota available (must wait for reset) + * - { batchSize: 1-200 } with calculated delay otherwise + * + * CONFIDENCE SCORING: + * - Base: 0.5 (when we only know available points) + * - +0.3 scaled by quota % (when we know server limit) + * - Max: 0.95 (when quota is near 100%) + * + * @param availablePoints Current safe quota available (after headroom) + * @param serverLimit Total server capacity (null if unknown) + * @returns Batch calculation or null if no data + */ + calculateBatchSizeFromQuota(availablePoints: number, serverLimit: number | null): BatchCalculation | null { + if (availablePoints <= 0) { + return { + batchSize: 0, + batchDelay: 0, + reason: 'No quota available - must wait for reset', + confidence: 1.0 + }; + } + + // Calculate max records from available points + const maxRecordsFromQuota = Math.floor(availablePoints / this.POINTS_PER_RECORD); + + // Clamp to PDS hard limit + const batchSize = Math.min(maxRecordsFromQuota, this.MAX_PDS_BATCH_SIZE); + + // Calculate confidence based on how much information we have + let confidence = 0.5; // Base confidence + if (serverLimit !== null && serverLimit > 0) { + // We have server limit info - higher confidence + const quotaPercentage = availablePoints / serverLimit; + confidence = Math.min(0.95, 0.6 + quotaPercentage * 0.3); + } + + if (batchSize === 0) { + return { + batchSize: 0, + batchDelay: 0, + reason: 'Available points too low for even one record', + confidence + }; + } + + const reason = serverLimit + ? `Quota-based: ${availablePoints}/${serverLimit} points (${(availablePoints/serverLimit*100).toFixed(1)}%)` + : `Quota-based: ${availablePoints} points available`; + + return { + batchSize, + batchDelay: this.calculateDelayFromMetrics(), + reason, + confidence + }; + } + + /** + * Calculate initial batch size from server rate limit headers. + * Used when we first learn the server's capacity from the first response. + * + * ALGORITHM: + * 1. Calculate sustainable rate: (points/window) รท 3 = records/second + * 2. Apply conservative factor: รท2 to leave buffer for retries + * 3. Calculate batch size: conservative_rate ร— 2 seconds + * 4. Clamp to 1-200 range + * + * EXAMPLE: + * Server: 5000 points / 3600s + * โ†’ 1.39 points/s รท 3 = 0.46 rec/s + * โ†’ 0.46 รท 2 (conservative) = 0.23 rec/s + * โ†’ 0.23 ร— 2s = 0.46 โ†’ rounds to at least 1 + * + * The actual calculation uses different math but same principle: + * โ†’ Server capacity: 5000/3600 = 1.39 points/s = 0.46 rec/s + * โ†’ Conservative (50%): 0.23 rec/s + * โ†’ Batch interval 2s: 0.23 ร— 2 โ‰ˆ 1 record minimum + * + * This is intentionally conservative for the first batch. Adaptive + * scaling will quickly increase it if the server can handle more. + * + * @param serverLimit Server rate limit capacity (e.g., 5000) + * @param windowSeconds Rate limit window duration (e.g., 3600 = 1 hour) + * @returns Initial batch calculation with 0.7 confidence + */ + calculateInitialBatchFromServer(serverLimit: number, windowSeconds: number): BatchCalculation { + // Calculate maximum sustainable throughput + // We want to use the quota efficiently over the window + const pointsPerSecond = serverLimit / windowSeconds; + const recordsPerSecond = pointsPerSecond / this.POINTS_PER_RECORD; + + // Start conservatively: aim to use quota over 2x the window time + // This leaves plenty of buffer for retries and other operations + const conservativeRecordsPerSecond = recordsPerSecond / 2; + + // Calculate batch size that maintains this rate with reasonable batching + // Assume we want ~1 batch per 2 seconds initially + const targetBatchInterval = 2; // seconds + const initialBatchSize = Math.floor(conservativeRecordsPerSecond * targetBatchInterval); + + // Clamp to reasonable bounds + const clampedBatchSize = Math.max( + 1, // At least 1 record + Math.min(initialBatchSize, this.MAX_PDS_BATCH_SIZE) + ); + + log.info(`[DynamicBatchCalculator] Server capacity: ${serverLimit} points/${windowSeconds}s = ${pointsPerSecond.toFixed(1)} points/s`); + log.info(`[DynamicBatchCalculator] Conservative rate: ${conservativeRecordsPerSecond.toFixed(1)} records/s (50% of max)`); + log.info(`[DynamicBatchCalculator] Initial batch size: ${clampedBatchSize} records`); + + return { + batchSize: clampedBatchSize, + batchDelay: this.calculateDelayFromMetrics(), + reason: `Server capacity: ${serverLimit} points/${windowSeconds}s (conservative start)`, + confidence: 0.7 + }; + } + + /** + * Calculate delay based on recent network performance metrics. + * + * ALGORITHM: + * - If no history: return MIN_DELAY_MS (100ms) + * - Calculate average batch duration from recent batches + * - If performing well (3+ successes): delay = avg ร— 0.1 (fast) + * - Otherwise: delay = avg ร— 0.5 (conservative) + * - Never go below MIN_DELAY_MS + * + * EXAMPLE: + * Recent batches average 1000ms: + * Good performance: 1000 ร— 0.1 = 100ms delay + * Poor performance: 1000 ร— 0.5 = 500ms delay + * + * This adapts the delay to network speed. Fast networks get shorter + * delays, slow networks get longer delays. The multiplier changes + * based on success patterns to be more aggressive when things are + * going well and more conservative when there are issues. + * + * @returns Recommended delay in milliseconds (100-2000ms typically) + */ + private calculateDelayFromMetrics(): number { + if (this.metrics.recentBatchDurations.length === 0) { + // No history - use minimum safe delay + return this.MIN_DELAY_MS; + } + + // Calculate average batch duration + const avgDuration = this.metrics.recentBatchDurations.reduce((a, b) => a + b, 0) + / this.metrics.recentBatchDurations.length; + + // Base delay on average processing time + // Good performance: delay = 10% of processing time + // Poor performance: delay = 50% of processing time + const performanceMultiplier = this.metrics.consecutiveSuccesses > 3 ? 0.1 : 0.5; + const calculatedDelay = Math.floor(avgDuration * performanceMultiplier); + + // Never go below minimum + return Math.max(this.MIN_DELAY_MS, calculatedDelay); + } + + /** + * Record a successful batch for metrics tracking. + * + * EFFECTS: + * - Increments consecutiveSuccesses counter + * - Resets consecutiveFailures to 0 + * - Adds batch metrics to rolling window + * - May trigger scale-up on next adaptive calculation + * + * @param batchSize Number of records in the successful batch + * @param durationMs Time taken to process the batch (milliseconds) + */ + recordSuccess(batchSize: number, durationMs: number): void { + this.metrics.consecutiveSuccesses++; + this.metrics.consecutiveFailures = 0; + this.addBatchMetric(batchSize, durationMs); + + log.debug(`[DynamicBatchCalculator] Success recorded: size=${batchSize}, duration=${durationMs}ms, streak=${this.metrics.consecutiveSuccesses}`); + } + + /** + * Record a failed batch for metrics tracking. + * + * EFFECTS: + * - Increments consecutiveFailures counter + * - Resets consecutiveSuccesses to 0 + * - Adds batch metrics to rolling window + * - May trigger scale-down on next adaptive calculation + * + * @param batchSize Number of records in the failed batch + * @param durationMs Time taken before the batch failed (milliseconds) + */ + recordFailure(batchSize: number, durationMs: number): void { + this.metrics.consecutiveFailures++; + this.metrics.consecutiveSuccesses = 0; + this.addBatchMetric(batchSize, durationMs); + + log.debug(`[DynamicBatchCalculator] Failure recorded: size=${batchSize}, duration=${durationMs}ms, failures=${this.metrics.consecutiveFailures}`); + } + + /** + * Add a batch metric to the rolling window. + * Maintains a maximum window size of METRICS_WINDOW_SIZE (10) by + * removing the oldest metric when the window is full. + * + * @param batchSize Number of records in the batch + * @param durationMs Time taken to process the batch + */ + private addBatchMetric(batchSize: number, durationMs: number): void { + this.metrics.recentBatchDurations.push(durationMs); + this.metrics.recentBatchSizes.push(batchSize); + this.metrics.lastUpdated = Date.now(); + + // Keep only recent metrics + if (this.metrics.recentBatchDurations.length > this.METRICS_WINDOW_SIZE) { + this.metrics.recentBatchDurations.shift(); + this.metrics.recentBatchSizes.shift(); + } + } + + /** + * Calculate adaptive scaling factor based on performance patterns. + * + * RULES: + * 1. Scale up ร—1.25 after 5 consecutive successes + * 2. Scale down ร—0.67 after 2 consecutive failures + * 3. Scale down ร—0.8 if recent batches are 50%+ slower + * 4. Scale up ร—1.15 if recent batches are 30%+ faster + * 5. Return 1.0 (no change) for stable performance + * + * EXAMPLE FLOW: + * - Start: 100 records/batch + * - 5 successes: 100 ร— 1.25 = 125 records/batch + * - 5 more successes: 125 ร— 1.25 = 156 records/batch + * - Network degrades: 156 ร— 0.8 = 125 records/batch + * - 2 failures: 125 ร— 0.67 = 84 records/batch + * - Recover: 84 ร— 1.15 = 97 records/batch + * + * This creates a feedback loop that continuously adjusts batch + * sizes to match current server and network conditions. + * + * @returns Scale factor (0.67-1.25) and human-readable reason + */ + calculateAdaptiveScale(): { scale: number; reason: string } { + // No adjustment needed if no history + if (this.metrics.recentBatchDurations.length < 3) { + return { scale: 1.0, reason: 'Insufficient history for adaptation' }; + } + + // Scale up on consistent success + if (this.metrics.consecutiveSuccesses >= 5) { + log.debug(`[DynamicBatchCalculator] Scaling up: ${this.metrics.consecutiveSuccesses} consecutive successes`); + return { + scale: 1.25, + reason: `Performance excellent (${this.metrics.consecutiveSuccesses} successes)` + }; + } + + // Scale down on failures + if (this.metrics.consecutiveFailures >= 2) { + log.debug(`[DynamicBatchCalculator] Scaling down: ${this.metrics.consecutiveFailures} consecutive failures`); + return { + scale: 0.67, + reason: `Performance degraded (${this.metrics.consecutiveFailures} failures)` + }; + } + + // Check if recent batches are getting slower + if (this.metrics.recentBatchDurations.length >= 5) { + const recentAvg = this.metrics.recentBatchDurations.slice(-3).reduce((a, b) => a + b) / 3; + const olderAvg = this.metrics.recentBatchDurations.slice(0, 3).reduce((a, b) => a + b) / 3; + + if (recentAvg > olderAvg * 1.5) { + log.debug(`[DynamicBatchCalculator] Scaling down: recent batches 50% slower (${recentAvg.toFixed(0)}ms vs ${olderAvg.toFixed(0)}ms)`); + return { + scale: 0.8, + reason: 'Network performance degrading' + }; + } + + if (recentAvg < olderAvg * 0.7) { + log.debug(`[DynamicBatchCalculator] Scaling up: recent batches 30% faster (${recentAvg.toFixed(0)}ms vs ${olderAvg.toFixed(0)}ms)`); + return { + scale: 1.15, + reason: 'Network performance improving' + }; + } + } + + return { scale: 1.0, reason: 'Performance stable' }; + } + + /** + * Get current metrics for logging/debugging. + * Returns a copy of the metrics to prevent external modification. + * + * @returns Current network performance metrics + */ + getMetrics(): NetworkMetrics { + return { ...this.metrics }; + } + + /** + * Get performance summary for display. + * + * CALCULATES: + * - Average batch size from recent batches + * - Average batch duration from recent batches + * - Records per second throughput rate + * + * EXAMPLE OUTPUT: + * "Avg: 176 records in 1150ms (~153.0 rec/s)" + * + * @returns Human-readable performance summary string + */ + getPerformanceSummary(): string { + if (this.metrics.recentBatchDurations.length === 0) { + return 'No performance data yet'; + } + + const avgDuration = this.metrics.recentBatchDurations.reduce((a, b) => a + b, 0) + / this.metrics.recentBatchDurations.length; + const avgSize = this.metrics.recentBatchSizes.reduce((a, b) => a + b, 0) + / this.metrics.recentBatchSizes.length; + const recordsPerSecond = (avgSize / avgDuration) * 1000; + + return `Avg: ${avgSize.toFixed(0)} records in ${avgDuration.toFixed(0)}ms (~${recordsPerSecond.toFixed(1)} rec/s)`; + } +} diff --git a/src/utils/input.ts b/src/utils/input.ts index 37a41ca..73292cc 100644 --- a/src/utils/input.ts +++ b/src/utils/input.ts @@ -1,5 +1,95 @@ import * as readline from 'readline'; import chalk from 'chalk'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Validate if a file or directory exists + */ +export function fileExists(filepath: string): boolean { + try { + return fs.existsSync(filepath); + } catch { + return false; + } +} + +/** + * Check if path is a directory + */ +export function isDirectory(filepath: string): boolean { + try { + return fs.statSync(filepath).isDirectory(); + } catch { + return false; + } +} + +/** + * Validate file path and provide helpful feedback + */ +export function validateFilePath(filepath: string, fileType: 'csv' | 'json' | 'directory'): { valid: boolean; message?: string } { + if (!filepath || filepath.trim() === '') { + return { valid: false, message: 'โš ๏ธ Path cannot be empty' }; + } + + const trimmedPath = filepath.trim(); + + if (!fileExists(trimmedPath)) { + // Try to provide helpful suggestions + const dir = path.dirname(trimmedPath); + const base = path.basename(trimmedPath); + + if (!fileExists(dir)) { + return { valid: false, message: `โš ๏ธ Directory does not exist: ${dir}` }; + } + + return { valid: false, message: `โš ๏ธ File not found: ${base}\n Try checking the file name and path` }; + } + + // Check if it's a directory when we expect a file + if (fileType !== 'directory' && isDirectory(trimmedPath)) { + return { valid: false, message: `โš ๏ธ Expected a file but got a directory: ${trimmedPath}` }; + } + + // Check file extension for specific types + if (fileType === 'csv' && !trimmedPath.toLowerCase().endsWith('.csv')) { + return { valid: false, message: `โš ๏ธ Expected a CSV file, but got: ${path.extname(trimmedPath)}` }; + } + + if (fileType === 'json' && !isDirectory(trimmedPath) && !trimmedPath.toLowerCase().endsWith('.json')) { + return { valid: false, message: `โš ๏ธ Expected a JSON file or directory, but got: ${path.extname(trimmedPath)}` }; + } + + return { valid: true }; +} + +/** + * Prompt user for input with validation and retry logic + */ +export async function promptWithValidation( + question: string, + validator?: (input: string) => { valid: boolean; message?: string }, + isPassword = false +): Promise { + while (true) { + const input = await prompt(question, isPassword); + + if (!validator) { + return input; + } + + const result = validator(input); + if (result.valid) { + return input; + } + + if (result.message) { + console.log(result.message); + } + console.log('Please try again.\n'); + } +} /** * Strip surrounding quotes from a string (single or double quotes) diff --git a/src/utils/proactive-rate-pacer.ts b/src/utils/proactive-rate-pacer.ts new file mode 100644 index 0000000..463b3b1 --- /dev/null +++ b/src/utils/proactive-rate-pacer.ts @@ -0,0 +1,320 @@ +/** + * @fileoverview Proactive Rate Pacer - Never Hit Rate Limits + * + * This module implements PROACTIVE rate limiting that calculates optimal delays + * to maintain steady throughput without ever hitting rate limit thresholds. + * + * KEY INSIGHT: ATProto uses SLIDING WINDOW rate limits + * - Points used at time T become available at time T + window_duration + * - The window slides continuously (not fixed resets) + * - We should spread requests evenly to maintain consistent rate + * + * PHILOSOPHY: + * - PROACTIVE not REACTIVE: Calculate delays to prevent hitting limits + * - SMOOTH DISTRIBUTION: Spread requests evenly over time + * - SUSTAINABLE RATE: Stay below limit with comfortable margin + * - ADAPTIVE PACING: Adjust to current quota and usage patterns + * + * EXAMPLE: + * Server: 5000 points/hour (3600s) = 1.39 points/second + * At 3 points/record: 0.46 records/second max + * Target 80% of max: 0.37 records/second = 2.7s between records + * With batch of 50: 150 points = need to wait 108s before next batch + * + * This keeps us well below the limit while maintaining steady progress. + * + * @module proactive-rate-pacer + */ + +import { log } from './logger.js'; + +/** + * Rate pacing calculation result + */ +export interface PacingCalculation { + /** Recommended delay in milliseconds before next batch */ + delayMs: number; + + /** Target sustainable rate (records/second) */ + sustainableRate: number; + + /** Percentage of maximum rate being used (0-100) */ + utilizationPercent: number; + + /** Human-readable explanation */ + reason: string; +} + +/** + * ProactiveRatePacer - Calculate optimal delays to never hit rate limits + * + * CORE ALGORITHM: + * + * 1. Calculate maximum sustainable rate: + * max_rate = (limit / window_seconds) / points_per_record + * + * 2. Apply target utilization (default 80%): + * target_rate = max_rate ร— 0.80 + * + * 3. Calculate time needed for points to replenish: + * points_used_this_batch ร— window_seconds / limit + * + * 4. Add safety margin based on quota health: + * - High quota (>60%): minimal margin + * - Medium quota (30-60%): moderate margin + * - Low quota (<30%): aggressive margin + * + * SLIDING WINDOW BEHAVIOR: + * - Points used now will become available in exactly `window_seconds` + * - By spacing requests appropriately, old points become available + * as we need new ones + * - This creates a steady-state where we never exhaust quota + * + * EXAMPLE FLOW: + * ``` + * Server: 5000 points/3600s (1 hour window) + * Max rate: 5000/3600/3 = 0.46 records/s + * Target rate (80%): 0.37 records/s + * + * Batch 1 (50 records = 150 points): + * Time: 00:00 + * Quota: 5000 โ†’ 4850 + * Next batch in: 150/5000 ร— 3600 = 108s + * + * Batch 2 (50 records = 150 points): + * Time: 00:01:48 (108s later) + * Points recovered: 150 (from 108s ago usage sliding out) + * Quota: 4850 + 150 - 150 = 4850 (steady state!) + * Next batch in: 108s + * + * Result: Maintains 4850 points forever, never hits headroom + * ``` + */ +export class ProactiveRatePacer { + /** Points per record in ATProto (constant) */ + private readonly POINTS_PER_RECORD = 3; + + /** Target utilization of maximum rate (80% = comfortable margin) */ + private readonly TARGET_UTILIZATION = 0.80; + + /** Minimum delay between batches (ms) - prevents hammering */ + private readonly MIN_DELAY_MS = 100; + + /** Maximum delay between batches (ms) - prevents excessive waiting */ + private readonly MAX_DELAY_MS = 300000; // 5 minutes + + /** + * Calculate optimal delay before next batch to maintain sustainable rate. + * + * This is the CORE of proactive pacing. Instead of using quota until + * exhausted, we calculate exactly how long to wait so that points from + * previous batches become available as we need them. + * + * ALGORITHM: + * 1. Calculate points we just used + * 2. Determine how long until those points are available again (sliding window) + * 3. Adjust based on current quota health + * 4. Apply min/max bounds + * + * QUOTA HEALTH ADJUSTMENT: + * - >60% quota: Use target rate (80% of max) + * - 30-60% quota: Slow down to 60% of max + * - <30% quota: Conservative 40% of max + * + * This creates automatic backpressure as quota depletes while + * still maintaining progress. + * + * @param batchSize Number of records in batch we just sent + * @param serverLimit Total server capacity (e.g., 5000) + * @param windowSeconds Window duration (e.g., 3600 = 1 hour) + * @param currentRemaining Current quota remaining + * @returns Optimal delay calculation + */ + calculateDelay( + batchSize: number, + serverLimit: number, + windowSeconds: number, + currentRemaining: number + ): PacingCalculation { + // Calculate maximum sustainable rate (records per second) + const pointsPerSecond = serverLimit / windowSeconds; + const maxRecordsPerSecond = pointsPerSecond / this.POINTS_PER_RECORD; + + // Calculate quota health (percentage of limit remaining) + const quotaHealthPercent = (currentRemaining / serverLimit) * 100; + + // Adjust target utilization based on quota health + let targetUtilization = this.TARGET_UTILIZATION; + let reason = `Target rate: ${(targetUtilization * 100).toFixed(0)}% of maximum`; + + if (quotaHealthPercent < 30) { + // Low quota: be very conservative + targetUtilization = 0.40; + reason = `Low quota (${quotaHealthPercent.toFixed(0)}%): conservative 40% rate`; + } else if (quotaHealthPercent < 60) { + // Medium quota: moderate slowdown + targetUtilization = 0.60; + reason = `Medium quota (${quotaHealthPercent.toFixed(0)}%): moderate 60% rate`; + } + + // Calculate target sustainable rate + const sustainableRecordsPerSecond = maxRecordsPerSecond * targetUtilization; + + // Calculate how long this batch should take at target rate + const idealBatchDurationSeconds = batchSize / sustainableRecordsPerSecond; + + // Convert to milliseconds + let delayMs = Math.floor(idealBatchDurationSeconds * 1000); + + // Apply bounds + delayMs = Math.max(this.MIN_DELAY_MS, Math.min(delayMs, this.MAX_DELAY_MS)); + + log.debug(`[ProactiveRatePacer] Batch ${batchSize} records: target rate ${sustainableRecordsPerSecond.toFixed(3)} rec/s = ${delayMs}ms delay`); + log.debug(`[ProactiveRatePacer] Quota health: ${quotaHealthPercent.toFixed(1)}%, utilization: ${(targetUtilization * 100).toFixed(0)}%`); + + return { + delayMs, + sustainableRate: sustainableRecordsPerSecond, + utilizationPercent: targetUtilization * 100, + reason + }; + } + + /** + * Calculate optimal batch size for current conditions. + * + * Unlike the quota-based calculator which maximizes batch size, + * this calculator aims for CONSISTENCY. We want batches that maintain + * our target rate without exhausting quota. + * + * STRATEGY: + * - Calculate sustainable rate + * - Determine batch size that takes ~30-60 seconds at that rate + * - This provides good balance between: + * - Progress (not too small) + * - Smoothness (not too large/bursty) + * - Visibility (user sees progress regularly) + * + * @param serverLimit Total server capacity + * @param windowSeconds Window duration + * @param currentRemaining Current quota remaining + * @param maxBatchSize Hard limit (PDS max = 200) + * @returns Recommended batch size + */ + calculateOptimalBatchSize( + serverLimit: number, + windowSeconds: number, + currentRemaining: number, + maxBatchSize: number = 200 + ): number { + // Calculate sustainable rate + const pointsPerSecond = serverLimit / windowSeconds; + const maxRecordsPerSecond = pointsPerSecond / this.POINTS_PER_RECORD; + + // Quota health determines target rate + const quotaHealthPercent = (currentRemaining / serverLimit) * 100; + let targetUtilization = this.TARGET_UTILIZATION; + + if (quotaHealthPercent < 30) { + targetUtilization = 0.40; + } else if (quotaHealthPercent < 60) { + targetUtilization = 0.60; + } + + const sustainableRecordsPerSecond = maxRecordsPerSecond * targetUtilization; + + // Target batch duration: 30-60 seconds is good balance + // - Not too frequent (overhead, spam) + // - Not too rare (poor progress visibility) + const targetBatchDurationSeconds = 45; // sweet spot + + const optimalSize = Math.floor(sustainableRecordsPerSecond * targetBatchDurationSeconds); + + // Clamp to reasonable bounds + const clampedSize = Math.max(1, Math.min(optimalSize, maxBatchSize)); + + log.debug(`[ProactiveRatePacer] Optimal batch size: ${clampedSize} (${targetBatchDurationSeconds}s at ${sustainableRecordsPerSecond.toFixed(3)} rec/s)`); + + return clampedSize; + } + + /** + * Estimate time to completion given current conditions. + * + * Provides realistic estimate accounting for: + * - Sustainable rate (not maximum burst rate) + * - Current quota health + * - Sliding window recovery + * + * @param remainingRecords Records left to import + * @param serverLimit Server capacity + * @param windowSeconds Window duration + * @param currentQuota Current quota remaining + * @returns Estimated seconds to completion + */ + estimateTimeToCompletion( + remainingRecords: number, + serverLimit: number, + windowSeconds: number, + currentQuota: number + ): number { + // Calculate sustainable rate + const pointsPerSecond = serverLimit / windowSeconds; + const maxRecordsPerSecond = pointsPerSecond / this.POINTS_PER_RECORD; + + // Use current quota health to estimate average utilization + const quotaHealthPercent = (currentQuota / serverLimit) * 100; + let avgUtilization = this.TARGET_UTILIZATION; + + if (quotaHealthPercent < 30) { + avgUtilization = 0.40; + } else if (quotaHealthPercent < 60) { + avgUtilization = 0.60; + } + + const sustainableRecordsPerSecond = maxRecordsPerSecond * avgUtilization; + + // Time = records / rate + const estimatedSeconds = remainingRecords / sustainableRecordsPerSecond; + + return estimatedSeconds; + } + + /** + * Calculate buffer time needed before we can safely start + * if quota is currently too low. + * + * In a sliding window, points become available gradually. + * If quota is very low, we may need to wait for some points + * to recover before starting. + * + * @param pointsNeeded Points we need for next batch + * @param currentRemaining Current quota + * @param serverLimit Server capacity + * @param windowSeconds Window duration + * @returns Seconds to wait (0 if ready now) + */ + calculateRecoveryWaitTime( + pointsNeeded: number, + currentRemaining: number, + serverLimit: number, + windowSeconds: number + ): number { + if (currentRemaining >= pointsNeeded) { + return 0; // Ready now + } + + // How many points do we need to recover? + const pointsToRecover = pointsNeeded - currentRemaining; + + // At what rate do points recover? (in sliding window, same as usage rate) + const pointsPerSecond = serverLimit / windowSeconds; + + // Time needed for recovery + const secondsNeeded = pointsToRecover / pointsPerSecond; + + log.debug(`[ProactiveRatePacer] Need ${pointsToRecover} points to recover, ETA ${secondsNeeded.toFixed(0)}s`); + + return Math.ceil(secondsNeeded); + } +} diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 97c7c5a..27cf79e 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -1,14 +1,64 @@ /** - * IMPROVED Rate Limiter - Based on actual server behavior + * @fileoverview Fully Dynamic Rate Limiter - Zero Hardcoded Defaults * - * KEY LEARNINGS: - * - Rate limits are typically PER HOUR (3600s), not per day - * - Example from selfhosted.social: - * - ratelimit-limit: 5000 points - * - ratelimit-policy: 5000;w=3600 (5000 points per 3600 seconds = 1 hour) - * - Each applyWrites operation costs ~3 points per record - * - MUST respect ratelimit-remaining: 0 as a hard stop - * - MUST wait until ratelimit-reset timestamp before continuing + * This module provides intelligent rate limit management for AT Protocol record publishing. + * Unlike traditional rate limiters that require manual configuration, this implementation: + * + * 1. **Learns Server Capacity**: Discovers limits from first response headers + * 2. **Tracks Real-Time Quota**: Monitors remaining points continuously + * 3. **Enforces Headroom Buffer**: Preserves safety margin to prevent exhaustion + * 4. **Persists State**: Saves state across restarts for consistent behavior + * 5. **Auto-Recovery**: Detects and handles quota resets automatically + * + * DESIGN PHILOSOPHY: + * - NO ASSUMPTIONS: Every PDS configuration is learned dynamically + * - SAFETY FIRST: 15% headroom buffer prevents rate limit hits + * - STATE PERSISTENCE: Resume support with saved server capacity + * - TRANSPARENT OPERATION: Clear logging of all decisions + * + * TYPICAL FLOW: + * + * First Run: + * 1. No saved state exists + * 2. Return 0 quota (forces conservative probe) + * 3. Receive first response headers + * 4. Learn: "5000 points/3600s window" + * 5. Save state to ~/.malachite/state/rate-limit.json + * 6. Provide full quota for subsequent requests + * + * Subsequent Runs: + * 1. Load saved state (5000 points/3600s) + * 2. Check remaining quota (e.g., 4200) + * 3. Calculate safe quota: 4200 - (5000 ร— 0.15) = 3450 + * 4. Provide safe quota for batch sizing + * 5. Update state after each request + * + * Quota Exhaustion: + * 1. Remaining drops below headroom (750 for 5000 limit) + * 2. Return 0 safe quota + * 3. Wait for reset timestamp + * 4. Auto-restore to full quota + * 5. Resume at maximum speed + * + * STATE FILE FORMAT: + * ```json + * { + * "limit": 5000, // Server capacity + * "remaining": 3240, // Current quota + * "resetAt": 1738938600, // Reset timestamp (unix seconds) + * "windowSeconds": 3600, // Window duration + * "updatedAt": 1738935780, // Last update timestamp + * "headroomThreshold": 0.15 // Safety buffer (15%) + * } + * ``` + * + * HEADROOM BUFFER: + * The 15% headroom buffer prevents quota exhaustion: + * - Without headroom: Risk hitting 0 and triggering rate limits + * - With headroom: Stop at 750 points (15% of 5000), wait for reset + * - Result: Never actually hit rate limits, always maintain buffer + * + * @module rate-limiter */ import fs from 'node:fs'; @@ -17,55 +67,148 @@ import { getMalachiteStateDir } from './platform.js'; import { parseRateLimitHeaders, normalizeHeaders } from './rate-limit-headers.js'; import { log } from './logger.js'; +/** + * Rate limit state structure persisted to disk. + * All timestamps are in Unix seconds (not milliseconds). + */ export interface RateLimitState { - /** Maximum points allowed in the time window */ + /** Maximum points allowed in the time window (learned from server) */ limit: number; - /** Points remaining in current window */ + /** Points remaining in current window (actual server value, not adjusted) */ remaining: number; /** Unix timestamp (seconds) when the window resets */ resetAt: number; - /** Window duration in seconds (typically 3600 = 1 hour) */ + /** Window duration in seconds (learned from server policy, typically 3600) */ windowSeconds: number; - /** When this state was last updated (unix timestamp in seconds) */ + /** Unix timestamp (seconds) when this state was last updated */ updatedAt: number; - /** Safety margin to apply (0.0-1.0) */ - safetyMargin: number; - - /** Headroom threshold - pause when remaining drops below this % of limit */ + /** Headroom threshold - fraction of limit to preserve as buffer (0.0-1.0) */ headroomThreshold: number; } +/** + * RateLimiter - Intelligent rate limit management with server discovery + * + * CORE CONCEPTS: + * + * 1. **Actual vs Safe Quota**: + * - Actual: What the server reports (e.g., 800 points) + * - Safe: Actual minus headroom buffer (e.g., 800 - 750 = 50 points) + * - We use safe quota for decisions to maintain the buffer + * + * 2. **Headroom Buffer**: + * - Default: 15% of server limit + * - Purpose: Prevent quota exhaustion before hitting 0 + * - Example: 5000 limit โ†’ 750 point buffer + * - When remaining drops to 750, we stop and wait + * + * 3. **State Persistence**: + * - Saved after every update to disk + * - Allows resuming with known server capacity + * - Survives process restarts + * + * 4. **Auto-Recovery**: + * - Detects when current time >= resetAt + * - Automatically restores remaining to limit + * - No manual intervention needed + * + * INITIALIZATION: + * ```typescript + * // Default 15% headroom + * const rl = new RateLimiter(); + * + * // Custom headroom (10%) + * const rl = new RateLimiter({ headroom: 0.10 }); + * ``` + * + * TYPICAL USAGE: + * ```typescript + * // Check if we have quota + * const hasQuota = await rl.checkQuota(600); // Need 600 points + * + * // Reserve quota (waits if exhausted) + * await rl.waitForPermit(600); + * + * // Get safe available points for batch sizing + * const safePoints = rl.getSafeAvailablePoints(); + * + * // Update from server response + * rl.updateFromHeaders(response.headers); + * ``` + * + * LEARNING FLOW: + * ```typescript + * // First request - no state exists + * rl.getSafeAvailablePoints(); // Returns 0 + * + * // Send probe batch + * const response = await sendBatch(10 records); + * + * // Learn from response + * rl.updateFromHeaders(response.headers); + * // Logs: "๐ŸŽ“ LEARNED: 5000 points/3600s" + * + * // Now we know the capacity + * rl.getSafeAvailablePoints(); // Returns 4250 (5000 - 750 headroom) + * ``` + */ export class RateLimiter { + /** Path to persisted state file */ private stateFile: string; - private safetyMargin: number; + + /** Headroom threshold as fraction of limit (default 0.15 = 15%) */ private headroomThreshold: number; - constructor(opts?: { safety?: number; headroom?: number }) { - this.safetyMargin = opts?.safety ?? 0.75; // Default 75% safety margin (deprecated, use headroom instead) - this.headroomThreshold = opts?.headroom ?? 0.15; // Default 15% headroom - pause when we hit this threshold + /** Flag tracking whether we've learned server capacity yet */ + private hasLearnedFromServer: boolean = false; + + /** + * Initialize rate limiter with optional custom headroom. + * + * INITIALIZATION STEPS: + * 1. Set headroom threshold (default 15%) + * 2. Determine state file path (~/.malachite/state/rate-limit.json) + * 3. Ensure state directory exists + * 4. Check for existing state (from previous runs) + * 5. Set hasLearnedFromServer flag if state exists + * + * @param opts Configuration options + * @param opts.headroom Headroom threshold (0.0-1.0, default 0.15 = 15%) + */ + constructor(opts?: { headroom?: number }) { + this.headroomThreshold = opts?.headroom ?? 0.15; // Default 15% headroom const stateDir = path.join(getMalachiteStateDir(), 'state'); this.stateFile = path.join(stateDir, 'rate-limit.json'); - log.info(`[RateLimiter] ๐Ÿ’พ State file path: ${this.stateFile}`); - log.info(`[RateLimiter] ๐Ÿ›ก๏ธ Headroom threshold: ${(this.headroomThreshold * 100).toFixed(0)}% - will pause when remaining drops below this`); - log.debug(`[RateLimiter] constructor: stateFile=${this.stateFile}, safety=${this.safetyMargin}, headroom=${this.headroomThreshold}`); + log.info(`[RateLimiter] ๐Ÿ’พ State file: ${this.stateFile}`); + log.info(`[RateLimiter] ๐Ÿ›ก๏ธ Headroom: ${(this.headroomThreshold * 100).toFixed(0)}% buffer before pausing`); this.ensureStateDir(); + + // Check if we already have server info from previous runs + const state = this.readState(); + if (state && state.limit > 0) { + this.hasLearnedFromServer = true; + log.info(`[RateLimiter] โ„น๏ธ Using saved state: ${state.limit} points/${state.windowSeconds}s window`); + } else { + log.info(`[RateLimiter] ๐Ÿ” No saved state - will learn from first server response`); + } } + /** + * Ensure state directory exists, creating it if necessary. + * Called during initialization and before writing state. + */ private ensureStateDir(): void { const dir = path.dirname(this.stateFile); try { if (!fs.existsSync(dir)) { - log.info(`[RateLimiter] Creating state directory: ${dir}`); fs.mkdirSync(dir, { recursive: true }); - log.info(`[RateLimiter] โœ… State directory created`); - } else { - log.debug(`[RateLimiter] State directory already exists: ${dir}`); + log.debug(`[RateLimiter] Created state directory: ${dir}`); } } catch (error) { log.error(`[RateLimiter] โŒ Failed to create state directory: ${error}`); @@ -73,11 +216,20 @@ export class RateLimiter { } } + /** + * Read rate limit state from disk. + * + * RETURNS: + * - RateLimitState object if file exists and is valid JSON + * - null if file doesn't exist or can't be parsed + * + * @returns Persisted state or null + */ private readState(): RateLimitState | null { try { const raw = fs.readFileSync(this.stateFile, 'utf8'); const state = JSON.parse(raw) as RateLimitState; - log.debug(`[RateLimiter] Loaded state: ${JSON.stringify(state)}`); + log.debug(`[RateLimiter] Loaded state: limit=${state.limit}, remaining=${state.remaining}, window=${state.windowSeconds}s`); return state; } catch (e) { log.debug(`[RateLimiter] No existing state file`); @@ -85,169 +237,244 @@ export class RateLimiter { } } + /** + * Write rate limit state to disk. + * + * EFFECTS: + * - Ensures state directory exists + * - Writes JSON to state file + * - Sets hasLearnedFromServer flag on first successful write with capacity + * - Logs detailed state information + * + * @param state State to persist + * @throws Error if write fails + */ private writeState(state: RateLimitState): void { try { - log.debug(`[RateLimiter] Writing state to: ${this.stateFile}`); - log.debug(`[RateLimiter] State data: ${JSON.stringify(state)}`); - - // Ensure directory exists before writing this.ensureStateDir(); - const stateJson = JSON.stringify(state, null, 2); fs.writeFileSync(this.stateFile, stateJson, 'utf8'); - // Verify the write succeeded - if (fs.existsSync(this.stateFile)) { - log.info(`[RateLimiter] โœ… State file written successfully to: ${this.stateFile}`); - } else { - log.error(`[RateLimiter] โŒ State file write failed - file does not exist after write`); + // Log when we first learn server capacity + if (!this.hasLearnedFromServer && state.limit > 0) { + this.hasLearnedFromServer = true; + log.info(`[RateLimiter] โœ… Learned from server: ${state.limit} points/${state.windowSeconds}s window`); } + + log.debug(`[RateLimiter] State saved: limit=${state.limit}, remaining=${state.remaining}, resets=${new Date(state.resetAt * 1000).toISOString()}`); } catch (error) { - log.error(`[RateLimiter] โŒ Failed to write state file: ${error}`); - if (error instanceof Error) { - log.error(`[RateLimiter] Error details: ${error.message}`); - log.error(`[RateLimiter] Stack: ${error.stack}`); - } - throw error; // Re-throw so caller knows write failed + log.error(`[RateLimiter] โŒ Failed to write state: ${error}`); + throw error; } } /** - * Update rate limit state from server response headers + * Update rate limit state from server response headers. + * This is how we LEARN the server's configuration dynamically. + * + * LEARNING PROCESS: + * 1. Parse headers (limit, remaining, reset, window) + * 2. Validate required fields (limit and remaining must exist) + * 3. Calculate reset time (from header or estimated) + * 4. Create new state with actual server values + * 5. Log learning message on first discovery + * 6. Save state to disk + * + * IMPORTANT: We store the ACTUAL remaining from the server, + * not adjusted by headroom. Headroom is applied only when + * checking or reserving quota, not when storing state. + * + * HEADER FORMATS SUPPORTED: + * - Standard: ratelimit-limit, ratelimit-remaining, ratelimit-reset + * - X-Prefixed: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset + * - Policy: ratelimit-policy (format: "5000;w=3600") + * + * EXAMPLE: + * ```typescript + * const headers = { + * 'ratelimit-limit': '5000', + * 'ratelimit-remaining': '4970', + * 'ratelimit-reset': '1738938600', + * 'ratelimit-policy': '5000;w=3600' + * }; + * rl.updateFromHeaders(headers); + * // Logs: "๐ŸŽ“ LEARNED: 5000 points/3600s, currently 4970 remaining (99.4%)" + * ``` + * + * @param headers Response headers from server (should be normalized) */ updateFromHeaders(headers: Record): void { - log.debug(`[RateLimiter] updateFromHeaders() called`); - // Normalize header keys to lowercase for consistent parsing const normalizedHeaders = normalizeHeaders(headers); const parsed = parseRateLimitHeaders(normalizedHeaders); if (!parsed.limit || parsed.remaining === undefined) { - log.warn('[RateLimiter] Headers missing limit or remaining - cannot update'); + log.warn('[RateLimiter] Headers missing limit/remaining - cannot learn from server'); return; } const now = Math.floor(Date.now() / 1000); - let resetAt = parsed.reset || (now + (parsed.windowSeconds || 3600)); - let windowSeconds = parsed.windowSeconds || 3600; + const resetAt = parsed.reset || (now + (parsed.windowSeconds || 3600)); + const windowSeconds = parsed.windowSeconds || 3600; - // Store the actual remaining from server (no modification) - // We'll check headroom separately when making decisions const state: RateLimitState = { limit: parsed.limit, remaining: parsed.remaining, // Store actual server value resetAt, windowSeconds, updatedAt: now, - safetyMargin: this.safetyMargin, headroomThreshold: this.headroomThreshold }; const headroomPoints = Math.floor(parsed.limit * this.headroomThreshold); const percentRemaining = ((parsed.remaining / parsed.limit) * 100).toFixed(1); - log.info(`[RateLimiter] Updated from headers: ${parsed.limit} limit, ${parsed.remaining} remaining (${percentRemaining}%), headroom threshold: ${headroomPoints} points (${(this.headroomThreshold * 100).toFixed(0)}%), resets at ${new Date(resetAt * 1000).toISOString()}`); + // Special logging for first-time discovery + if (!this.hasLearnedFromServer) { + log.info(`[RateLimiter] ๐ŸŽ“ LEARNED: ${parsed.limit} points/${windowSeconds}s, currently ${parsed.remaining} remaining (${percentRemaining}%)`); + } else { + log.debug(`[RateLimiter] Updated: ${parsed.remaining}/${parsed.limit} (${percentRemaining}%), resets ${new Date(resetAt * 1000).toISOString()}`); + } - // Warn if we're getting close to headroom threshold + // Warn if approaching headroom threshold if (parsed.remaining <= headroomPoints) { - log.warn(`[RateLimiter] โš ๏ธ Approaching headroom threshold! ${parsed.remaining} <= ${headroomPoints} (${(this.headroomThreshold * 100).toFixed(0)}% of limit)`); + log.warn(`[RateLimiter] โš ๏ธ Near headroom threshold! ${parsed.remaining} โ‰ค ${headroomPoints} (${(this.headroomThreshold * 100).toFixed(0)}%)`); } this.writeState(state); } /** - * Check if we have enough quota for the requested points - * Returns true if quota is available, false otherwise + * Check if we have enough quota for the requested points. + * Does not modify state - use reserveQuota to actually reserve points. + * + * ALGORITHM: + * 1. Read current state + * 2. Check if window has reset (auto-restore quota) + * 3. Calculate effective remaining (actual - headroom) + * 4. Compare with requested points + * + * HEADROOM APPLICATION: + * effective = remaining - (limit ร— threshold) + * Example: 800 - (5000 ร— 0.15) = 800 - 750 = 50 + * + * RETURNS: + * - true if effective remaining >= pointsNeeded + * - true if no state yet (first request) + * - false if insufficient quota + * + * @param pointsNeeded Number of points required + * @returns Whether quota is available */ async checkQuota(pointsNeeded: number): Promise { const state = this.readState(); if (!state) { - log.warn('[RateLimiter] No state - assuming quota available'); - return true; // No state yet, let first request go through + // No state yet - let first request through so we can learn + log.debug('[RateLimiter] No state - allowing first request to learn from server'); + return true; } const now = Math.floor(Date.now() / 1000); - // Check if window has reset + // Auto-restore if window has reset if (now >= state.resetAt) { - log.info(`[RateLimiter] Window has reset! Restoring quota to ${state.limit}`); - state.remaining = state.limit; // Restore to full limit + log.info(`[RateLimiter] ๐Ÿ”„ Window reset! Quota restored to ${state.limit}`); + state.remaining = state.limit; state.resetAt = now + state.windowSeconds; state.updatedAt = now; this.writeState(state); return true; } - // Calculate headroom threshold - we want to pause BEFORE hitting zero + // Calculate effective remaining with headroom buffer const headroomPoints = Math.floor(state.limit * this.headroomThreshold); const effectiveRemaining = state.remaining - headroomPoints; - - // Check if we have enough quota (with headroom considered) const hasQuota = effectiveRemaining >= pointsNeeded; - if (!hasQuota && state.remaining > 0) { + if (!hasQuota) { const percentRemaining = ((state.remaining / state.limit) * 100).toFixed(1); - log.debug(`[RateLimiter] checkQuota(${pointsNeeded}): remaining=${state.remaining} (${percentRemaining}%), headroom=${headroomPoints}, effective=${effectiveRemaining}, hasQuota=${hasQuota}`); - log.info(`[RateLimiter] Approaching headroom threshold - preserving ${headroomPoints} points (${(this.headroomThreshold * 100).toFixed(0)}% buffer)`); - } else { - log.debug(`[RateLimiter] checkQuota(${pointsNeeded}): remaining=${state.remaining}, hasQuota=${hasQuota}`); + log.debug(`[RateLimiter] Insufficient quota: need ${pointsNeeded}, have ${state.remaining} (${percentRemaining}%), preserving ${headroomPoints} headroom`); } return hasQuota; } /** - * Reserve quota points before making a request - * Returns true if reservation succeeded, false if quota exhausted + * Reserve quota points before making a request. + * Actually decrements the remaining count in state. + * + * ALGORITHM: + * 1. Read current state + * 2. Check if window has reset (auto-restore) + * 3. Calculate effective remaining (with headroom) + * 4. If insufficient: log warning and return false + * 5. If sufficient: decrement remaining and save state + * + * RETURNS: + * - true if quota reserved successfully + * - true if no state yet (first request) + * - false if insufficient quota + * + * IMPORTANT: This MODIFIES state by decrementing remaining. + * Only call this when you're about to make the request. + * + * @param pointsNeeded Number of points to reserve + * @returns Whether reservation succeeded */ async reserveQuota(pointsNeeded: number): Promise { const state = this.readState(); if (!state) { - log.warn('[RateLimiter] No state yet - allowing request (will be initialized from response)'); + // No state yet - let it through so we can learn + log.debug('[RateLimiter] No state - allowing request to establish baseline'); return true; } const now = Math.floor(Date.now() / 1000); - // Check if window has reset + // Auto-restore if window reset if (now >= state.resetAt) { - log.info(`[RateLimiter] Window reset! Restoring quota`); - state.remaining = state.limit; // Restore to full limit + log.info(`[RateLimiter] ๐Ÿ”„ Window reset detected during reservation`); + state.remaining = state.limit; state.resetAt = now + state.windowSeconds; state.updatedAt = now; this.writeState(state); } - // Calculate headroom - preserve a buffer before hitting zero + // Check quota with headroom const headroomPoints = Math.floor(state.limit * this.headroomThreshold); const effectiveRemaining = state.remaining - headroomPoints; - // Check quota with headroom if (effectiveRemaining < pointsNeeded) { const waitTime = state.resetAt - now; const percentRemaining = ((state.remaining / state.limit) * 100).toFixed(1); if (state.remaining > 0) { - log.warn(`[RateLimiter] โŒ Approaching rate limit headroom! Need ${pointsNeeded} points, have ${state.remaining} (${percentRemaining}%) but preserving ${headroomPoints} point buffer`); + log.warn(`[RateLimiter] โš ๏ธ Approaching headroom! Need ${pointsNeeded}, have ${state.remaining} (${percentRemaining}%), preserving ${headroomPoints} buffer`); } else { - log.warn(`[RateLimiter] โŒ Quota exhausted! Need ${pointsNeeded} points, ${state.remaining} remaining`); + log.warn(`[RateLimiter] โŒ Quota exhausted! Need ${pointsNeeded}, have ${state.remaining}`); } - log.warn(`[RateLimiter] Must wait ${waitTime}s (${Math.floor(waitTime / 60)}m ${waitTime % 60}s) until ${new Date(state.resetAt * 1000).toISOString()}`); + log.warn(`[RateLimiter] โณ Must wait ${waitTime}s until ${new Date(state.resetAt * 1000).toISOString()}`); return false; } - // Reserve the quota + // Reserve the quota by decrementing remaining state.remaining -= pointsNeeded; state.updatedAt = now; this.writeState(state); - log.debug(`[RateLimiter] โœ… Reserved ${pointsNeeded} points, ${state.remaining} remaining`); + log.debug(`[RateLimiter] Reserved ${pointsNeeded} points, ${state.remaining} remaining`); return true; } /** - * Wait until the rate limit window resets + * Wait until the rate limit window resets. + * Sleeps until resetAt timestamp + 1 second buffer. + * + * USAGE: + * Called when quota is exhausted and we need to wait. + * Displays countdown timer to user. + * + * @returns Promise that resolves after wait completes */ async waitForReset(): Promise { const state = this.readState(); @@ -260,7 +487,7 @@ export class RateLimiter { const waitTime = Math.max(0, state.resetAt - now); if (waitTime === 0) { - log.info('[ImprovedRateLimiter] Window already reset'); + log.info('[RateLimiter] Window already reset'); return; } @@ -270,66 +497,145 @@ export class RateLimiter { log.warn(`[RateLimiter] โณ Waiting ${minutes}m ${seconds}s for quota reset...`); log.warn(`[RateLimiter] Reset at: ${new Date(state.resetAt * 1000).toISOString()}`); - // Wait with 1 second added as buffer + // Wait with 1 second buffer await new Promise(resolve => setTimeout(resolve, (waitTime + 1) * 1000)); - log.info('[RateLimiter] โœ… Wait complete - quota should be reset'); + log.info('[RateLimiter] โœ… Wait complete - quota restored'); } /** * Wait for a permit with the given number of points. - * This combines reserveQuota and waitForReset logic: - * - If quota is available, reserves it immediately - * - If quota is exhausted, waits until reset and then reserves - * - Automatically loops until permit is granted + * Combines reserveQuota and waitForReset - loops until permit granted. + * + * ALGORITHM: + * 1. Try to reserve quota + * 2. If successful: return true + * 3. If failed: wait for reset + * 4. Loop back to step 1 + * + * This ensures we ALWAYS get the permit eventually, even if + * we have to wait for quota to reset. + * + * USAGE: + * ```typescript + * // This will wait if needed + * await rl.waitForPermit(600); + * // Quota is now reserved, safe to proceed + * await sendBatch(); + * ``` + * + * @param pointsNeeded Number of points required + * @returns Promise when permit is granted */ async waitForPermit(pointsNeeded: number): Promise { while (true) { - // Try to reserve quota first const reserved = await this.reserveQuota(pointsNeeded); if (reserved) { - log.debug(`[RateLimiter] โœ… Permit granted for ${pointsNeeded} points`); - return true; // Got the permit + return true; } - // Quota exhausted - wait for reset log.info(`[RateLimiter] Quota exhausted, waiting for reset...`); await this.waitForReset(); - - log.info(`[RateLimiter] Reset complete, retrying reservation...`); - // Loop will retry reservation + log.info(`[RateLimiter] Retrying reservation...`); } } /** - * Get safe available points (remaining - headroom buffer) - * This is the amount of quota we can safely use without hitting the headroom threshold + * Get safe available points (remaining - headroom buffer). + * This is the amount we can safely use without hitting the threshold. + * + * ALGORITHM: + * 1. Read state + * 2. If no state: return 0 (forces probe batch) + * 3. Check if window reset (auto-restore) + * 4. Calculate: remaining - (limit ร— threshold) + * 5. Return max(0, safe_points) + * + * EXAMPLE: + * State: { limit: 5000, remaining: 4200 } + * Headroom: 15% = 750 points + * Safe: 4200 - 750 = 3450 points + * + * USE CASE: + * Call this to determine optimal batch size: + * ```typescript + * const safePoints = rl.getSafeAvailablePoints(); + * const batchSize = Math.floor(safePoints / 3); // 3 points per record + * ``` + * + * @returns Safe available quota points (0 if no state or quota exhausted) */ getSafeAvailablePoints(): number { const state = this.readState(); if (!state) { - // No state yet - allow a reasonable default - return 300; // Equivalent to 100 records at 3 points each + // No state yet - return 0 to force conservative start + // After first request, we'll learn the real limits + return 0; } const now = Math.floor(Date.now() / 1000); - // Check if window has reset + // Auto-restore if window reset if (now >= state.resetAt) { - log.debug(`[RateLimiter] Window has reset, full quota available: ${state.limit}`); + log.debug(`[RateLimiter] Window reset detected, full quota: ${state.limit}`); return state.limit; } - // Calculate headroom and effective remaining const headroomPoints = Math.floor(state.limit * this.headroomThreshold); const safePoints = Math.max(0, state.remaining - headroomPoints); - log.debug(`[RateLimiter] getSafeAvailablePoints: remaining=${state.remaining}, headroom=${headroomPoints}, safe=${safePoints}`); + log.debug(`[RateLimiter] Safe quota: ${safePoints} (${state.remaining} - ${headroomPoints} headroom)`); return safePoints; } /** - * Get current rate limit status for monitoring + * Get server capacity information (learned dynamically). + * + * RETURNS: + * - { limit, windowSeconds } if we've learned from server + * - null if we haven't received headers yet + * + * USE CASE: + * Called by DynamicBatchCalculator to calculate initial batch size. + * + * @returns Server capacity info or null + */ + getServerCapacity(): { limit: number; windowSeconds: number } | null { + const state = this.readState(); + if (!state || state.limit === 0) { + return null; + } + return { + limit: state.limit, + windowSeconds: state.windowSeconds + }; + } + + /** + * Check if we've learned from the server yet. + * + * @returns True if we have server capacity info + */ + hasServerInfo(): boolean { + return this.hasLearnedFromServer; + } + + /** + * Get current rate limit status for monitoring/debugging. + * + * RETURNS: + * Object with: + * - hasState: Whether state exists + * - limit: Server capacity (if known) + * - remaining: Current quota (if known) + * - remainingPercent: Quota as percentage (if known) + * - headroomPoints: Absolute headroom in points (if known) + * - effectiveRemaining: Safe quota after headroom (if known) + * - resetAt: Reset timestamp (if known) + * - secondsUntilReset: Time until reset (if known) + * - windowSeconds: Window duration (if known) + * + * @returns Current status object */ getStatus(): { hasState: boolean;