← back to Rentv Adintel

src/adapters/base.js

243 lines

'use strict';
/**
 * Public-source adapter contract + common pipeline (spec §10).
 *
 * AdvertiserSourceAdapter is the base class every source implements:
 *   - policy                  the SourcePolicy this adapter runs under
 *   - discover(cursor)        async generator of DiscoveredSourceItem
 *   - fetch(item)             retrieve bytes (only when policy permits)
 *   - parse(input)            async generator of extractions
 *
 * runPipeline() provides the shared machinery the spec mandates:
 *   policy validation before every run, per-host throttle, exponential backoff
 *   with jitter, conditional-request hook, idempotent upsert hook, dead-letter
 *   recording into ingestion_jobs (status DEAD_LETTER), dry-run mode,
 *   incremental cursor, and job logging into ingestion_runs.
 */

const { assertSourceEnabledLegal } = require('../../lib/compliance/source-policy');

/**
 * Base adapter. Concrete adapters override discover/fetch/parse. The defaults
 * are deliberately inert (yield nothing / refuse) so a half-built adapter can
 * never accidentally fetch.
 */
class AdvertiserSourceAdapter {
  constructor(policy) {
    if (!policy || typeof policy !== 'object') {
      throw new Error('AdvertiserSourceAdapter: a SourcePolicy is required');
    }
    this.policy = policy;
  }

  // eslint-disable-next-line require-yield
  async *discover(_cursor) {
    // Default: discover nothing. Concrete adapters override.
    return;
  }

  async fetch(_item) {
    throw new Error(
      `${this.constructor.name}.fetch not implemented — adapter must define fetch or be manual_review_only`
    );
  }

  // eslint-disable-next-line require-yield
  async *parse(_input) {
    return;
  }
}

