Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,41 @@ Services with `pull_policy: always` still pull again in the start phase because
Compose applies that policy on every `up`; such pulls count against the start
budget.

Native authored projects with persistent storage also prove their storage with
short-lived helper containers. Each helper proof (one storage carrier: dependency
check, target observation, helper create, policy, start, response and cleanup) has
its own budget, separate from the start budget:

| Budget | Default | Variable | Range (ms) |
| --- | --- | --- | --- |
| One native storage carrier | 90 seconds | `HACK_NATIVE_STORAGE_TIMEOUT_MS` | 1,000–600,000 |

`HACK_COMPOSE_STARTUP_TIMEOUT_MS` neither shortens nor lengthens a storage proof:
carriers follow only `HACK_NATIVE_STORAGE_TIMEOUT_MS`. Storage
proofs that run while a native `up` or `restart` builds and starts workloads do not
consume the start budget: their elapsed time extends the start deadline, and each
proof stays bounded by its own carrier budget. Each Docker command inside a carrier
keeps its fixed 10–15 second cap.

The two variables bound different things:

- Per proof: each storage carrier ends within `HACK_NATIVE_STORAGE_TIMEOUT_MS` (S).
- Per start phase: the storage time excluded from one `up` or `restart` start
deadline may total at most 16 × S or 1 hour, whichever is smaller (24 minutes at
the 90-second default; 1 hour from S = 225,000 up). The start deadline moves
later by at most that cap. A proof that would start after the cap is spent is
refused before it runs, and a proof that ends past the cap refuses the phase.
- Whole phase: the workload phase therefore ends within
`HACK_COMPOSE_STARTUP_TIMEOUT_MS`, plus the cap, plus at most one in-flight
guard proof. Raising S raises both the per-proof bound and the cap; raising the
start budget never changes either.

When a carrier budget or the whole-phase cap runs out, the command reports the
closed effect reason `storage-budget` in `error.detail.native_compose_effect_refusal`,
and the human summary names `HACK_NATIVE_STORAGE_TIMEOUT_MS`. Exhaustion leaves the
same retained, uncertain state as any other storage refusal; inspect saved state
before retrying.

To share dependency data only across worktrees with the same lockfile/runtime fingerprint, label
the bootstrap service with a logical top-level Compose volume:

Expand Down
20 changes: 17 additions & 3 deletions docs/reference/native-compose-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,17 @@ fallback.

The first command slice supports whole-project detached `up`, `restart`, `run`,
`down`, `ps`, plain `logs`, and `exec`. Startup uses the existing configurable
Compose startup budget. Compose owns dependency startup, restart, and exec
Compose startup budget (`HACK_COMPOSE_STARTUP_TIMEOUT_MS`) for builds, container
start, readiness and route verification. Storage preparation, enrollment and
every storage proof use the separate per-carrier storage budget
(`HACK_NATIVE_STORAGE_TIMEOUT_MS`, default 90 seconds); proofs in the build and
pre-spawn guards extend the start deadline by their own elapsed time instead of
consuming it. That excluded time is capped per start phase at 16 × the storage
budget or 1 hour, whichever is smaller: the deadline moves at most that far, a
guard proof that would start after the cap is refused before it runs, and one
that ends past it refuses the phase as `storage-budget`. The phase thus ends
within the start budget, plus the cap, plus at most one in-flight guard proof.
Compose owns dependency startup, restart, and exec
health checks. Hack checks the exact proposed generation: each service must be
running, healthy when it declares a health check, and each initializer job must
have exited successfully. An older healthy container does not satisfy a new
Expand Down Expand Up @@ -337,8 +347,12 @@ expectation admission/write, journal, enrollment read, provision, root discovery
seed, verification, closing root proof, completion write and publication. Carrier
transport additionally distinguishes target, create, policy, start, response and
cleanup; policy reasons identify shape, command, labels, host policy, mount
cardinality, program/storage mounts and policy stability. Unknown errors remain
`unclassified`. Diagnostics contain no paths, resource names, helper replies,
cardinality, program/storage mounts and policy stability. A refusal that finds
its carrier's storage budget exhausted, and not cancelled, reports
`storage-budget`, which names `HACK_NATIVE_STORAGE_TIMEOUT_MS`; a start phase
that spends its whole-phase storage cap reports the same reason at
`guard-storage`. Unknown errors
remain `unclassified`. Diagnostics contain no paths, resource names, helper replies,
error messages or causes. They add no observation, retry, repair or cleanup and
do not change pending-state, deadline or effect admission rules. A stage names
the first captured boundary; it does not establish the cause of older failures
Expand Down
27 changes: 27 additions & 0 deletions docs/reference/native-compose-storage-xattr-carrier.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,33 @@ leaves durable uncertainty that only explicit recovery can report. Earlier
one-second identity reads and a three-second settlement budget refused healthy
commands that way on loaded hosts.

