200 lines · 7.5 KB
import { DurableObject } from "cloudflare:workers";
import { batch, first, stmt } from "./db";
import { afterHostingFlush } from "./sites/caps";

/** How long increments sit in the object before D1 sees them; reads that must be exact go to the object. */
const FLUSH_DELAY_MS = 10_000;
const RETRY_DELAY_MS = 60_000;
const FLUSH_BATCH = 200;
/** Rows untouched this long are dropped once flushed, so an object only holds its working set. */
const IDLE_ROW_MS = 3 * 24 * 60 * 60 * 1000;

export type CounterKind = "site-usage" | "pages-usage" | "release-downloads";
export type Totals = [number, number, number];
type Row = { key: string; a: number; b: number; c: number; meta: string | null };

type Kind = {
  /** Current D1 totals for a key the object has not seen yet, so taking over a live counter loses nothing. */
  seed(scope: string, key: string): Promise<Totals>;
  /** Writes absolute totals, so a retried flush is idempotent. */
  flush(scope: string, rows: Row[]): ReturnType<typeof stmt>[];
  /** Runs after a successful flush with the billed accounts it touched; reads D1 and writes KV only. */
  afterFlush?(accountIds: string[]): Promise<void>;
};

const KINDS: Record<CounterKind, Kind> = {
  /** scope = site id, key = UTC day, totals = requests / cpu ms / bytes out, meta = billed account id. */
  "site-usage": {
    async seed(siteId, day) {
      const row = await first<{ requests: number; cpu_ms: number; bytes_out: number }>(
        "SELECT requests, cpu_ms, bytes_out FROM site_usage WHERE site_id = ? AND day = ?",
        siteId,
        day,
      );
      return [row?.requests ?? 0, row?.cpu_ms ?? 0, row?.bytes_out ?? 0];
    },
    flush(siteId, rows) {
      return rows.map((row) =>
        stmt(
          `INSERT INTO site_usage (site_id, day, account_id, requests, cpu_ms, bytes_out)
           SELECT ?1, ?2, ?3, ?4, ?5, ?6 WHERE EXISTS (SELECT 1 FROM sites WHERE id = ?1)