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"; export const EDITOR_INSTANCE_TYPE = "standard-1"; export const EDITOR_METER = "compute.editor"; const MAX_OPEN_SESSIONS = 20; const HOUR_MS = 60 * 60 * 1000; 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 { return planAllowsEditorContainers((await billingSettings(accountId)).plan); } /** * 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. */ export async function openContainerSession( accountId: string, repoId: string, branch: string, budgetSeconds: number, ): Promise { const open = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM editor_container_sessions WHERE account_id = ? AND stopped_at IS NULL", accountId, ); if ((open?.count ?? 0) >= MAX_OPEN_SESSIONS) throw new Error("Too many running containers for this account"); const id = randomId(); const reservation = await reserveUsage({ id, accountId, meter: EDITOR_METER, quantity: budgetSeconds, ttlMs: budgetSeconds * SECOND_MS + HOUR_MS, }); try { await run( `INSERT INTO editor_container_sessions (id, account_id, repo_id, branch, instance_type, started_at) VALUES (?, ?, ?, ?, ?, ?)`, id, accountId, repoId, branch, EDITOR_INSTANCE_TYPE, Date.now(), ); } catch (error) { await releaseUsage(id).catch(() => undefined); throw error; } return reservation; } async function closeRow(sessionId: string, reason: string, now: number) { return first<{ account_id: string; repo_id: string; started_at: number }>( `UPDATE editor_container_sessions SET stopped_at = ?1, minutes = CAST(ROUND((?1 - started_at) / ${MINUTE_S * SECOND_MS}.0) AS INTEGER), stop_reason = ?2 WHERE id = ?3 AND stopped_at IS NULL RETURNING account_id, repo_id, started_at`, now, reason, sessionId, ); } /** Closes the session row and settles its hold with the exact seconds it ran. Returns those seconds. */ export async function closeContainerSession(reservation: Reservation, reason: string): Promise { const now = Date.now(); const row = await closeRow(reservation.id, reason, now); if (!row) return 0; const quantity = (now - row.started_at) / SECOND_MS; await commitUsage(reservation, { quantity, repoId: row.repo_id, attributes: { instanceType: EDITOR_INSTANCE_TYPE, 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 the elapsed time against the original hold when it still exists. */ export async function closeOrphanSession(sessionId: string, reason: string): Promise { const now = Date.now(); const row = await closeRow(sessionId, reason, now); if (!row) return 0; const quantity = (now - row.started_at) / SECOND_MS; const attributes = { instanceType: EDITOR_INSTANCE_TYPE, 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 }; await commitUsage(reservation, { quantity, repoId: row.repo_id, attributes }); } else { await recordUsage({ accountId: row.account_id, meter: EDITOR_METER, quantity, repoId: row.repo_id, attributes, idempotencyKey: `reservation:${sessionId}`, at: now, }); } return quantity; } 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; }