Budgets are layered and all finite:

- Each carrier (one helper proof session) has an absolute deadline from the
named storage budget `HACK_NATIVE_STORAGE_TIMEOUT_MS` (default 90 seconds,
1,000–600,000 ms). It is never derived from `HACK_COMPOSE_STARTUP_TIMEOUT_MS`,
so a short start budget cannot starve a helper proof and a long one does not
lengthen it.
- In a native `up` or `restart` start phase, guard proof time is excluded from
the start deadline but capped in total at 16 × the storage budget or 1 hour,
whichever is smaller. A guard proof that would start after the cap is spent is
refused before it runs, and one that ends past it refuses as `storage-budget`.
- Inside a carrier every Docker probe and command keeps a fixed cap, clipped to
the carrier's remaining budget: 15 seconds for reads, target observation and
helper create/start, and 10 seconds for helper removal and cleanup reads, which
are not clipped. A recorded command's 15-second settlement budget follows its
exit.
- An unrecorded command (an identity read or a legacy root/seed command) waits
at most three seconds for its process group to become absent after exit. Like
the handshake and settlement budgets, this deadline is monotonic
(`performance.now`), so a wall-clock step cannot shorten or extend it.
Exhaustion refuses as `probe-budget`.

A refusal that finds its carrier's own deadline passed (and the carrier not
cancelled) reports the closed effect reason `storage-budget`; a probe clipped to
the remaining budget times out exactly at that end. Cancellation keeps
`probe-cancel`. The reason changes no check, retention or recovery rule.

Only the original in-memory writer can advance its exact sequence. Publication
failure or deadline permanently invalidates that writer and retains its private
files/descriptors. A rename or sync may finish after refusal, so a serialized
Expand Down
140 changes: 140 additions & 0 deletions src/lib/compose-startup-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,20 @@ import { CliUsageError } from "../cli/command.ts";
export const DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS = 90_000;
/** Default budget for image build/pull and container creation before start. */
export const DEFAULT_COMPOSE_IMAGE_TIMEOUT_MS = 1_800_000;
/**
* Default budget of one native storage carrier: its dependency check, target
* observation, helper create/policy/start/response/cleanup and closing proof.
* It equals the earlier shared start default, so an unset host keeps the same
* per-carrier bound.
*/
export const DEFAULT_NATIVE_STORAGE_TIMEOUT_MS = 90_000;
export const COMPOSE_STARTUP_TIMEOUT_ENV = "HACK_COMPOSE_STARTUP_TIMEOUT_MS";
export const COMPOSE_IMAGE_TIMEOUT_ENV = "HACK_COMPOSE_IMAGE_TIMEOUT_MS";
export const NATIVE_STORAGE_TIMEOUT_ENV = "HACK_NATIVE_STORAGE_TIMEOUT_MS";
const MIN_TIMEOUT_MS = 1000;
const MAX_STARTUP_TIMEOUT_MS = 3_600_000;
const MAX_IMAGE_TIMEOUT_MS = 14_400_000;
const MAX_NATIVE_STORAGE_TIMEOUT_MS = 600_000;
const INTEGER_TEXT = /^[0-9]+$/;

/**
Expand Down Expand Up @@ -66,6 +75,137 @@ export function resolveComposeImageTimeoutMs(
});
}

/**
* Budget of one native storage carrier (one fresh helper proof session), from
* `HACK_NATIVE_STORAGE_TIMEOUT_MS`. It is separate from the start budget:
* storage preparation, enrollment and every storage proof use it, never
* `HACK_COMPOSE_STARTUP_TIMEOUT_MS`. Each Docker probe inside a carrier keeps its
* own fixed 10-15 s cap, and an exhausted carrier refuses with the closed
* `storage-budget` effect reason. It grants nothing and relaxes no check.
*/
export function resolveNativeStorageTimeoutMs(
opts: {
readonly storageTimeoutMs?: number;
readonly env?: Readonly<Record<string, string | undefined>>;
} = {}
): number {
return resolveBudget({
explicit: opts.storageTimeoutMs,
configured: (opts.env ?? process.env)[NATIVE_STORAGE_TIMEOUT_ENV],
fallback: DEFAULT_NATIVE_STORAGE_TIMEOUT_MS,
max: MAX_NATIVE_STORAGE_TIMEOUT_MS,
invalid: () =>
new CliUsageError(
`Native storage timeout must be an integer from ${MIN_TIMEOUT_MS} to ${MAX_NATIVE_STORAGE_TIMEOUT_MS} milliseconds (${NATIVE_STORAGE_TIMEOUT_ENV})`
),
});
}

