import { all, first, run } from "../db"; import { randomId } from "../crypto"; import { billingSettings, commitUsage, hardCap, periodUsage, releaseUsage, reserveUsage } from "../metering/quota"; import type { Reservation } from "../metering/quota"; import { recordUsage } from "../metering/record"; import { planAllowsEditorContainers } from "~/lib/plans"; import { containerLimits, containerStub } from "./container-config"; import { accountDisableState, assertAccountActive } from "../account-disable"; export const EDITOR_INSTANCE_TYPE = "standard-1"; export const EDITOR_METER = "compute.editor"; const HOUR_MS = 60 * 60 * 1000; const HOUR_S = 60 * 60; const SECOND_MS = 1000; const MINUTE_S = 60; export type ContainerUsage = { /** Seconds committed this month plus the elapsed time of sessions still running. */ usedSeconds: number; includedSeconds: number; hardCapSeconds: number | null; /** False on plans with no editor allowance and a zero hard cap (free tier without a spending limit). */ allowed: boolean; }; export const CONTAINERS_PAID_ONLY = "Containers are available on paid plans"; /** The account's editor-container usage this month against its plan, for status bars and listings. */ export async function containerUsage(accountId: string): Promise { const now = Date.now(); const [settings, period, running] = await Promise.all([ billingSettings(accountId), periodUsage(accountId, EDITOR_METER), all<{ started_at: number }>( "SELECT started_at FROM editor_container_sessions WHERE account_id = ? AND stopped_at IS NULL", accountId, ), ]); const elapsed = running.reduce((sum, row) => sum + (now - row.started_at) / SECOND_MS, 0); const includedSeconds = settings.plan.included[EDITOR_METER] ?? 0; const hardCapSeconds = hardCap(settings, EDITOR_METER); return { usedSeconds: period.quantity + elapsed, includedSeconds, hardCapSeconds, allowed: planAllowsEditorContainers(settings.plan), }; } /** Whether `accountId`'s plan lets it start editor containers at all (independent of EDITOR_CONTAINERS). */ export async function containersAllowedFor(accountId: string): Promise { if (await accountDisableState(accountId)) return false; return planAllowsEditorContainers((await billingSettings(accountId)).plan); } export class ContainerCapError extends Error {} /** * Holds `budgetSeconds` of compute.editor for a new session and records its row. The reservation id is * the session id, so a retried start of the same session never holds twice. Throws QuotaExceededError * when the hold doesn't fit. The per-account concurrency cap is enforced inside the insert itself, so two * simultaneous starts cannot both slip under it. */ export async function openContainerSession( accountId: string, repoId: string, branch: string, budgetSeconds: number, ): Promise { await assertAccountActive(accountId); const cap = containerLimits().perAccount; const id = randomId(); const reservation = await reserveUsage({ id, accountId, meter: EDITOR_METER, quantity: budgetSeconds, ttlMs: budgetSeconds * SECOND_MS + HOUR_MS, }); try { const result = await run( `INSERT INTO editor_container_sessions (id, account_id, repo_id, branch, instance_type, started_at) SELECT ?1, ?2, ?3, ?4, ?5, ?6 WHERE (SELECT COUNT(*) FROM editor_container_sessions WHERE account_id = ?2 AND stopped_at IS NULL) < ?7`, id, accountId, repoId, branch, EDITOR_INSTANCE_TYPE, Date.now(), cap, ); if ((result.meta.changes ?? 0) === 0) { throw new ContainerCapError(`This account already has ${cap} containers running; stop one first`); } } catch (error) { await releaseUsage(id).catch(() => undefined); throw error; } return reservation; } type ClosedRow = { account_id: string; repo_id: string; started_at: number; stopped_at: number; stop_reason: string }; /** * Closes the row (a no-op when already closed) and returns it as stored, so a retried settle after a * failed commit bills the same seconds again and the keyed commit dedupes it. */ async function closeRow(sessionId: string, reason: string, stoppedAt: number): Promise { await run( `UPDATE editor_container_sessions SET stopped_at = MAX(started_at, ?1), minutes = CAST(ROUND((MAX(started_at, ?1) - started_at) / ${MINUTE_S * SECOND_MS}.0) AS INTEGER), stop_reason = ?2 WHERE id = ?3 AND stopped_at IS NULL`, stoppedAt, reason, sessionId, ); const row = await first( "SELECT account_id, repo_id, started_at, stopped_at, stop_reason FROM editor_container_sessions WHERE id = ?", sessionId, ); return row && row.stopped_at !== null && row.stop_reason !== "failed" ? row : null; } function billedSeconds(row: ClosedRow, heldSeconds: number): number { return Math.max(0, Math.min((row.stopped_at - row.started_at) / SECOND_MS, heldSeconds)); } /** * Closes the session row and settles its hold with the seconds it ran, never more than was held. * Idempotent: safe to retry after any partial failure. Returns the seconds billed. */ export async function closeContainerSession( reservation: Reservation, reason: string, stoppedAt = Date.now(), ): Promise { const row = await closeRow(reservation.id, reason, stoppedAt); if (!row) return 0; const quantity = billedSeconds(row, reservation.quantity); await commitUsage(reservation, { quantity, repoId: row.repo_id, attributes: { instanceType: EDITOR_INSTANCE_TYPE, reason: row.stop_reason }, }); return quantity; } /** A boot that never produced a usable container: nothing is billed and the hold is released. */ export async function abandonContainerSession(reservation: Reservation): Promise { const now = Date.now(); await run( `UPDATE editor_container_sessions SET stopped_at = ?1, minutes = 0, stop_reason = 'failed' WHERE id = ?2 AND stopped_at IS NULL`, now, reservation.id, ); await releaseUsage(reservation.id); } /** * Settles a session whose Durable Object no longer remembers it (its storage was reset, or the row was * left open by a crash). Bills against the original hold when it still exists, else up to the session cap. */ export async function closeOrphanSession(sessionId: string, reason: string, now = Date.now()): Promise { const row = await closeRow(sessionId, reason, now); if (!row) return 0; const attributes = { instanceType: EDITOR_INSTANCE_TYPE, reason: row.stop_reason }; const held = await first<{ period: string; quantity: number }>( "SELECT period, quantity FROM usage_reservations WHERE id = ?", sessionId, ); if (held) { const reservation: Reservation = { id: sessionId, accountId: row.account_id, meter: EDITOR_METER, ...held }; const quantity = billedSeconds(row, held.quantity); await commitUsage(reservation, { quantity, repoId: row.repo_id, attributes }); return quantity; } const quantity = billedSeconds(row, containerLimits().capHours * HOUR_S); await recordUsage({ accountId: row.account_id, meter: EDITOR_METER, quantity, repoId: row.repo_id, attributes, idempotencyKey: `reservation:${sessionId}`, at: now, }); return quantity; } const EXPIRED_BATCH = 100; /** A row is inserted only after its hold, so a hold gone this long means the session was never settled. */ const MISSING_HOLD_GRACE_MS = 10 * 60 * 1000; /** * Cron: closes rows whose hold expired or vanished without the Durable Object settling them, so they stop * counting toward the concurrency cap and running usage. Each row is billed at most what it held. */ export async function closeExpiredContainerSessions(now = Date.now()): Promise { const rows = await all<{ id: string; account_id: string; repo_id: string; branch: string; expires_at: number | null; }>( `SELECT s.id, s.account_id, s.repo_id, s.branch, u.expires_at FROM editor_container_sessions s LEFT JOIN usage_reservations u ON u.id = s.id WHERE s.stopped_at IS NULL AND ((u.id IS NULL AND s.started_at < ?2) OR u.expires_at < ?1) LIMIT ?3`, now, now - MISSING_HOLD_GRACE_MS, EXPIRED_BATCH, ); let closed = 0; for (const row of rows) { try { // The object still running this session stops its container and settles from its own hold first. await containerStub(row.account_id, row.repo_id, row.branch).stop("expired", row.id); await closeOrphanSession(row.id, "expired", row.expires_at ?? now); closed++; } catch (error) { console.error("Couldn't close an expired container session", error); } } return closed; } export type ContainerSessionRow = { id: string; accountId: string; accountName: string | null; repoId: string; repoOwner: string | null; repoName: string | null; branch: string; startedAt: number; stoppedAt: number | null; stopReason: string | null; /** Last terminal or preview I/O, from the Durable Object; null once stopped or when it isn't reachable. */ lastActivity: number | null; /** Derived from the same start and stop times the meter is billed on. */ minutes: number; }; const RECENT_LIMIT = 30; type SessionDbRow = { id: string; account_id: string; account_name: string | null; repo_id: string; owner_name: string | null; repo_name: string | null; branch: string; started_at: number; stopped_at: number | null; stop_reason: string | null; }; function toSession(row: SessionDbRow, now: number): ContainerSessionRow { return { id: row.id, accountId: row.account_id, accountName: row.account_name, repoId: row.repo_id, repoOwner: row.owner_name, repoName: row.repo_name, branch: row.branch, startedAt: row.started_at, stoppedAt: row.stopped_at, stopReason: row.stop_reason, lastActivity: null, minutes: ((row.stopped_at ?? now) - row.started_at) / (MINUTE_S * SECOND_MS), }; } const SESSION_SELECT = `SELECT s.id, s.account_id, a.name AS account_name, s.repo_id, o.name AS owner_name, r.name AS repo_name, s.branch, s.started_at, s.stopped_at, s.stop_reason FROM editor_container_sessions s LEFT JOIN accounts a ON a.id = s.account_id LEFT JOIN repos r ON r.id = s.repo_id LEFT JOIN accounts o ON o.id = r.owner_id`; /** Running sessions first, then the most recent stopped ones, for one billed account or one repo. */ export async function listContainerSessions( scope: { accountId: string } | { repoId: string }, ): Promise { const column = "accountId" in scope ? "s.account_id" : "s.repo_id"; const value = "accountId" in scope ? scope.accountId : scope.repoId; const rows = await all( `${SESSION_SELECT} WHERE ${column} = ? ORDER BY (s.stopped_at IS NULL) DESC, s.started_at DESC LIMIT ${RECENT_LIMIT}`, value, ); const now = Date.now(); return rows.map((row) => toSession(row, now)); } export async function loadContainerSession(sessionId: string): Promise { const row = await first(`${SESSION_SELECT} WHERE s.id = ?`, sessionId); return row ? toSession(row, Date.now()) : null; }