/** Exponential backoff with full jitter, capped. */
function backoffDelay(attempt, baseMs = 500, capMs = 30000) {
  const exp = Math.min(capMs, baseMs * 2 ** attempt);
  return Math.floor(Math.random() * exp); // full jitter
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

/** Simple per-host throttle used inside the pipeline (delegates to policy). */
async function throttle(policy, lastByHost, host) {
  const rpm = policy.maxRequestsPerMinute || Number(process.env.DEFAULT_REQUESTS_PER_MINUTE || 6);
  const minDelay = policy.minimumDelayMs != null ? policy.minimumDelayMs : Math.ceil(60000 / rpm);
  const last = lastByHost.get(host) || 0;
  const since = Date.now() - last;
  if (last && since < minDelay) await sleep(minDelay - since);
  lastByHost.set(host, Date.now());
}

function hostOf(url) {
  try {
    return new URL(url).hostname.toLowerCase();
  } catch (_e) {
    return 'unknown';
  }
}

/**
 * runPipeline(adapter, opts)
 *
 * opts:
 *   db        { query } — the db module (required for real logging; optional in
 *                         dry-run tests where you can pass a stub or omit)
 *   dryRun    boolean   — discover + parse but never fetch or upsert
 *   upsert    async(extraction, ctx) => void  — idempotent write callback
 *   cursor    string    — incremental cursor passed to discover()
 *   maxItems  number    — safety cap
 *   maxAttempts number  — per-item retry budget before dead-letter (default 3)
 *   conditionalRequest  async(item) => ({skip:boolean, headers?:object})
 *                        — conditional-request hook (etag/last-modified)
 *
 * Returns { runId, stats, cursor }.
 */
async function runPipeline(adapter, opts = {}) {
  if (!adapter || !(adapter instanceof AdvertiserSourceAdapter)) {
    throw new Error('runPipeline: adapter must extend AdvertiserSourceAdapter');
  }
  const policy = adapter.policy;

  // §10 — policy validation BEFORE every run. Throws on an illegal enabled source.
  assertSourceEnabledLegal(policy);

  const db = opts.db || null;
  const dryRun = opts.dryRun === true;
  const upsert = typeof opts.upsert === 'function' ? opts.upsert : null;
  const maxItems = opts.maxItems || 1000;
  const maxAttempts = opts.maxAttempts || 3;
  const conditionalRequest =
    typeof opts.conditionalRequest === 'function' ? opts.conditionalRequest : null;

  const stats = {
    discovered: 0,
    fetched: 0,
    skippedConditional: 0,
    parsed: 0,
    upserted: 0,
    deadLettered: 0,
    errors: 0,
  };
  const lastByHost = new Map();

  // Open a run row (ingestion_runs).
  let runId = null;
  if (db && db.query) {
    const r = await db.query(
      `INSERT INTO ingestion_runs (source_key, status, dry_run, stats)
       VALUES ($1, 'RUNNING', $2, $3::jsonb) RETURNING id`,
      [policy.sourceKey, dryRun, JSON.stringify(stats)]
    );
    runId = r.rows[0].id;
  }

  const deadLetter = async (item, err) => {
    stats.deadLettered += 1;
    if (db && db.query && runId) {
      await db.query(
        `INSERT INTO ingestion_jobs (run_id, job_type, payload, status, attempts, last_error)
         VALUES ($1, 'FETCH_PARSE', $2::jsonb, 'DEAD_LETTER', $3, $4)`,
        [runId, JSON.stringify(item || {}), maxAttempts, String((err && err.message) || err)]
      );
    }
  };

  let finalError = null;
  let cursor = opts.cursor;

  try {
    for await (const item of adapter.discover(opts.cursor)) {
      stats.discovered += 1;
      if (item && item.externalId) cursor = item.externalId; // incremental cursor
      if (stats.discovered > maxItems) break;

      if (dryRun) {
        // Dry-run: exercise discover only; no fetch, no writes.
        continue;
      }

      // Conditional-request hook (etag/last-modified) — skip unchanged.
      if (conditionalRequest) {
        try {
          const cr = await conditionalRequest(item);
          if (cr && cr.skip) {
            stats.skippedConditional += 1;
            continue;
          }
        } catch (_e) {
          /* non-fatal; proceed to fetch */
        }
      }

      // Fetch with retry + exponential backoff + jitter → dead-letter on give-up.
      let fetched = null;
      let attempt = 0;
      // eslint-disable-next-line no-constant-condition
      while (true) {
        try {
          await throttle(policy, lastByHost, hostOf(item.url));
          fetched = await adapter.fetch(item);
          stats.fetched += 1;
          break;
        } catch (err) {
          attempt += 1;
          stats.errors += 1;
          if (attempt >= maxAttempts) {
            await deadLetter(item, err);
            break;
          }
          await sleep(backoffDelay(attempt));
        }
      }
      if (!fetched) continue; // dead-lettered

      // Parse + idempotent upsert.
      try {
        for await (const extraction of adapter.parse({
          bytes: fetched.bytes,
          contentType: fetched.contentType,
          finalUrl: fetched.finalUrl,
        })) {
          stats.parsed += 1;
          if (upsert) {
            await upsert(extraction, { item, fetched, policy, runId });
            stats.upserted += 1;
          }
        }
      } catch (err) {
        stats.errors += 1;
        await deadLetter(item, err);
      }
    }
  } catch (err) {
    finalError = err;
  }

  // Close the run row.
  if (db && db.query && runId) {
    await db.query(
      `UPDATE ingestion_runs
         SET finished_at = now(),
             status = $2,
             stats = $3::jsonb,
             error = $4
       WHERE id = $1`,
      [
        runId,
        finalError ? 'FAILED' : 'COMPLETED',
        JSON.stringify(stats),
        finalError ? String(finalError.message || finalError) : null,
      ]
    );
  }

  if (finalError) throw finalError;
  return { runId, stats, cursor };
}

module.exports = {
  AdvertiserSourceAdapter,
  runPipeline,
  backoffDelay,
};