/**
* Whole-phase cap on storage time excluded from one `up`/`restart` start
* deadline. Each guard storage proof is bounded per carrier by
* `HACK_NATIVE_STORAGE_TIMEOUT_MS` (S), but a phase runs up to (5B+3) guard
* proofs for B builds, each over every enrolled volume, so the sum alone is
* unbounded in practice. The excluded total may therefore not exceed
* {@link NATIVE_STORAGE_EXCLUSION_MULTIPLE} x S or
* {@link MAX_NATIVE_STORAGE_EXCLUSION_MS}, whichever is smaller: 24 minutes at
* the 90 s default and 1 hour from S = 225 s up. Ordinary proofs take a small
* fraction of S, so the cap only stops a phase whose proofs keep running near
* their per-proof budget.
*/
export const NATIVE_STORAGE_EXCLUSION_MULTIPLE = 16;
/** Absolute ceiling of excluded storage time in one start phase (1 hour). */
export const MAX_NATIVE_STORAGE_EXCLUSION_MS = 3_600_000;

/** Excluded storage time one start phase may use for a per-carrier budget S. */
export function nativeComposeStorageExclusionCapMs(
storageTimeoutMs: number
): number {
return Math.min(
NATIVE_STORAGE_EXCLUSION_MULTIPLE * storageTimeoutMs,
MAX_NATIVE_STORAGE_EXCLUSION_MS
);
}

/**
* Closed, value-free refusal once a start phase has spent its whole-phase
* storage exclusion cap. Its effect reason is `storage-budget`; it grants
* nothing and relaxes no check.
*/
export class NativeStorageTotalBudgetError extends Error {
constructor() {
super(
"Native storage proofs exhausted the whole-phase storage budget; values omitted."
);
this.name = "NativeStorageTotalBudgetError";
}
}

/**
* Start deadline of a native `up`/`restart` workload phase (image builds,
* container start, readiness and route verification), bounded by
* `HACK_COMPOSE_STARTUP_TIMEOUT_MS`. Storage proofs that pre-spawn guards run
* inside this phase are bounded by their own `HACK_NATIVE_STORAGE_TIMEOUT_MS`
* carriers instead: their monotonic elapsed time extends this deadline rather
* than consuming it. Overlapping exclusions count once, a rejected proof still
* counts as excluded time, and the deadline never moves earlier.
*
* The excluded total is capped by
* {@link nativeComposeStorageExclusionCapMs}: the deadline moves at most that
* far, a proof that would start at or past the cap is refused before it runs,
* and a proof that ends past it refuses the phase with
* {@link NativeStorageTotalBudgetError} (a failed proof keeps its own error).
* The phase therefore ends within the start budget, plus the cap, plus at
* most one in-flight guard proof, which is itself bounded per carrier.
*/
export function createNativeComposeStartDeadline(opts: {
readonly timeoutMs: number;
/** Per-carrier storage budget S, from {@link resolveNativeStorageTimeoutMs}. */
readonly storageTimeoutMs: number;
/** Wall clock that callers compare the deadline with; defaults to `Date.now`. */
readonly wallNow?: () => number;
/** Monotonic clock that measures excluded storage time. */
readonly monotonicNow?: () => number;
}): {
readonly deadline: () => number;
readonly excludeStorage: <T>(work: () => Promise<T>) => Promise<T>;
} {
const wallNow = opts.wallNow ?? Date.now;
const monotonicNow = opts.monotonicNow ?? (() => performance.now());
const capMs = nativeComposeStorageExclusionCapMs(opts.storageTimeoutMs);
const base = wallNow() + opts.timeoutMs;
let excluded = 0;
let active = 0;
let since = 0;
const spent = () =>
excluded + (active > 0 ? Math.max(0, monotonicNow() - since) : 0);
return {
deadline: () => base + Math.min(capMs, spent()),
excludeStorage: async (work) => {
if (spent() >= capMs) {
throw new NativeStorageTotalBudgetError();
}
if (active === 0) {
since = monotonicNow();
}
active += 1;
let value: Awaited<ReturnType<typeof work>>;
try {
value = await work();
} finally {
active -= 1;
if (active === 0) {
excluded += Math.max(0, monotonicNow() - since);
}
}
if (spent() > capMs) {
throw new NativeStorageTotalBudgetError();
}
return value;
},
};
}

function resolveBudget(opts: {
readonly explicit: number | undefined;
readonly configured: string | undefined;
Expand Down
23 changes: 11 additions & 12 deletions src/lib/native-compose-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,17 +326,22 @@ async function image(
export async function buildNativeComposeImages(opts: {
readonly intents: readonly NativeComposeBuildIntent[];
readonly projectRoot: string;
readonly deadline?: number;
/**
* Start-phase wall deadline, read at every check: storage proof time that
* `assertOwned` excludes from the start budget extends it while builds run.
*/
readonly deadline?: () => number;
readonly signal: AbortSignal;
readonly env: Record<string, string> | undefined;
readonly json: boolean;
readonly assertFresh: () => Promise<void>;
readonly assertOwned: () => Promise<void>;
readonly io?: BuildIo;
}): Promise<number> {
const deadline = opts.deadline;
const within = () => deadline === undefined || Date.now() < deadline();
const probe = (args: readonly string[]) => {
const remaining =
opts.deadline === undefined ? 15_000 : opts.deadline - Date.now();
const remaining = deadline === undefined ? 15_000 : deadline() - Date.now();
requireValue(remaining > 0);
return createNativeComposeProbe({
signal: opts.signal,
Expand Down Expand Up @@ -366,10 +371,7 @@ export async function buildNativeComposeImages(opts: {
};
let engine: string | null = null;
const fresh = async () => {
requireValue(
!opts.signal.aborted &&
(opts.deadline === undefined || Date.now() < opts.deadline)
);
requireValue(!opts.signal.aborted && within());
await opts.assertFresh();
await opts.assertOwned();
const observed = await io.engine();
Expand All @@ -379,10 +381,7 @@ export async function buildNativeComposeImages(opts: {
(engine === null || engine === observed)
);
engine = observed;
requireValue(
!opts.signal.aborted &&
(opts.deadline === undefined || Date.now() < opts.deadline)
);
requireValue(!opts.signal.aborted && within());
};
const admitted: { intent: NativeComposeBuildIntent; id: string }[] = [];
// Admit every selected cached tag before any builder is invoked.
Expand All @@ -402,7 +401,7 @@ export async function buildNativeComposeImages(opts: {
// Recheck after it, then admit spawn synchronously with the remaining budget.
await fresh();
const timeoutMs =
opts.deadline === undefined ? undefined : opts.deadline - Date.now();
deadline === undefined ? undefined : deadline() - Date.now();
requireValue(
!opts.signal.aborted && (timeoutMs === undefined || timeoutMs > 0)
);
Expand Down
8 changes: 5 additions & 3 deletions src/lib/native-compose-command-storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HackCliError } from "./cli-result.ts";
import { resolveComposeStartupTimeoutMs } from "./compose-startup-budget.ts";
import { resolveNativeStorageTimeoutMs } from "./compose-startup-budget.ts";
import { isRecord } from "./guards.ts";
import {
type NativeComposeEffectRefusal,
Expand Down Expand Up @@ -186,13 +186,15 @@ export async function prepareNativeComposeCommandStorage(opts: {
return refuse();
}
const engineId = await engine({ signal });
// Each fresh carrier gets the named storage budget, never the start budget:
// a short HACK_COMPOSE_STARTUP_TIMEOUT_MS must not starve helper proofs.
const carrier = (): Promise<NativeComposeStorageXattrCarrier> =>
createCarrier({
store,
authority: mutation.materialAuthority,
engineId,
signal,
deadline: Date.now() + resolveComposeStartupTimeoutMs(),
deadline: Date.now() + resolveNativeStorageTimeoutMs(),
originalCommandRecords: true,
});
// Check the explicit dependency before any authored hook, intent or volume effect.
Expand Down Expand Up @@ -383,7 +385,7 @@ export async function reconcileNativeComposeCommandStorage(opts: {
const engineId = await observeNativeComposeFileEngine({
signal: opts.signal,
});
const deadline = Date.now() + resolveComposeStartupTimeoutMs();
const deadline = Date.now() + resolveNativeStorageTimeoutMs();
for (const state of candidates) {
if (state.state !== "enrolled" || state.reference.version !== 3) {
continue;
Expand Down
Loading
Loading