4eb9292cbe
## Summary
Three related changes, each independently gated:
**Queue metrics and health.** Per-queue depth, throughput (enqueued,
started, completed), concurrency, whether a queue is throttled, and
scheduling delay (how long a run waits between becoming eligible and
actually starting), plus a per concurrency-key breakdown for keyed
queues. Collected from inside the run queue itself, stored in
ClickHouse, and surfaced on the Queues list, a new per-queue detail
page, the task pages, and the run inspector. The question it answers is
"does this queue have enough concurrency to keep up, and if not, which
key or which limit is the constraint".
**Percent-based queue concurrency limits.** A queue's concurrency
override can now be expressed as a percentage of the environment limit,
stored as the source of truth and re-materialized whenever the
environment limit changes. Absolute overrides above the environment
limit are now **rejected with a 400** instead of being silently capped,
which is a behavior change on `POST
/api/v1/queues/:queue/concurrency/override`.
**The `health` report.** A server-computed verdict on whether work is
flowing, whether the runs that do start are healthy, and whether
telemetry is fresh, rendered as text with sparklines. Available as `GET
/api/v1/reports/:key`, `trigger report`, and the `get_report` MCP tool
(plus a `report` MCP prompt, which shows up as a slash command in hosts
that support prompts).
With the flags off, the Queues page renders the pre-metrics component
verbatim, nothing is emitted, and nothing is written to ClickHouse.
## Configuration
Two independent gates, on purpose. Emission is global so data accrues
for everyone before anyone can look at it; the view is per organization
so it can be turned on for one org at a time without a deploy.
**Runtime flags (no restart)**
| Flag | Store | Gates |
| --- | --- | --- |
| `queue_metrics:enabled` | run-queue Redis key (`"1"`/`"0"`, off by
default) | All emission, gauges and counters. Cached in-process for 10s
with stale-while-revalidate, warmed eagerly at boot so the first op
after a deploy is not dropped. |
| `queue_metrics:gauge_sample_rate` | run-queue Redis key, `0..1` |
Fraction of queue ops that emit a gauge. Counters are never sampled, so
throughput stays exact at any rate. |
| `queueMetricsUiEnabled` | feature-flag catalog: global `FeatureFlag`
row, per-org `Organization.featureFlags` override wins | Whether an org
sees the metrics view at all: the Queues list variant, the queue detail
route, the built-in Queues dashboard, the concurrency-keys endpoint, and
the metrics blocks on task pages and the run inspector. Off by default;
a gated org gets a 404 on the detail route rather than an empty page. |
Both Redis keys are readable and writable from `/admin/queue-metrics`
(super-admin UI, with a live per-shard stream-health table) and
`GET`/`POST /admin/api/v1/queue-metrics` (admin PAT). The admin surface
uses its own Redis client, so it works on any instance regardless of
whether that instance runs the emitter or the consumer.
**Environment variables (boot time)**
| Variable | Default | Notes |
| --- | --- | --- |
| `QUEUE_METRICS_EMIT_ENABLED` | `0` | Constructs the emitter and
injects it into the run engine. Without it the run queue has no emitter
at all. |
| `QUEUE_METRICS_CONSUMER_ENABLED` | `0` | Boots the stream consumer on
this instance. Independent of emission, so consumers can be sized
separately from the API. |
| `QUEUE_METRICS_STREAM_SHARD_COUNT` | `4` | Stream shards, hashed per
queue. |
| `QUEUE_METRICS_CONSUMER_BATCH_SIZE` | `1000` | Poll batch equals
insert batch, so an ack can never outrun a write. |
| `QUEUE_METRICS_REDIS_{HOST,PORT,USERNAME,PASSWORD,TLS_DISABLED}` |
falls back to the run-queue Redis | Set `HOST` to move the metrics
stream onto a dedicated instance so a metrics backlog cannot compete
with the run queue for memory. Self-hosters can leave it unset and get a
single-Redis deployment. |
| `QUEUE_METRICS_COUNTER_STREAM_MAXLEN` | `2000000` shared, `8000000`
dedicated | Bound on how much a stalled consumer can hold. The default
is deliberately lower when the stream shares the queue-critical Redis. |
| `QUEUE_METRICS_COUNTER_ODOMETER_TTL_SECONDS` | `604800` | TTL on the
per-queue cumulative counter key, refreshed on every write, so only
queues idle for the whole window are purged. |
| `QUEUE_METRICS_MAX_QUEUE_NAMES_PER_ENV` | `1000` | Distinct queue
names tracked per environment; overflow collapses into `__overflow__`. |
| `QUEUE_METRICS_MAX_CONCURRENCY_KEYS_PER_QUEUE` | `10000` | Same idea
one level down, per queue. |
| `QUEUE_METRICS_GAUGE_SAMPLE_RATE` | `1` | Default for the live
sample-rate key above. |
| `QUEUE_METRICS_QUERY_TABLES_VISIBLE` | `0` | Lists the queue-metrics
tables in the Query page, its schema docs, the schema API and the AI
query context. Off keeps them unlisted while the feature is dark; a
query naming them still runs either way. |
| `QUEUE_METRICS_CLICKHOUSE_URL` | falls back to the shared wiring |
Runs queue metrics on their own ClickHouse service: the consumer's
inserts and every queue-metrics read go through it, so a metrics-heavy
chart refresh never competes with runs-list or trace reads. Unset
reproduces the previous split exactly (inserts on `CLICKHOUSE_URL`,
reads on the query pool). |
| `QUEUE_METRICS_CLICKHOUSE_READER_URL` | the write URL | Reader split,
so the consumer's inserts can never land on a read endpoint. |
|
`QUEUE_METRICS_CLICKHOUSE_{KEEP_ALIVE_ENABLED,KEEP_ALIVE_IDLE_SOCKET_TTL_MS,MAX_OPEN_CONNECTIONS,LOG_LEVEL,COMPRESSION_REQUEST}`
| `1`, unset, `10`, `info`, `1` | Pool tuning, matching the other
per-workload ClickHouse clients. |
Migrations to apply: ClickHouse `036_create_queue_metrics_v1.sql`, and a
Postgres migration adding the nullable
`TaskQueue.concurrencyLimitOverridePercent`. Both are additive.
## How collection works
Queue operations produce two kinds of signal, and they have opposite
failure modes, so they are handled differently.
**Gauges** (queued, running, queue limit, env queued, env running, env
limit, throttled, plus keys-with-backlog and worst-key wait on keyed
queues) are read *inside* the same Redis script that performs the
enqueue or dequeue, so the reading is atomic with the operation it
describes rather than a racy follow-up read. The script returns them on
its reply and the app forwards them to the stream. Gauges are sampled
and drop-tolerant: they are aggregated with `max`, so a lost reading
costs resolution, never correctness.
**Counters** (enqueued, started, completed, plus nack and dead-lettered)
are cumulative odometers. Each event increments a per-queue key on the
metrics Redis and emits the absolute total, and ClickHouse takes the
difference across buckets at read time. This is the important property
of the design: a summed-delta counter undercounts permanently on any
lost event, while a cumulative one self-heals, because the next
surviving reading restates the whole total. Only bucket granularity can
be lost, never the total. A queue returning after its odometer TTL
expired restarts at 1 and reset detection handles it, which is safe
precisely because expiry only spans a window with no activity.
Both land on one sharded Redis stream. A consumer reads it with a
consumer group, reclaims stale pending entries on a 15s interval rather
than on every poll, maps one entry to one or two ClickHouse rows
(whole-queue and, for keyed queues, per-key), and acks only after the
insert lands. Each batch carries a dedup token derived from its
stream-entry ids, and the target tables set
`non_replicated_deduplication_window`, so a retried batch cannot
double-count either the raw rows or the aggregates that hang off them.
Consumer and emitter both emit OTel metrics
(`queue_metrics.emitter.emitted`,
`queue_metrics.consumer.{entries,rows_inserted,insert_errors,insert_duration,stream_depth,group_lag,pending,lag_unknown}`);
stream depth and group lag are the two worth alerting on, and
`lag_unknown` exists because Redis can report a null lag after a trim,
which must not be read as zero.
## Storage and read path
`queue_metrics_raw_v1` is a short landing table with a 6 hour TTL. Four
aggregate tiers are materialized straight from raw, never cascaded off
each other, each with a 30 day TTL:
- `queue_metrics_v1`, 10 second buckets per queue, the default read path
- `queue_metrics_5m_v1`, 5 minute buckets per queue, for wide ranges and
cross-queue ranking
- `env_metrics_v1`, 10 second buckets per environment, queue-independent
so it stays cheap at any range
- `queue_metrics_ck_v1`, 10 second buckets per concurrency key
Every tier is an MV from raw because the counter states do not survive a
cascade: their merge is order sensitive, so a `-MergeState` chain off
the 10s table inflates the result, and the same property means an
aggregate state may only be merged inside one queue. That constraint is
now enforced by the query engine rather than by reviewer discipline: a
column can declare a `mergeGroupKey`, and any query that references it
without grouping by, or pinning to a single value of, every named key
fails to compile with an actionable message.
On the read side, TRQL gains three tables (`queue_metrics`,
`env_metrics`, and a `queue_metrics_by_key` that is hidden from the
editor, schema docs and schema API but still queryable, so per-key rows
can never silently merge into a plain per-queue query), plus
`deltaSumTimestampMerge` and `quantilesTDigestMerge`. Two schema-level
optimizations ride along: a table can declare coarser rollups, so a
query whose bucket interval is 5 minutes or wider is routed to the 5m
table with no change to the query itself, and it can opt into the
ClickHouse query cache with time bounds floored to a fixed grid, so the
auto-refreshing dashboards actually share cache entries instead of
missing on every tick. Both are caller-side substitutions, so the
printer stays unaware of physical layout.
All of this can also live on its own ClickHouse service. A table
declares the pool its reads run on, the three queue-metrics tables name
the dedicated one, and the ingestion consumer writes through the same
client, so both directions move together with one env var and nothing
else routes differently.
The other engine change is opt-in gap filling: charts can request rows
for empty buckets, where counters zero-fill and gauges carry forward.
Grouped gauge series are densified per group and carried inside a
partition, so a quiet queue's line holds its last value without bleeding
another queue's value into it.
## Queue concurrency limits
`concurrencyLimitOverridePercent` on `TaskQueue` is the source of truth
when an override is set as a percentage; the absolute `concurrencyLimit`
is materialized from it (floored, clamped to at least 1 so a percentage
can never act as a pause, and never above the environment limit). Every
path that changes an environment limit now recalculates the
environment's percent-based overrides afterwards, outside the
transaction, and pushes changed limits to the engine. The push is
attempted even when the stored value did not change, so a previously
failed sync self-heals rather than leaving the database and the engine
diverged; paused queues are skipped so a recalculation cannot
effectively unpause one.
The API accepts exactly one of `concurrencyLimit` or `percent`, and the
reject-instead-of-clamp change above means a request asking for more
than the environment allows now fails loudly. The percent bound (greater
than 0, at most 100) is defined once and shared by the zod schema, the
dashboard mutation handler and the service, so the three cannot drift.
The concurrency-keys table on a queue is now paginated against the
ClickHouse per-key tier, ranked by peak backlog with the total on every
row from a single scan, and only the keys on the current page are
enriched with live counts from Redis. That replaces a hard top-50 cap
with something whose cost is a function of page size rather than key
cardinality.
## The health report
`GET /api/v1/reports/:key?period=&format=markdown|ansi|json`. The
verdict is computed on the server and is deterministic, not
model-generated. Three independent analyzers run over one input
snapshot: flow (is work moving, and if not, is the cause a limit,
throttling, one bad queue, or dead-lettering), execution (are the runs
that start succeeding, and at what latency), and liveness (how fresh is
the telemetry). When telemetry is genuinely stale, the first two are
forced to unknown and every actionable field is stripped, so no surface
ever advises action off stale data.
Authorization is per query table rather than a blanket query grant: a
JWT must be scoped to every table the report reads (`runs`,
`env_metrics`, `queue_metrics`), so a narrowly scoped token cannot pull
a report that reads more than it was granted. `period` is validated as a
shorthand with a 90 day ceiling at the edge. The report catalog is a
registry of `{ load, interpret }` entries, so the next report is a new
entry and no change to the route, the view model, the renderers, the CLI
or the MCP tool.
`trigger mcp` no longer launches the install wizard when stdout is a
TTY, which fixed a real failure: hosts spawn the server over a PTY, so
the wizard would open and the client would time out waiting for a server
that never started. The wizard now needs `trigger mcp --install`.
## The part that is live regardless of every flag
The enqueue and dequeue scripts now return a 2-tuple so a gauge reading
can ride back on the reply. Every return site in the eight affected
scripts is wrapped, and a `nil` original is converted to `false` on the
way out, because a raw `nil` in the first slot would make Lua truncate
the multi-bulk reply and silently drop the gauge on the throttled and
empty-queue paths. The reply shape and the destructuring on the app side
are exercised on every queue operation whether or not metrics are
enabled, so that is the part of `run-engine` worth the closest review.
One behavior fix in the same area: the scheduling-delay anchor is set
only on a run's first entry into the queue. Anchoring it to trigger time
on re-enqueues made waitpoint and checkpoint resumes report the entire
wait as scheduling delay. Queue ordering is untouched, so a re-enqueued
run keeps its position, and nacks deliberately keep the original anchor
because a rolled-back dequeue is the same continuous wait.
A pending-version promotion still anchors to trigger time, on purpose:
that promotion is the run's first real entry into the queue, since the
trigger deliberately held it back waiting for a worker version, and the
TTL is armed at the same point for the same reason. The consequence is
worth naming, because it is a judgement call: a run that waits on a
deployment reports that wait as scheduling delay on its queue, which is
time unrelated to queue capacity.
## Verification
Unit and integration suites across the new package, the run queue, the
mapping layer, the query engine and ClickHouse (including a test that
applies migration 036 through the same splitter CI uses, and a
regression test that inserts the same batch three times to prove the
aggregates do not inflate). Beyond that, the whole path was driven end
to end against a live stack with real runs: emitter to Redis stream to
consumer to ClickHouse to the dashboards, for both the local dev path
and the deployed path where a supervisor drives the dequeue, with
assertions on exact counter reconstruction per queue and per concurrency
key, throttling, environment saturation, scheduling delay, and a
deliberate mid-stream reading drop to confirm the cumulative counters
still reconstruct the correct total. The gated-off state was checked on
every touched surface.
The dedicated ClickHouse service was verified against a second,
separately-schema'd instance: with it configured, the driven counters
reconstruct exactly on the dedicated instance, the shared instance gains
no rows for that window, a read through the query API returns the value
that exists only on the dedicated instance, and a `runs` query still
succeeds (it would fail outright if it were mis-routed to a service
without that table). With the variable unset, the full suite passes
unchanged.
---------
Co-authored-by: Katia Bulatova <katia@trigger.dev>
Co-authored-by: Katia Bulatova <katherine.bulatova@gmail.com>
Co-authored-by: James Ritchie <james@trigger.dev>
2218 lines
109 KiB
TypeScript
2218 lines
109 KiB
TypeScript
import { z } from "zod";
|
||
import { MachinePresetName } from "@trigger.dev/core/v3";
|
||
import { BoolEnv } from "./utils/boolEnv";
|
||
import { isValidDatabaseUrl } from "./utils/db";
|
||
import { isValidRegex } from "./utils/regex";
|
||
import { isValidDuration } from "./services/realtime/duration.server";
|
||
|
||
// `z.string()` constrained to a `parseDuration`-parseable string (e.g.
|
||
// `7d`, `1h`). Validated at boot so a typo'd duration fails fast.
|
||
function durationString() {
|
||
return z.string().refine(isValidDuration, "must be a duration like 7d, 30d, 365d, 1h, 1y");
|
||
}
|
||
|
||
// Parses a CSV of machine preset names (e.g. "small-1x,small-2x") into a
|
||
// non-empty array of MachinePresetName. Used by COMPUTE_TEMPLATE_MACHINE_PRESETS
|
||
// and its _REQUIRED variant. Adds zod issues for empty input or unknown names.
|
||
const parseMachinePresetCsv = (raw: string, ctx: z.RefinementCtx): MachinePresetName[] => {
|
||
const names = raw
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
if (names.length === 0) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: "must list at least one machine preset",
|
||
});
|
||
return z.NEVER;
|
||
}
|
||
const out: MachinePresetName[] = [];
|
||
for (const name of names) {
|
||
const parsed = MachinePresetName.safeParse(name);
|
||
if (!parsed.success) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: `unknown machine preset: "${name}"`,
|
||
});
|
||
return z.NEVER;
|
||
}
|
||
out.push(parsed.data);
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const GithubAppEnvSchema = z.preprocess(
|
||
(val) => {
|
||
const obj = val as any;
|
||
if (!obj || !obj.GITHUB_APP_ENABLED) {
|
||
return { ...obj, GITHUB_APP_ENABLED: "0" };
|
||
}
|
||
return obj;
|
||
},
|
||
z.discriminatedUnion("GITHUB_APP_ENABLED", [
|
||
z.object({
|
||
GITHUB_APP_ENABLED: z.literal("1"),
|
||
GITHUB_APP_ID: z.string(),
|
||
GITHUB_APP_PRIVATE_KEY: z.string(),
|
||
GITHUB_APP_WEBHOOK_SECRET: z.string(),
|
||
GITHUB_APP_SLUG: z.string(),
|
||
}),
|
||
z.object({
|
||
GITHUB_APP_ENABLED: z.literal("0"),
|
||
}),
|
||
])
|
||
);
|
||
|
||
// eventually we can make all S2 env vars required once the S2 OSS version is out
|
||
const S2EnvSchema = z.preprocess(
|
||
(val) => {
|
||
const obj = val as any;
|
||
if (!obj || !obj.S2_ENABLED) {
|
||
return { ...obj, S2_ENABLED: "0" };
|
||
}
|
||
return obj;
|
||
},
|
||
z.discriminatedUnion("S2_ENABLED", [
|
||
z.object({
|
||
S2_ENABLED: z.literal("1"),
|
||
S2_ACCESS_TOKEN: z.string(),
|
||
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
|
||
S2_DEPLOYMENT_STREAMS_LOCAL: z.string().default("0"),
|
||
}),
|
||
z.object({
|
||
S2_ENABLED: z.literal("0"),
|
||
}),
|
||
])
|
||
);
|
||
|
||
// Previously published secret values must never be accepted, including when
|
||
// an existing deployment or external secret manager still supplies one.
|
||
const INSECURE_SECRET_VALUES = [
|
||
"managed-secret",
|
||
"2818143646516f6fffd707b36f334bbb",
|
||
"44da78b7bbb0dfe709cf38931d25dcdd",
|
||
"f686147ab967943ebbe9ed3b496e465a",
|
||
"447c29678f9eaf289e9c4b70d3dd8a7f",
|
||
];
|
||
|
||
// Escape hatch for deployments that can't rotate a published default yet (e.g.
|
||
// ENCRYPTION_KEY protects existing data). Read raw: a refine can't see the
|
||
// sibling parsed flag.
|
||
const allowInsecureDefaultSecrets = ["true", "1"].includes(
|
||
(process.env.ALLOW_INSECURE_DEFAULT_SECRETS ?? "").toLowerCase().trim()
|
||
);
|
||
|
||
const isNotInsecureSecret = (value: string) =>
|
||
allowInsecureDefaultSecrets || !INSECURE_SECRET_VALUES.includes(value);
|
||
|
||
const INSECURE_SECRET_MESSAGE =
|
||
"must not be a known-insecure published default; set a strong, unique value. If you cannot rotate it yet (e.g. it protects existing encrypted data or active sessions), set ALLOW_INSECURE_DEFAULT_SECRETS=1 to boot while you migrate.";
|
||
|
||
const EnvironmentSchema = z
|
||
.object({
|
||
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
|
||
DATABASE_URL: z
|
||
.string()
|
||
.refine(
|
||
isValidDatabaseUrl,
|
||
"DATABASE_URL is invalid, for details please check the additional output above this message."
|
||
),
|
||
DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10),
|
||
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
|
||
DATABASE_CONNECTION_TIMEOUT: z.coerce.number().int().default(20),
|
||
// Dashboard-agent conversation store. Cloud points this at a dedicated
|
||
// database; when unset it falls back to DATABASE_URL (OSS), where
|
||
// the tables live in the isolated `trigger_dashboard_agent` schema.
|
||
DASHBOARD_AGENT_DATABASE_URL: z.string().optional(),
|
||
// The secret key (tr_*) for the runtime environment the dashboard-agent task
|
||
// is deployed to. The chat session is created in that environment via the
|
||
// standard chat.agent SDK flow. When unset, the live agent is disabled — the
|
||
// conversation store / History still work, no chat can start.
|
||
DASHBOARD_AGENT_SECRET_KEY: z.string().optional(),
|
||
// Pins agent sessions to a specific deployed version (paired with
|
||
// --skip-promotion deploys); unset => the project env's current version.
|
||
DASHBOARD_AGENT_VERSION: z.string().optional(),
|
||
// Global default for the `hasDashboardAgentAccess` flag. "0" (off) ships the
|
||
// agent dark; flip to "1" to enable it for everyone at GA. Per-org overrides
|
||
// (org featureFlags) win regardless.
|
||
DASHBOARD_AGENT_ENABLED: z.string().default("0"),
|
||
// Gates the create-org management API endpoint (default off).
|
||
ORG_CREATION_API_ENABLED: z.string().default("0"),
|
||
// "1" gives admins/impersonators an everywhere-preview (default off),
|
||
// separate from the per-org rollout flag above.
|
||
DASHBOARD_AGENT_ADMIN_PREVIEW: z.string().default("0"),
|
||
// Anthropic key for the dashboard agent's Head Start route only (the warm
|
||
// first-turn step-1 LLM call runs in this process). The agent run itself
|
||
// uses its own key on the Trigger side. When unset, Head Start is disabled
|
||
// and the first turn falls back to the normal cold-start path.
|
||
ANTHROPIC_API_KEY: z.string().optional(),
|
||
DIRECT_URL: z
|
||
.string()
|
||
.refine(
|
||
isValidDatabaseUrl,
|
||
"DIRECT_URL is invalid, for details please check the additional output above this message."
|
||
),
|
||
DATABASE_READ_REPLICA_URL: z.string().optional(),
|
||
// --- Run-ops DB split — Cloud-only scaling concern; OFF by default. ---
|
||
// Explicit positive opt-in. Split behavior is unreachable unless this is true
|
||
// AND the distinct-DB sentinel confirms the two URLs are physically distinct DBs.
|
||
RUN_OPS_SPLIT_ENABLED: BoolEnv.default(false),
|
||
// Canonical connection URL for the dedicated NEW run-ops DB — drives the runtime pool, the split
|
||
// decision, replication, and migrations. Optional so single-DB installs never set it.
|
||
RUN_OPS_DATABASE_URL: z
|
||
.string()
|
||
.refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_URL is invalid")
|
||
.optional(),
|
||
// The LEGACY run-ops DB. Now a CONNECTED DSN (Track 2): when split is on and this is set it builds
|
||
// an INDEPENDENT legacy Prisma client, no longer an alias of the control-plane client (nor merely
|
||
// the sentinel's probe target). Unset -> legacy reuses the control-plane client / DATABASE_URL, so
|
||
// single-DB and self-host installs boot byte-identical.
|
||
RUN_OPS_LEGACY_DATABASE_URL: z
|
||
.string()
|
||
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_URL is invalid")
|
||
.optional(),
|
||
// The NEW dedicated run-ops DB read replica. Optional; self-host never sets it.
|
||
// Refined (unlike the unrefined control-plane DATABASE_READ_REPLICA_URL) so a malformed run-ops
|
||
// replica URL fails boot loudly rather than silently degrading — do not align it down to the CP shape.
|
||
RUN_OPS_DATABASE_READ_REPLICA_URL: z
|
||
.string()
|
||
.refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_READ_REPLICA_URL is invalid")
|
||
.optional(),
|
||
// The LEGACY run-ops DB read replica (Track 2). Unset -> the legacy replica handle falls back to the
|
||
// legacy WRITER (as $replica does with no CP replica). Set in production so legacy reads hit the reader.
|
||
RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL: z
|
||
.string()
|
||
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is invalid")
|
||
.optional(),
|
||
// Optional cap for the unpooled new run-ops read replica. Unset falls back to DATABASE_CONNECTION_LIMIT.
|
||
RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT: z.coerce.number().int().optional(),
|
||
// Direct DSN for applying the full @trigger.dev/database migrations to the LEGACY run-ops DB, keeping
|
||
// its schema current after the control plane moves off it. Direct, not pooled — migrations never run
|
||
// over a pooler. Optional; unset -> the entrypoint's legacy migrate step is skipped.
|
||
RUN_OPS_LEGACY_DIRECT_URL: z
|
||
.string()
|
||
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DIRECT_URL is invalid")
|
||
.optional(),
|
||
// Advisory control-plane co-residency sentinel enforcement (Track 2, T2.3). Default OFF; the advisory
|
||
// arm always emits its metric, this only turns a still-co-resident pair into a hard boot failure.
|
||
RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT: BoolEnv.default(false),
|
||
// --- Control-plane datasource repoint. Additive-only. ---
|
||
// Optional control-plane DB. Unset (self-host/single-DB) -> getClient()/getReplicaClient() fall back to
|
||
// DATABASE_URL/DATABASE_READ_REPLICA_URL, so boot is byte-identical. When set, these point at the
|
||
// dedicated control-plane DSN; moving off the shared DB is an ops config change, not a code edit.
|
||
CONTROL_PLANE_DATABASE_URL: z
|
||
.string()
|
||
.refine(
|
||
(v) => v === undefined || isValidDatabaseUrl(v),
|
||
"CONTROL_PLANE_DATABASE_URL is invalid"
|
||
)
|
||
.optional(),
|
||
CONTROL_PLANE_DATABASE_READ_REPLICA_URL: z.string().optional(),
|
||
// Control-plane cache relax knobs. Unset -> defaults (DEFAULT_CP_CACHE_TTL_MS / _MAX_ENTRIES).
|
||
CONTROL_PLANE_CACHE_TTL_MS: z.coerce.number().int().optional(),
|
||
CONTROL_PLANE_CACHE_MAX_ENTRIES: z.coerce.number().int().optional(),
|
||
SESSION_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
|
||
MAGIC_LINK_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
|
||
ENCRYPTION_KEY: z
|
||
.string()
|
||
.refine(
|
||
(val) => Buffer.from(val, "utf8").length === 32,
|
||
"ENCRYPTION_KEY must be exactly 32 bytes"
|
||
)
|
||
.refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
|
||
WHITELISTED_EMAILS: z
|
||
.string()
|
||
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
|
||
.optional(),
|
||
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
|
||
REMIX_APP_PORT: z.string().optional(),
|
||
// Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port.
|
||
// Read directly from process.env in server.ts (before this schema loads); declared here for discoverability.
|
||
WEBAPP_TELNET_LOGS_PORT: z.coerce.number().optional(),
|
||
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
|
||
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
|
||
APP_ORIGIN: z.string().default("http://localhost:3030"),
|
||
API_ORIGIN: z.string().optional(),
|
||
// Alternative API origin for deployed runs whose org has the
|
||
// internalApiOriginEnabled feature flag on. Unset = flag is a no-op.
|
||
INTERNAL_API_ORIGIN: z.string().optional(),
|
||
// Global default for internalApiOriginEnabled when an org hasn't set it.
|
||
INTERNAL_API_ORIGIN_ENABLED: z.string().default("0"),
|
||
STREAM_ORIGIN: z.string().optional(),
|
||
ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"),
|
||
// A comma separated list of electric origins to shard into different electric instances by environmentId
|
||
// example: "http://localhost:3060,http://localhost:3061,http://localhost:3062"
|
||
ELECTRIC_ORIGIN_SHARDS: z.string().optional(),
|
||
APP_ENV: z.string().default(process.env.NODE_ENV),
|
||
SERVICE_NAME: z.string().default("trigger.dev webapp"),
|
||
SENTRY_DSN: z.string().optional(),
|
||
POSTHOG_PROJECT_KEY: z.string().default("phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"),
|
||
// Upstream hosts the /ph reverse proxy forwards to (defaults: PostHog Cloud
|
||
// EU). The client points api_host at the same-origin /ph path; the proxy
|
||
// fans out to the ingest vs assets host by path.
|
||
POSTHOG_INGEST_HOST: z.string().default("eu.i.posthog.com"),
|
||
POSTHOG_ASSETS_HOST: z.string().default("eu-assets.i.posthog.com"),
|
||
// PostHog app host, used for the browser toolbar (ui_host) and the server
|
||
// client. Set to https://us.posthog.com for a US project (also switch the
|
||
// ingest/assets hosts to their us.i / us-assets equivalents).
|
||
POSTHOG_HOST: z.string().default("https://eu.posthog.com"),
|
||
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
|
||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
|
||
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||
FROM_EMAIL: z.string().optional(),
|
||
REPLY_TO_EMAIL: z.string().optional(),
|
||
RESEND_API_KEY: z.string().optional(),
|
||
SMTP_HOST: z.string().optional(),
|
||
SMTP_PORT: z.coerce.number().optional(),
|
||
SMTP_SECURE: BoolEnv.optional(),
|
||
SMTP_USER: z.string().optional(),
|
||
SMTP_PASSWORD: z.string().optional(),
|
||
|
||
PLAIN_API_KEY: z.string().optional(),
|
||
PLAIN_CUSTOMER_CARDS_SECRET: z.string().optional(),
|
||
PLAIN_CUSTOMER_CARDS_KEY: z.string().optional(),
|
||
PLAIN_CUSTOMER_CARDS_HEADERS: z.string().optional(),
|
||
// How often each replica reloads the global flags snapshot from the DB.
|
||
// Sets kill/ramp propagation latency.
|
||
GLOBAL_FLAGS_RELOAD_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000),
|
||
WORKER_ENABLED: z.string().default("true"),
|
||
GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().default(60000),
|
||
DISABLE_SSE: z.string().optional(),
|
||
OPENAI_API_KEY: z.string().optional(),
|
||
|
||
// Redis options
|
||
REDIS_HOST: z.string().optional(),
|
||
REDIS_READER_HOST: z.string().optional(),
|
||
REDIS_READER_PORT: z.coerce.number().optional(),
|
||
REDIS_PORT: z.coerce.number().optional(),
|
||
REDIS_USERNAME: z.string().optional(),
|
||
REDIS_PASSWORD: z.string().optional(),
|
||
REDIS_TLS_DISABLED: z.string().optional(),
|
||
|
||
RATE_LIMIT_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
RATE_LIMIT_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
RATE_LIMIT_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
RATE_LIMIT_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
RATE_LIMIT_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
RATE_LIMIT_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
RATE_LIMIT_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
|
||
CACHE_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
CACHE_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
CACHE_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
CACHE_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
CACHE_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
CACHE_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
CACHE_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
CACHE_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
|
||
TASK_META_CACHE_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
TASK_META_CACHE_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
TASK_META_CACHE_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
TASK_META_CACHE_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
TASK_META_CACHE_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
TASK_META_CACHE_CURRENT_ENV_TTL_SECONDS: z.coerce.number().default(86400),
|
||
|
||
// Runs-list empty-state check: how far back the ClickHouse "does this env have any run"
|
||
// probe looks. Bounds the prove-absence partition scan. 0 = unbounded ("any run ever").
|
||
RUN_LIST_HAS_RUNS_LOOKBACK_DAYS: z.coerce.number().default(30),
|
||
// SWR TTLs for the empty-state has-runs cache (memory + Redis).
|
||
RUN_LIST_HAS_RUNS_CACHE_FRESH_MS: z.coerce.number().default(86_400_000),
|
||
RUN_LIST_HAS_RUNS_CACHE_STALE_MS: z.coerce.number().default(604_800_000),
|
||
TASK_META_CACHE_BY_WORKER_TTL_SECONDS: z.coerce.number().default(2592000),
|
||
|
||
REALTIME_STREAMS_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
REALTIME_STREAMS_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
REALTIME_STREAMS_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
REALTIME_STREAMS_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
REALTIME_STREAMS_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
REALTIME_STREAMS_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
REALTIME_STREAMS_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
REALTIME_STREAMS_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
REALTIME_STREAMS_INACTIVITY_TIMEOUT_MS: z.coerce.number().int().default(60000), // 1 minute
|
||
|
||
REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(24 * 60 * 60 * 1000), // 1 day in milliseconds
|
||
|
||
// Master switch for the native realtime backend; off = Electric serves everything, publishes no-op.
|
||
REALTIME_BACKEND_NATIVE_ENABLED: z.string().default("0"),
|
||
// Default backend when an org has no `realtimeBackend` override and no global flag row is set.
|
||
REALTIME_BACKEND_DEFAULT: z.enum(["electric", "native", "shadow"]).default("electric"),
|
||
// Live long-poll backstop hold (ms); matches Electric's ~20s cadence.
|
||
REALTIME_BACKEND_NATIVE_LIVE_POLL_TIMEOUT_MS: z.coerce.number().int().default(20_000),
|
||
// Jitter ratio on the live-poll hold (0.15 = ±15%) to avoid synchronized refetch herds.
|
||
REALTIME_BACKEND_NATIVE_LIVE_POLL_JITTER_RATIO: z.coerce.number().default(0.15),
|
||
// Hard cap on the tag-list snapshot size.
|
||
REALTIME_BACKEND_NATIVE_MAX_LIST_RESULTS: z.coerce.number().int().default(1_000),
|
||
// TTL/size of the coalescing cache for the multi-run resolve+hydrate (same-filter feeds share one query).
|
||
REALTIME_BACKEND_NATIVE_RUNSET_CACHE_TTL_MS: z.coerce.number().int().default(1_000),
|
||
REALTIME_BACKEND_NATIVE_RUNSET_CACHE_MAX_ENTRIES: z.coerce.number().int().default(5_000),
|
||
// Size/TTL of the per-handle working-set cache used to diff multi-run live polls.
|
||
REALTIME_BACKEND_NATIVE_WORKING_SET_MAX_ENTRIES: z.coerce.number().int().default(10_000),
|
||
REALTIME_BACKEND_NATIVE_WORKING_SET_TTL_MS: z.coerce.number().int().default(300_000),
|
||
// Bucket (ms) the tag-list createdAt floor is quantized to so same-tag feeds share a cache entry; 0 disables.
|
||
REALTIME_BACKEND_NATIVE_RUNSET_CREATED_AT_BUCKET_MS: z.coerce.number().int().default(60_000),
|
||
// Leading-edge throttle (ms) on per-env wake delivery; 0 wakes on every change.
|
||
REALTIME_BACKEND_NATIVE_ENV_WAKE_COALESCE_WINDOW_MS: z.coerce.number().int().default(250),
|
||
// "1" shares per-connection replay cursors fleet-wide via Redis, so a load-balancer hop reads the connection's true inter-poll gap instead of cold-resolving.
|
||
REALTIME_BACKEND_NATIVE_SHARED_REPLAY_CURSORS: z.string().default("1"),
|
||
// "1" holds a multi-run live poll open on a non-matching wake instead of replying up-to-date.
|
||
REALTIME_BACKEND_NATIVE_HOLD_ON_EMPTY: z.string().default("1"),
|
||
// Max concurrent fresh ClickHouse resolves per instance (reconnect-stampede gate); 0 disables.
|
||
REALTIME_BACKEND_NATIVE_RESOLVE_ADMISSION_LIMIT: z.coerce.number().int().default(16),
|
||
// Replay window (ms) for buffered change records delivered to newly-armed feeds; 0 disables.
|
||
REALTIME_BACKEND_NATIVE_REPLAY_WINDOW_MS: z.coerce.number().int().default(2_000),
|
||
// Cap on buffered recent records per env (latest record per run).
|
||
REALTIME_BACKEND_NATIVE_REPLAY_MAX_RUNS: z.coerce.number().int().default(512),
|
||
// Keep an env subscribed + buffering this long (ms) after its last feed closes; 0 disables.
|
||
REALTIME_BACKEND_NATIVE_UNSUBSCRIBE_LINGER_MS: z.coerce.number().int().default(5_000),
|
||
// Fallback per-env concurrent-connection limit when the org has none configured.
|
||
REALTIME_BACKEND_NATIVE_DEFAULT_CONCURRENCY_LIMIT: z.coerce.number().int().default(100_000),
|
||
// TTL/size of the single-run read-through cache that collapses duplicate refetch bursts.
|
||
REALTIME_BACKEND_NATIVE_RUN_CACHE_TTL_MS: z.coerce.number().int().default(250),
|
||
REALTIME_BACKEND_NATIVE_RUN_CACHE_MAX_ENTRIES: z.coerce.number().int().default(5_000),
|
||
// TTL/size of the per-org realtimeBackend flag cache used to pick the serving backend.
|
||
REALTIME_BACKEND_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000),
|
||
REALTIME_BACKEND_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(50_000),
|
||
REALTIME_BACKEND_NATIVE_RUN_READS_FROM_PRIMARY: z.string().default("0"),
|
||
// "1" enables the read-your-writes gate: wake hydrates wait out the measured replica lag
|
||
// (anchored to the change record's updatedAtMs) and stale reads are retried.
|
||
REALTIME_BACKEND_NATIVE_REPLICA_LAG_GATE_ENABLED: z.string().default("1"),
|
||
// Reader-side lag probe cadence while the router is active; probing pauses when idle.
|
||
REALTIME_BACKEND_NATIVE_REPLICA_LAG_SAMPLE_INTERVAL_MS: z.coerce.number().int().default(250),
|
||
REALTIME_BACKEND_NATIVE_REPLICA_LAG_IDLE_AFTER_MS: z.coerce.number().int().default(30_000),
|
||
// The lag estimate is the max sample inside this window (spikes widen it immediately).
|
||
REALTIME_BACKEND_NATIVE_REPLICA_LAG_WINDOW_MS: z.coerce.number().int().default(5_000),
|
||
// Estimate before the first sample lands (and the floor when probing is unavailable).
|
||
REALTIME_BACKEND_NATIVE_REPLICA_LAG_DEFAULT_MS: z.coerce.number().int().default(30),
|
||
// Safety margin (clock skew + scheduling) added on top of the lag estimate.
|
||
REALTIME_BACKEND_NATIVE_REPLICA_LAG_MARGIN_MS: z.coerce.number().int().default(10),
|
||
// Hard cap on any single gate delay — a sick replica degrades freshness, never liveness.
|
||
REALTIME_BACKEND_NATIVE_REPLICA_LAG_MAX_DELAY_MS: z.coerce.number().int().default(1_000),
|
||
// Re-hydrate attempts for rows the tripwire still finds stale after the delay.
|
||
REALTIME_BACKEND_NATIVE_STALE_HYDRATE_RETRIES: z.coerce.number().int().default(3),
|
||
// How long a tripwire-observed staleness floors the lag estimate (vanilla-PG replicas
|
||
// can't measure mid-apply lag, so observations carry the estimate between races).
|
||
REALTIME_BACKEND_NATIVE_REPLICA_LAG_OBSERVED_FLOOR_TTL_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000),
|
||
|
||
PUBSUB_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
PUBSUB_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
PUBSUB_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
PUBSUB_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
PUBSUB_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
PUBSUB_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
PUBSUB_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
PUBSUB_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
|
||
// Dedicated pub/sub Redis for the native realtime backend; falls back to PUBSUB_REDIS_* then REDIS_*.
|
||
REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.PUBSUB_REDIS_HOST ?? process.env.REDIS_HOST),
|
||
REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform((v) => {
|
||
if (v !== undefined) return v;
|
||
const raw = process.env.PUBSUB_REDIS_PORT ?? process.env.REDIS_PORT;
|
||
return raw ? parseInt(raw) : undefined;
|
||
}),
|
||
REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.PUBSUB_REDIS_USERNAME ?? process.env.REDIS_USERNAME),
|
||
REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.PUBSUB_REDIS_PASSWORD ?? process.env.REDIS_PASSWORD),
|
||
REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.PUBSUB_REDIS_TLS_DISABLED ?? process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_CLUSTER_MODE_ENABLED: z
|
||
.string()
|
||
.default(process.env.PUBSUB_REDIS_CLUSTER_MODE_ENABLED ?? "0"),
|
||
// Use sharded pub/sub (SSUBSCRIBE/SPUBLISH) in cluster mode; "0" forces classic pub/sub.
|
||
REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_SHARDED_ENABLED: z.string().default("1"),
|
||
|
||
DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(100),
|
||
DEFAULT_ENV_EXECUTION_CONCURRENCY_BURST_FACTOR: z.coerce.number().default(1.0),
|
||
DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(300),
|
||
DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1),
|
||
|
||
//API Rate limiting
|
||
/**
|
||
* @example "60s"
|
||
* @example "1m"
|
||
* @example "1h"
|
||
* @example "1d"
|
||
* @example "1000ms"
|
||
* @example "1000s"
|
||
*/
|
||
API_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"), // refill 250 tokens every 10 seconds
|
||
API_RATE_LIMIT_MAX: z.coerce.number().int().default(750), // allow bursts of 750 requests
|
||
API_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(250), // refix 250 tokens every 10 seconds
|
||
API_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
|
||
API_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
|
||
API_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
|
||
|
||
API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"),
|
||
API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60),
|
||
|
||
// Per-IP rate limit for the unauthenticated OTLP ingestion endpoints
|
||
// (/otel/*). Bounds unauthenticated request rates. Opt-in
|
||
// (disabled by default): because it keys on the source IP, it is only
|
||
// safe to enable when each client presents a distinct IP through a proxy
|
||
// that appends the real client IP to X-Forwarded-For. Enabling it where
|
||
// many clients share one egress IP (e.g. behind NAT or a shared proxy)
|
||
// would collapse that traffic into a single bucket and could throttle
|
||
// legitimate telemetry. Set OTLP_RATE_LIMIT_ENABLED=1 to enable, then tune
|
||
// OTLP_RATE_LIMIT_MAX / OTLP_RATE_LIMIT_WINDOW for expected volume.
|
||
OTLP_RATE_LIMIT_ENABLED: z.string().default("0"),
|
||
OTLP_RATE_LIMIT_WINDOW: z
|
||
.string()
|
||
.regex(/^\d+ ?(?:ms|s|m|h|d)$/)
|
||
.default("1m"),
|
||
OTLP_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(3000),
|
||
|
||
DEPOT_TOKEN: z.string().optional(),
|
||
DEPOT_ORG_ID: z.string().optional(),
|
||
DEPOT_REGION: z.string().default("us-east-1"),
|
||
|
||
// Deployment registry (v3)
|
||
DEPLOY_REGISTRY_HOST: z.string().min(1),
|
||
DEPLOY_REGISTRY_USERNAME: z.string().optional(),
|
||
DEPLOY_REGISTRY_PASSWORD: z.string().optional(),
|
||
DEPLOY_REGISTRY_NAMESPACE: z.string().min(1).default("trigger"),
|
||
DEPLOY_REGISTRY_ECR_TAGS: z.string().optional(), // csv, for example: "key1=value1,key2=value2"
|
||
DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: z.string().optional(),
|
||
DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: z.string().optional(),
|
||
DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY: z.string().optional(), // raw IAM policy JSON applied to every repo created by the webapp
|
||
|
||
// Deployment registry (v4) - falls back to v3 registry if not specified
|
||
V4_DEPLOY_REGISTRY_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.DEPLOY_REGISTRY_HOST)
|
||
.pipe(z.string().min(1)), // Ensure final type is required string
|
||
V4_DEPLOY_REGISTRY_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.DEPLOY_REGISTRY_USERNAME),
|
||
V4_DEPLOY_REGISTRY_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.DEPLOY_REGISTRY_PASSWORD),
|
||
V4_DEPLOY_REGISTRY_NAMESPACE: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.DEPLOY_REGISTRY_NAMESPACE)
|
||
.pipe(z.string().min(1).default("trigger")), // Ensure final type is required string
|
||
V4_DEPLOY_REGISTRY_ECR_TAGS: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_TAGS),
|
||
V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN),
|
||
V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID),
|
||
V4_DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY),
|
||
|
||
// Compute gateway (template creation during deploy finalize)
|
||
COMPUTE_GATEWAY_URL: z.string().optional(),
|
||
COMPUTE_GATEWAY_AUTH_TOKEN: z.string().optional(),
|
||
COMPUTE_TEMPLATE_SHADOW_ROLLOUT_PCT: z.string().optional(),
|
||
// Comma-separated machine preset names to build boot snapshots for on
|
||
// deploy (e.g. "small-1x,small-2x,medium-1x"). Default: "small-1x".
|
||
COMPUTE_TEMPLATE_MACHINE_PRESETS: z
|
||
.string()
|
||
.default("small-1x")
|
||
.transform(parseMachinePresetCsv),
|
||
// Subset of COMPUTE_TEMPLATE_MACHINE_PRESETS that must succeed for a
|
||
// required-mode deploy to be considered successful. Failures of presets
|
||
// outside this list are logged but don't fail the deploy. Defaults to the
|
||
// full COMPUTE_TEMPLATE_MACHINE_PRESETS list when unset (everything required).
|
||
COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED: z
|
||
.string()
|
||
.optional()
|
||
.transform((v, ctx) =>
|
||
parseMachinePresetCsv(v ?? process.env.COMPUTE_TEMPLATE_MACHINE_PRESETS ?? "small-1x", ctx)
|
||
),
|
||
|
||
DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"),
|
||
DEPLOY_TIMEOUT_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60 * 1000 * 8), // 8 minutes
|
||
DEPLOY_QUEUE_TIMEOUT_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60 * 1000 * 15), // 15 minutes
|
||
|
||
// When enabled, reject deploys made by v3 CLI versions (i.e. payloads that
|
||
// omit the `type` field). v4 CLI versions always send `type` ("MANAGED" or "V1"),
|
||
// so they are unaffected. Defaults to off so detection can run in
|
||
// log-only mode before enforcement.
|
||
DEPRECATE_V3_CLI_DEPLOYS_ENABLED: z.string().default("0"),
|
||
|
||
// Verify the deploy image exists before promoting. Disable for out-of-band/air-gapped push. ECR only.
|
||
DEPLOY_IMAGE_VERIFICATION_ENABLED: BoolEnv.default(true),
|
||
|
||
OBJECT_STORE_BASE_URL: z.string().optional(),
|
||
OBJECT_STORE_BUCKET: z.string().optional(),
|
||
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
|
||
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||
OBJECT_STORE_REGION: z.string().optional(),
|
||
OBJECT_STORE_SERVICE: z.string().default("s3"),
|
||
|
||
// Protocol to use for new uploads (e.g., "s3", "r2"). Data without protocol uses default provider above.
|
||
// If specified, you must configure the corresponding provider using OBJECT_STORE_{PROTOCOL}_* env vars.
|
||
// Example: OBJECT_STORE_DEFAULT_PROTOCOL=s3 requires OBJECT_STORE_S3_BASE_URL, OBJECT_STORE_S3_ACCESS_KEY_ID, etc.
|
||
// Enables zero-downtime migration between providers (old data keeps working, new data uses new provider).
|
||
OBJECT_STORE_DEFAULT_PROTOCOL: z
|
||
.string()
|
||
.regex(/^[a-z0-9]+$/)
|
||
.optional(),
|
||
|
||
ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(),
|
||
ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(),
|
||
ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
|
||
ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||
ARTIFACTS_OBJECT_STORE_REGION: z.string().optional(),
|
||
EVENTS_BATCH_SIZE: z.coerce.number().int().default(100),
|
||
EVENTS_BATCH_INTERVAL: z.coerce.number().int().default(1000),
|
||
EVENTS_DEFAULT_LOG_RETENTION: z.coerce.number().int().default(7),
|
||
EVENTS_MIN_CONCURRENCY: z.coerce.number().int().default(1),
|
||
EVENTS_MAX_CONCURRENCY: z.coerce.number().int().default(10),
|
||
EVENTS_MAX_BATCH_SIZE: z.coerce.number().int().default(500),
|
||
EVENTS_MEMORY_PRESSURE_THRESHOLD: z.coerce.number().int().default(5000),
|
||
EVENTS_LOAD_SHEDDING_THRESHOLD: z.coerce.number().int().default(100000),
|
||
EVENTS_LOAD_SHEDDING_ENABLED: z.string().default("1"),
|
||
|
||
MANAGED_WORKER_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
|
||
|
||
// Allow booting with a known-insecure published default secret. Temporary
|
||
// bridge for deployments that can't rotate yet; rotate as soon as possible.
|
||
ALLOW_INSECURE_DEFAULT_SECRETS: BoolEnv.default(false),
|
||
|
||
// Tenant scoping on worker actions is header-driven (folded into the engine snapshot read) and
|
||
// needs no flag. This is only the no-header fallback: when "1", a worker action on a run created
|
||
// after WORKLOAD_TOKEN_CUTOFF without a verified env header is rejected; runs on or before the
|
||
// cutoff pass (grandfathered). Default off = no run-row read, byte-for-byte today's behavior.
|
||
WORKLOAD_CREATED_AT_GATE_ENABLED: z.string().default("0"),
|
||
WORKLOAD_TOKEN_CUTOFF: z.string().datetime().optional(),
|
||
|
||
// Development OTEL environment variables
|
||
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
|
||
DEV_OTEL_METRICS_ENDPOINT: z.string().optional(),
|
||
// If this is set to 1, then the below variables are used to configure the batch processor for spans and logs
|
||
DEV_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
|
||
DEV_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
|
||
DEV_OTEL_SPAN_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
|
||
DEV_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
|
||
DEV_OTEL_SPAN_MAX_QUEUE_SIZE: z.string().default("512"),
|
||
DEV_OTEL_LOG_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
|
||
DEV_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
|
||
DEV_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
|
||
DEV_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
|
||
DEV_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
|
||
DEV_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
|
||
DEV_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
|
||
|
||
PROD_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
|
||
PROD_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
|
||
PROD_OTEL_SPAN_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
|
||
PROD_OTEL_SPAN_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
|
||
PROD_OTEL_SPAN_MAX_QUEUE_SIZE: z.string().default("512"),
|
||
PROD_OTEL_LOG_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
|
||
PROD_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
|
||
PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
|
||
PROD_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
|
||
PROD_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
|
||
PROD_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
|
||
PROD_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
|
||
|
||
TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
|
||
TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
|
||
TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT: z.string().default("131072"),
|
||
TRIGGER_OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT: z.string().default("131072"),
|
||
TRIGGER_OTEL_SPAN_EVENT_COUNT_LIMIT: z.string().default("10"),
|
||
TRIGGER_OTEL_LINK_COUNT_LIMIT: z.string().default("2"),
|
||
TRIGGER_OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT: z.string().default("10"),
|
||
TRIGGER_OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT: z.string().default("10"),
|
||
|
||
CHECKPOINT_THRESHOLD_IN_MS: z.coerce.number().int().default(30000),
|
||
|
||
// Internal OTEL environment variables
|
||
INTERNAL_OTEL_TRACE_EXPORTER_URL: z.string().optional(),
|
||
INTERNAL_OTEL_TRACE_EXPORTER_AUTH_HEADERS: z.string().optional(),
|
||
INTERNAL_OTEL_TRACE_LOGGING_ENABLED: z.string().default("1"),
|
||
// this means 1/20 traces or 5% of traces will be sampled (sampled = recorded)
|
||
INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"),
|
||
INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"),
|
||
INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"),
|
||
DISABLE_HTTP_INSTRUMENTATION: BoolEnv.default(false),
|
||
|
||
INTERNAL_OTEL_LOG_EXPORTER_URL: z.string().optional(),
|
||
INTERNAL_OTEL_METRIC_EXPORTER_URL: z.string().optional(),
|
||
INTERNAL_OTEL_METRIC_EXPORTER_AUTH_HEADERS: z.string().optional(),
|
||
INTERNAL_OTEL_METRIC_EXPORTER_ENABLED: z.string().default("0"),
|
||
INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS: z.coerce.number().int().default(30_000),
|
||
INTERNAL_OTEL_HOST_METRICS_ENABLED: BoolEnv.default(true),
|
||
INTERNAL_OTEL_NODEJS_METRICS_ENABLED: BoolEnv.default(true),
|
||
INTERNAL_OTEL_ADDITIONAL_DETECTORS_ENABLED: BoolEnv.default(true),
|
||
|
||
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
|
||
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
|
||
|
||
/** Vercel integration OAuth credentials */
|
||
VERCEL_INTEGRATION_CLIENT_ID: z.string().optional(),
|
||
VERCEL_INTEGRATION_CLIENT_SECRET: z.string().optional(),
|
||
VERCEL_INTEGRATION_APP_SLUG: z.string().optional(),
|
||
|
||
/** These enable the alerts feature in v3 */
|
||
ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||
ALERT_FROM_EMAIL: z.string().optional(),
|
||
ALERT_REPLY_TO_EMAIL: z.string().optional(),
|
||
ALERT_RESEND_API_KEY: z.string().optional(),
|
||
ALERT_SMTP_HOST: z.string().optional(),
|
||
ALERT_SMTP_PORT: z.coerce.number().optional(),
|
||
ALERT_SMTP_SECURE: BoolEnv.optional(),
|
||
ALERT_SMTP_USER: z.string().optional(),
|
||
ALERT_SMTP_PASSWORD: z.string().optional(),
|
||
ALERT_RATE_LIMITER_EMISSION_INTERVAL: z.coerce.number().int().default(2_500),
|
||
ALERT_RATE_LIMITER_BURST_TOLERANCE: z.coerce.number().int().default(10_000),
|
||
ALERT_RATE_LIMITER_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
ALERT_RATE_LIMITER_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
ALERT_RATE_LIMITER_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
ALERT_RATE_LIMITER_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
ALERT_RATE_LIMITER_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
ALERT_RATE_LIMITER_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
ALERT_RATE_LIMITER_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
ALERT_RATE_LIMITER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
|
||
LOOPS_API_KEY: z.string().optional(),
|
||
ATTIO_API_KEY: z.string().optional(),
|
||
|
||
PROD_TASK_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
|
||
|
||
/* Usage settings */
|
||
USAGE_EVENT_URL: z.string().optional(),
|
||
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
|
||
|
||
CENTS_PER_RUN: z.coerce.number().default(0),
|
||
|
||
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
|
||
MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000),
|
||
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
|
||
// Emergency circuit breaker: when set, clamps the trace summary and detailed
|
||
// summary span limits on both event store paths to this value. Unset = disabled.
|
||
TRACE_VIEW_EMERGENCY_SPAN_CAP: z.coerce.number().int().positive().optional(),
|
||
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
|
||
BATCH_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().optional(), // Defaults to TASK_PAYLOAD_OFFLOAD_THRESHOLD if not set
|
||
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
|
||
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
|
||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
|
||
|
||
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||
QUEUE_SIZE_CACHE_TTL_MS: z.coerce.number().int().optional().default(1_000), // 1 second
|
||
QUEUE_SIZE_CACHE_MAX_SIZE: z.coerce.number().int().optional().default(5_000),
|
||
QUEUE_SIZE_CACHE_ENABLED: z.coerce.number().int().optional().default(1),
|
||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||
|
||
// 2-phase batch API settings
|
||
STREAMING_BATCH_MAX_ITEMS: z.coerce.number().int().default(1_000), // Max items in streaming batch
|
||
STREAMING_BATCH_ITEM_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728),
|
||
// Number of streamed batch items ingested concurrently in Phase 2. Peak
|
||
// in-flight memory per request ≈ this × STREAMING_BATCH_ITEM_MAXIMUM_SIZE,
|
||
// so raise with care. Set to 1 for fully sequential ingestion.
|
||
STREAMING_BATCH_INGEST_CONCURRENCY: z.coerce.number().int().positive().default(10),
|
||
BATCH_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(100),
|
||
BATCH_RATE_LIMIT_MAX: z.coerce.number().int().default(1200),
|
||
BATCH_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"),
|
||
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(5),
|
||
|
||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
|
||
REALTIME_STREAM_TTL: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60 * 60 * 24), // 1 day in seconds
|
||
BATCH_METADATA_OPERATIONS_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||
BATCH_METADATA_OPERATIONS_FLUSH_ENABLED: z.string().default("1"),
|
||
BATCH_METADATA_OPERATIONS_FLUSH_LOGGING_ENABLED: z.string().default("1"),
|
||
|
||
// Run Engine 2.0
|
||
RUN_ENGINE_WORKER_COUNT: z.coerce.number().int().default(4),
|
||
RUN_ENGINE_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||
RUN_ENGINE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
|
||
RUN_ENGINE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(100),
|
||
RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(100),
|
||
RUN_ENGINE_TIMEOUT_PENDING_EXECUTING: z.coerce.number().int().default(60_000),
|
||
RUN_ENGINE_TIMEOUT_PENDING_CANCEL: z.coerce.number().int().default(60_000),
|
||
RUN_ENGINE_TIMEOUT_EXECUTING: z.coerce.number().int().default(300_000), // 5 minutes
|
||
RUN_ENGINE_TIMEOUT_EXECUTING_WITH_WAITPOINTS: z.coerce.number().int().default(300_000), // 5 minutes
|
||
RUN_ENGINE_TIMEOUT_SUSPENDED: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000 * 10),
|
||
RUN_ENGINE_DEBUG_WORKER_NOTIFICATIONS: BoolEnv.default(false),
|
||
RUN_ENGINE_PARENT_QUEUE_LIMIT: z.coerce.number().int().default(1000),
|
||
RUN_ENGINE_CONCURRENCY_LIMIT_BIAS: z.coerce.number().default(0.75),
|
||
RUN_ENGINE_AVAILABLE_CAPACITY_BIAS: z.coerce.number().default(0.3),
|
||
RUN_ENGINE_QUEUE_AGE_RANDOMIZATION_BIAS: z.coerce.number().default(0.25),
|
||
RUN_ENGINE_REUSE_SNAPSHOT_COUNT: z.coerce.number().int().default(0),
|
||
RUN_ENGINE_MAXIMUM_ENV_COUNT: z.coerce.number().int().optional(),
|
||
RUN_ENGINE_RUN_QUEUE_SHARD_COUNT: z.coerce.number().int().default(4),
|
||
// Queue metrics ingestion (Redis Stream -> ClickHouse). The runtime on/off is the
|
||
// `queue_metrics:enabled` Redis key; these gate emitter construction + consumer boot.
|
||
QUEUE_METRICS_EMIT_ENABLED: z.string().default("0"),
|
||
QUEUE_METRICS_CONSUMER_ENABLED: z.string().default("0"),
|
||
QUEUE_METRICS_STREAM_SHARD_COUNT: z.coerce.number().int().default(4),
|
||
QUEUE_METRICS_CONSUMER_BATCH_SIZE: z.coerce.number().int().default(1000),
|
||
// Counter stream (exact counts, loss-intolerant). Unset host => the run-queue Redis;
|
||
// set it to a dedicated instance so counter backlog never competes with the run queue.
|
||
QUEUE_METRICS_REDIS_HOST: z.string().optional(),
|
||
QUEUE_METRICS_REDIS_PORT: z.coerce.number().optional(),
|
||
QUEUE_METRICS_REDIS_USERNAME: z.string().optional(),
|
||
QUEUE_METRICS_REDIS_PASSWORD: z.string().optional(),
|
||
QUEUE_METRICS_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
// Default depends on where the stream lives: see metricsDefinition() in
|
||
// queueMetrics.server.ts (2M on the shared run-queue Redis, 8M on a dedicated one).
|
||
QUEUE_METRICS_COUNTER_STREAM_MAXLEN: z.coerce.number().int().optional(),
|
||
// TTL (seconds) on the per-(queue,op) cumulative odometer key, refreshed on every write.
|
||
// Idle-past-TTL queues purge and self-heal (restart from 1) on return; default 7 days.
|
||
QUEUE_METRICS_COUNTER_ODOMETER_TTL_SECONDS: z.coerce.number().int().default(604_800),
|
||
// Per-env distinct queue_name cap (0 = unlimited); overflow maps to "__overflow__".
|
||
QUEUE_METRICS_MAX_QUEUE_NAMES_PER_ENV: z.coerce.number().int().default(1000),
|
||
QUEUE_METRICS_MAX_CONCURRENCY_KEYS_PER_QUEUE: z.coerce.number().int().default(10_000),
|
||
// Fraction (0..1) of ops that emit a gauge; counters are never sampled. Dial below 1
|
||
// only if EngineCPU is too high in slow-path-heavy regions (hurts low-traffic queues).
|
||
QUEUE_METRICS_GAUGE_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1),
|
||
/**
|
||
* Lists the queue-metrics tables in the Query page, its schema docs, the schema API and the
|
||
* AI query context. Off by default so the tables are not advertised before ingestion is
|
||
* enabled. Listing only: a query naming one of these tables still runs either way.
|
||
*/
|
||
QUEUE_METRICS_QUERY_TABLES_VISIBLE: z.string().default("0"),
|
||
RUN_ENGINE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||
RUN_ENGINE_RETRY_WARM_START_THRESHOLD_MS: z.coerce.number().int().default(30_000),
|
||
RUN_ENGINE_PROCESS_WORKER_QUEUE_DEBOUNCE_MS: z.coerce.number().int().default(200),
|
||
RUN_ENGINE_DEQUEUE_BLOCKING_TIMEOUT_SECONDS: z.coerce.number().int().default(10),
|
||
RUN_ENGINE_DEQUEUE_DISABLED_WORKER_QUEUES: z.string().optional(),
|
||
RUN_ENGINE_MASTER_QUEUE_CONSUMERS_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||
// Off by default. Enable on a single service (e.g. the engine worker) so only one
|
||
// instance reports worker queue length, rather than every replica.
|
||
RUN_ENGINE_WORKER_QUEUE_OBSERVER_ENABLED: z.string().default("0"),
|
||
RUN_ENGINE_WORKER_QUEUE_OBSERVER_INTERVAL_MS: z.coerce.number().int().default(30_000),
|
||
// Comma-separated cloud providers to exclude from worker queue length observation.
|
||
RUN_ENGINE_WORKER_QUEUE_OBSERVER_EXCLUDED_CLOUD_PROVIDERS: z.string().default("digitalocean"),
|
||
RUN_ENGINE_MASTER_QUEUE_COOLOFF_PERIOD_MS: z.coerce.number().int().default(10_000),
|
||
RUN_ENGINE_MASTER_QUEUE_COOLOFF_COUNT_THRESHOLD: z.coerce.number().int().default(10),
|
||
RUN_ENGINE_MASTER_QUEUE_CONSUMER_DEQUEUE_COUNT: z.coerce.number().int().default(10),
|
||
RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_SCHEDULE: z.string().optional(),
|
||
RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_SCHEDULE: z.string().optional(),
|
||
RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_JITTER_IN_MS: z.coerce.number().int().optional(),
|
||
RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_JITTER_IN_MS: z.coerce.number().int().optional(),
|
||
|
||
// TTL System settings for automatic run expiration
|
||
RUN_ENGINE_TTL_SYSTEM_DISABLED: BoolEnv.default(false),
|
||
RUN_ENGINE_TTL_SYSTEM_SHARD_COUNT: z.coerce.number().int().optional(),
|
||
RUN_ENGINE_TTL_SYSTEM_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
|
||
RUN_ENGINE_TTL_SYSTEM_BATCH_SIZE: z.coerce.number().int().default(100),
|
||
RUN_ENGINE_TTL_WORKER_CONCURRENCY: z.coerce.number().int().default(1),
|
||
RUN_ENGINE_TTL_WORKER_BATCH_MAX_SIZE: z.coerce.number().int().default(50),
|
||
RUN_ENGINE_TTL_CONSUMERS_DISABLED: BoolEnv.default(false),
|
||
RUN_ENGINE_TTL_WORKER_BATCH_MAX_WAIT_MS: z.coerce.number().int().default(5_000),
|
||
|
||
/** Optional maximum TTL for all runs (e.g. "14d"). If set, runs without an explicit TTL
|
||
* will use this as their TTL, and runs with a TTL larger than this will be clamped. */
|
||
RUN_ENGINE_DEFAULT_MAX_TTL: z.string().optional(),
|
||
|
||
RUN_ENGINE_RUN_LOCK_DURATION: z.coerce.number().int().default(5000),
|
||
RUN_ENGINE_RUN_LOCK_AUTOMATIC_EXTENSION_THRESHOLD: z.coerce.number().int().default(1000),
|
||
RUN_ENGINE_RUN_LOCK_MAX_RETRIES: z.coerce.number().int().default(10),
|
||
RUN_ENGINE_RUN_LOCK_BASE_DELAY: z.coerce.number().int().default(100),
|
||
RUN_ENGINE_RUN_LOCK_MAX_DELAY: z.coerce.number().int().default(3000),
|
||
RUN_ENGINE_RUN_LOCK_BACKOFF_MULTIPLIER: z.coerce.number().default(1.8),
|
||
RUN_ENGINE_RUN_LOCK_JITTER_FACTOR: z.coerce.number().default(0.15),
|
||
RUN_ENGINE_RUN_LOCK_MAX_TOTAL_WAIT_TIME: z.coerce.number().int().default(15000),
|
||
|
||
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_MAX_COUNT: z.coerce.number().int().default(12),
|
||
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_MAX_DELAY_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000 * 60 * 6),
|
||
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_INITIAL_DELAY_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000),
|
||
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
|
||
|
||
/** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */
|
||
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000 * 60), // 1 hour
|
||
|
||
/**
|
||
* Bucket size in milliseconds used to quantize the newly computed `delayUntil`
|
||
* in the debounce system. Quantization collapses concurrent triggers on the
|
||
* same hot debounce key onto the same target time so the unlocked fast-path
|
||
* skip is effective. Set to 0 to disable. Default: 1000ms (1s).
|
||
*/
|
||
RUN_ENGINE_DEBOUNCE_QUANTIZE_NEW_DELAY_UNTIL_MS: z.coerce.number().int().min(0).default(1000),
|
||
|
||
/**
|
||
* Whether the unlocked fast-path skip is enabled in the debounce system.
|
||
* Acts as a kill switch in case the fast-path needs to be disabled in
|
||
* production without a redeploy. Default: "1" (enabled).
|
||
*/
|
||
RUN_ENGINE_DEBOUNCE_FAST_PATH_SKIP_ENABLED: z.string().default("1"),
|
||
|
||
RUN_ENGINE_WORKER_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
RUN_ENGINE_WORKER_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
RUN_ENGINE_WORKER_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
RUN_ENGINE_WORKER_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
RUN_ENGINE_WORKER_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
RUN_ENGINE_WORKER_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
RUN_ENGINE_WORKER_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
|
||
RUN_ENGINE_RUN_QUEUE_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
RUN_ENGINE_RUN_QUEUE_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
RUN_ENGINE_RUN_QUEUE_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
RUN_ENGINE_RUN_QUEUE_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
RUN_ENGINE_RUN_QUEUE_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
RUN_ENGINE_RUN_QUEUE_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
RUN_ENGINE_RUN_QUEUE_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
|
||
RUN_ENGINE_RUN_LOCK_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
RUN_ENGINE_RUN_LOCK_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
RUN_ENGINE_RUN_LOCK_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
RUN_ENGINE_RUN_LOCK_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
RUN_ENGINE_RUN_LOCK_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
RUN_ENGINE_RUN_LOCK_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
RUN_ENGINE_RUN_LOCK_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
|
||
RUN_ENGINE_DEV_PRESENCE_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
RUN_ENGINE_DEV_PRESENCE_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
RUN_ENGINE_DEV_PRESENCE_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
RUN_ENGINE_DEV_PRESENCE_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
|
||
//API Rate limiting
|
||
/**
|
||
* @example "60s"
|
||
* @example "1m"
|
||
* @example "1h"
|
||
* @example "1d"
|
||
* @example "1000ms"
|
||
* @example "1000s"
|
||
*/
|
||
RUN_ENGINE_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"), // refill 250 tokens every 10 seconds
|
||
RUN_ENGINE_RATE_LIMIT_MAX: z.coerce.number().int().default(1200), // allow bursts of 750 requests
|
||
RUN_ENGINE_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(400), // refix 250 tokens every 10 seconds
|
||
RUN_ENGINE_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
|
||
RUN_ENGINE_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
|
||
RUN_ENGINE_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
|
||
|
||
RUN_ENGINE_RELEASE_CONCURRENCY_ENABLED: z.string().default("0"),
|
||
RUN_ENGINE_RELEASE_CONCURRENCY_DISABLE_CONSUMERS: z.string().default("0"),
|
||
RUN_ENGINE_RELEASE_CONCURRENCY_MAX_TOKENS_RATIO: z.coerce.number().default(1),
|
||
RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_MAX_AGE: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000 * 30),
|
||
RUN_ENGINE_RELEASE_CONCURRENCY_RELEASINGS_POLL_INTERVAL: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000),
|
||
RUN_ENGINE_RELEASE_CONCURRENCY_MAX_RETRIES: z.coerce.number().int().default(3),
|
||
RUN_ENGINE_RELEASE_CONCURRENCY_CONSUMERS_COUNT: z.coerce.number().int().default(1),
|
||
RUN_ENGINE_RELEASE_CONCURRENCY_POLL_INTERVAL: z.coerce.number().int().default(500),
|
||
RUN_ENGINE_RELEASE_CONCURRENCY_BATCH_SIZE: z.coerce.number().int().default(10),
|
||
|
||
RUN_ENGINE_WORKER_ENABLED: z.string().default("1"),
|
||
RUN_ENGINE_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||
RUN_ENGINE_RUN_QUEUE_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
RUN_ENGINE_TREAT_PRODUCTION_EXECUTION_STALLS_AS_OOM: z.string().default("0"),
|
||
RUN_ENGINE_READ_REPLICA_SNAPSHOTS_SINCE_ENABLED: z.string().default("0"),
|
||
RUN_ENGINE_SNAPSHOTS_SINCE_REPLICA_RETRY_MIN_MS: z.coerce.number().int().default(50),
|
||
RUN_ENGINE_SNAPSHOTS_SINCE_REPLICA_RETRY_MAX_MS: z.coerce.number().int().default(200),
|
||
RUN_ENGINE_DEBOUNCE_USE_REPLICA_FOR_FAST_PATH_READ: z.string().default("0"),
|
||
|
||
/** How long should the presence ttl last */
|
||
DEV_PRESENCE_SSE_TIMEOUT: z.coerce.number().int().default(30_000),
|
||
DEV_PRESENCE_TTL_MS: z.coerce.number().int().default(5_000),
|
||
DEV_PRESENCE_POLL_MS: z.coerce.number().int().default(1_000),
|
||
/** How many ms to wait until dequeuing again, if there was a run last time */
|
||
DEV_DEQUEUE_INTERVAL_WITH_RUN: z.coerce.number().int().default(250),
|
||
/** How many ms to wait until dequeuing again, if there was no run last time */
|
||
DEV_DEQUEUE_INTERVAL_WITHOUT_RUN: z.coerce.number().int().default(1_000),
|
||
/** The max number of runs per API call that we'll dequeue in DEV */
|
||
DEV_DEQUEUE_MAX_RUNS_PER_PULL: z.coerce.number().int().default(10),
|
||
|
||
/** The maximum concurrent local run processes executing at once in dev. This is a hard limit */
|
||
DEV_MAX_CONCURRENT_RUNS: z.coerce.number().int().optional(),
|
||
|
||
/** The CLI should connect to this for dev runs */
|
||
DEV_ENGINE_URL: z.string().default(process.env.APP_ORIGIN ?? "http://localhost:3030"),
|
||
|
||
COMMON_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||
COMMON_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||
COMMON_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||
COMMON_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||
COMMON_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
|
||
COMMON_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
|
||
COMMON_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||
COMMON_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||
|
||
COMMON_WORKER_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
COMMON_WORKER_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
COMMON_WORKER_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
COMMON_WORKER_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
COMMON_WORKER_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
COMMON_WORKER_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
COMMON_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
COMMON_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
|
||
// Global default for the scheduled worker-queue split. When "1", runs in a
|
||
// scheduled lineage (rootTriggerSource === "schedule") are routed to a
|
||
// dedicated `<region>:scheduled` worker queue so a separate consumer fleet
|
||
// can dequeue them independently of standard/agent runs. The per-org
|
||
// `workerQueueScheduledSplitEnabled` feature flag overrides this default in
|
||
// BOTH directions (an org set to false stays on the single queue even when
|
||
// this is "1"; an org set to true splits even when this is "0"). Never
|
||
// applies to DEVELOPMENT environments.
|
||
TRIGGER_WORKER_QUEUE_SCHEDULED_SPLIT_ENABLED: z.string().default("0"),
|
||
|
||
TRIGGER_MOLLIFIER_ENABLED: z.string().default("0"),
|
||
// Separate switch for the drainer (consumer side) so it can be split
|
||
// off onto a dedicated worker service. Unset → inherits
|
||
// TRIGGER_MOLLIFIER_ENABLED, so single-container self-hosters don't have to
|
||
// flip two switches. Multi-replica drainers are correct — `popAndMarkDraining`
|
||
// is an atomic RPOP + status flip in one Lua call, so only one replica
|
||
// can win any given entry — but inefficient: polling load (SMEMBERS +
|
||
// per-env scans) multiplies by N, and `TRIGGER_MOLLIFIER_DRAIN_CONCURRENCY`
|
||
// is per-process so engine load also multiplies. Splitting the drainer
|
||
// onto a dedicated worker keeps that traffic off the request-serving
|
||
// replicas. `TRIGGER_MOLLIFIER_ENABLED` is still the master kill switch;
|
||
// setting this to "1" while `TRIGGER_MOLLIFIER_ENABLED` is "0" is a
|
||
// no-op because the gate-side singleton refuses to construct a buffer
|
||
// when the system is off.
|
||
TRIGGER_MOLLIFIER_DRAINER_ENABLED: z
|
||
.string()
|
||
.default(process.env.TRIGGER_MOLLIFIER_ENABLED ?? "0"),
|
||
TRIGGER_MOLLIFIER_SHADOW_MODE: z.string().default("0"),
|
||
TRIGGER_MOLLIFIER_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
TRIGGER_MOLLIFIER_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
TRIGGER_MOLLIFIER_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
TRIGGER_MOLLIFIER_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
TRIGGER_MOLLIFIER_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
TRIGGER_MOLLIFIER_TRIP_WINDOW_MS: z.coerce.number().int().positive().default(200),
|
||
TRIGGER_MOLLIFIER_TRIP_THRESHOLD: z.coerce.number().int().positive().default(100),
|
||
TRIGGER_MOLLIFIER_HOLD_MS: z.coerce.number().int().positive().default(500),
|
||
TRIGGER_MOLLIFIER_DRAIN_CONCURRENCY: z.coerce.number().int().positive().default(50),
|
||
TRIGGER_MOLLIFIER_DRAIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3),
|
||
TRIGGER_MOLLIFIER_DRAIN_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000),
|
||
TRIGGER_MOLLIFIER_DRAIN_MAX_ORGS_PER_TICK: z.coerce.number().int().positive().default(500),
|
||
// Per-env per-tick pop cap. The drainer rotates one env per org per
|
||
// tick; this bounds how many entries it pops from that env before
|
||
// dispatching them through the shared `DRAIN_CONCURRENCY`-bounded
|
||
// limiter. Default matches `DRAIN_CONCURRENCY` so a single-env burst
|
||
// uses the full handler-parallelism budget — for 20k buffered on one
|
||
// env this is the difference between ~17m (one-pop-per-tick × ~50ms)
|
||
// and ~20s (400 ticks × concurrent engine.trigger). Org/env fairness
|
||
// is preserved because the per-tick env selection is unchanged; only
|
||
// the in-env pop count grows.
|
||
TRIGGER_MOLLIFIER_DRAIN_BATCH_SIZE: z.coerce.number().int().positive().default(50),
|
||
// Periodic sweep that scans buffer queue LISTs for entries whose
|
||
// dwell exceeds the stale threshold. Independent of the drainer —
|
||
// its job is exactly to make a stuck/offline drainer visible to
|
||
// ops. Defaults: explicitly opt-in (a separate kill switch from
|
||
// the mollifier itself), run every 5 minutes, alert on anything
|
||
// that's been dwelling for 5+ minutes (matches the sweep interval
|
||
// — "anything still here when we check" is the simplest threshold
|
||
// that converges).
|
||
//
|
||
// The sweep was previously defaulting to inherit
|
||
// `TRIGGER_MOLLIFIER_ENABLED`, which meant any deployment already
|
||
// running with the mollifier on would auto-start the sweep worker
|
||
// on upgrade — turning on new background load with no explicit
|
||
// rollout step. Hard-defaulting to "0" preserves the intent of
|
||
// exposing the sweep as a separate switch.
|
||
TRIGGER_MOLLIFIER_STALE_SWEEP_ENABLED: z.string().default("0"),
|
||
TRIGGER_MOLLIFIER_STALE_SWEEP_INTERVAL_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.default(5 * 60_000),
|
||
TRIGGER_MOLLIFIER_STALE_SWEEP_THRESHOLD_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.default(5 * 60_000),
|
||
// Bounds for one stale-sweep pass (see mollifierStaleSweep.server.ts).
|
||
// Max entries scanned per env and max orgs visited per tick — together
|
||
// they cap the Redis traffic / wall-time of a single sweep pass.
|
||
TRIGGER_MOLLIFIER_STALE_SWEEP_MAX_ENTRIES_PER_ENV: z.coerce
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.default(1000),
|
||
TRIGGER_MOLLIFIER_STALE_SWEEP_MAX_ORGS_PER_PASS: z.coerce
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.default(100),
|
||
|
||
// --- Mollifier buffer internals (wired into MollifierBuffer in
|
||
// mollifierBuffer.server.ts). ---
|
||
// Grace TTL applied to the entry hash on drainer ack so direct reads
|
||
// (retrieve, trace) have a safety net while PG replica lag settles.
|
||
TRIGGER_MOLLIFIER_ACK_GRACE_TTL_SECONDS: z.coerce.number().int().positive().default(30),
|
||
// ioredis per-request retry limit on the buffer's Redis client.
|
||
TRIGGER_MOLLIFIER_REDIS_MAX_RETRIES_PER_REQUEST: z.coerce.number().int().positive().default(20),
|
||
// ioredis reconnect backoff envelope for the buffer client: the base
|
||
// grows by `STEP_MS` per attempt, capped at `MAX_MS`, then equal-jittered.
|
||
TRIGGER_MOLLIFIER_REDIS_RECONNECT_STEP_MS: z.coerce.number().int().positive().default(50),
|
||
TRIGGER_MOLLIFIER_REDIS_RECONNECT_MAX_MS: z.coerce.number().int().positive().default(1000),
|
||
|
||
// --- Mollifier drainer loop internals (wired into MollifierDrainer in
|
||
// mollifierDrainer.server.ts). ---
|
||
// Tick gap when a tick drained nothing; under backlog ticks run back-to-back.
|
||
TRIGGER_MOLLIFIER_DRAIN_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(100),
|
||
// Cap on the drainer's exponential backoff after consecutive runOnce errors.
|
||
TRIGGER_MOLLIFIER_DRAIN_MAX_BACKOFF_MS: z.coerce.number().int().positive().default(5_000),
|
||
// Floor for the drainer's backoff base (so a tiny poll interval doesn't
|
||
// collapse the backoff to near-zero during a sustained outage).
|
||
TRIGGER_MOLLIFIER_DRAIN_BACKOFF_FLOOR_MS: z.coerce.number().int().positive().default(100),
|
||
// Required margin between the drainer's shutdown deadline and
|
||
// GRACEFUL_SHUTDOWN_TIMEOUT; boot fails loud if the timeout leaves less.
|
||
TRIGGER_MOLLIFIER_DRAIN_SHUTDOWN_MARGIN_MS: z.coerce.number().int().positive().default(1_000),
|
||
|
||
// How often the draining-tracker ZSET cardinality is polled into the gauge.
|
||
TRIGGER_MOLLIFIER_DRAINING_GAUGE_INTERVAL_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.default(15_000),
|
||
|
||
// --- Pre-gate idempotency claim (idempotencyClaim.server.ts). ---
|
||
// TTL on the claim key (and the upper clamp on the customer-derived
|
||
// claim TTL), how long a waiter blocks before timing out, and the
|
||
// waiter poll interval.
|
||
TRIGGER_MOLLIFIER_CLAIM_TTL_SECONDS: z.coerce.number().int().positive().default(30),
|
||
// Pipeline floor: the claim never shrinks below this even for a short customer key TTL, so it
|
||
// can't expire mid-pipeline and let a loser re-claim (cross-DB duplicate under the split).
|
||
TRIGGER_MOLLIFIER_CLAIM_MIN_TTL_SECONDS: z.coerce.number().int().positive().default(5),
|
||
TRIGGER_MOLLIFIER_CLAIM_WAIT_MS: z.coerce.number().int().positive().default(5_000),
|
||
TRIGGER_MOLLIFIER_CLAIM_POLL_MS: z.coerce.number().int().positive().default(25),
|
||
|
||
// --- Buffered-run mutate-with-fallback wait loop (mutateWithFallback.server.ts). ---
|
||
// Ceiling on the wait-for-materialisation loop, initial poll gap, the
|
||
// backoff ceiling, and the exponential growth factor (a float).
|
||
TRIGGER_MOLLIFIER_MUTATE_SAFETY_NET_MS: z.coerce.number().int().positive().default(2_000),
|
||
TRIGGER_MOLLIFIER_MUTATE_POLL_STEP_MS: z.coerce.number().int().positive().default(20),
|
||
TRIGGER_MOLLIFIER_MUTATE_MAX_POLL_STEP_MS: z.coerce.number().int().positive().default(250),
|
||
TRIGGER_MOLLIFIER_MUTATE_BACKOFF_FACTOR: z.coerce.number().gt(1).default(1.7),
|
||
|
||
// --- Buffered-run metadata CAS retry loop (applyMetadataMutation.server.ts). ---
|
||
// Retry budget for concurrent metadata writers, and the jittered
|
||
// conflict-backoff envelope: random in [0, base + attempt * step) ms.
|
||
TRIGGER_MOLLIFIER_METADATA_MAX_RETRIES: z.coerce.number().int().positive().default(12),
|
||
TRIGGER_MOLLIFIER_METADATA_BACKOFF_BASE_MS: z.coerce.number().int().positive().default(5),
|
||
TRIGGER_MOLLIFIER_METADATA_BACKOFF_STEP_MS: z.coerce.number().int().positive().default(5),
|
||
|
||
BATCH_TRIGGER_PROCESS_JOB_VISIBILITY_TIMEOUT_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000 * 5), // 5 minutes
|
||
|
||
BATCH_TRIGGER_CACHED_RUNS_CHECK_ENABLED: BoolEnv.default(false),
|
||
|
||
BATCH_TRIGGER_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||
BATCH_TRIGGER_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||
BATCH_TRIGGER_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||
BATCH_TRIGGER_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||
BATCH_TRIGGER_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
|
||
BATCH_TRIGGER_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(20),
|
||
BATCH_TRIGGER_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||
BATCH_TRIGGER_WORKER_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
|
||
BATCH_TRIGGER_WORKER_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
BATCH_TRIGGER_WORKER_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
BATCH_TRIGGER_WORKER_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
BATCH_TRIGGER_WORKER_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
BATCH_TRIGGER_WORKER_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
BATCH_TRIGGER_WORKER_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
BATCH_TRIGGER_WORKER_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
|
||
// BatchQueue DRR settings (Run Engine v2)
|
||
BATCH_QUEUE_DRR_QUANTUM: z.coerce.number().int().default(25),
|
||
BATCH_QUEUE_MAX_DEFICIT: z.coerce.number().int().default(100),
|
||
BATCH_QUEUE_CONSUMER_COUNT: z.coerce.number().int().default(3),
|
||
BATCH_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(50),
|
||
BATCH_QUEUE_WORKER_ENABLED: BoolEnv.default(true),
|
||
// Number of master queue shards for horizontal scaling
|
||
BATCH_QUEUE_SHARD_COUNT: z.coerce.number().int().default(1),
|
||
// Maximum queues to fetch from master queue per iteration
|
||
BATCH_QUEUE_MASTER_QUEUE_LIMIT: z.coerce.number().int().default(1000),
|
||
// Enable worker queue for two-stage processing (claim messages, push to worker queue, process from worker queue)
|
||
BATCH_QUEUE_WORKER_QUEUE_ENABLED: BoolEnv.default(true),
|
||
// Worker queue blocking timeout in seconds (for two-stage processing, only used when BATCH_QUEUE_WORKER_QUEUE_ENABLED is true)
|
||
BATCH_QUEUE_WORKER_QUEUE_TIMEOUT_SECONDS: z.coerce.number().int().default(10),
|
||
// Global rate limit: max items processed per second across all consumers
|
||
// If not set, no global rate limiting is applied
|
||
BATCH_QUEUE_GLOBAL_RATE_LIMIT: z.coerce.number().int().positive().optional(),
|
||
// Max items in the worker queue before claiming pauses (protects visibility timeouts)
|
||
// If not set, no depth limit is applied
|
||
BATCH_QUEUE_WORKER_QUEUE_MAX_DEPTH: z.coerce.number().int().positive().optional(),
|
||
|
||
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||
ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||
ADMIN_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||
ADMIN_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
|
||
ADMIN_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(20),
|
||
ADMIN_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||
ADMIN_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||
|
||
ADMIN_WORKER_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
ADMIN_WORKER_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
ADMIN_WORKER_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
ADMIN_WORKER_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
ADMIN_WORKER_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
ADMIN_WORKER_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
ADMIN_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
ADMIN_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
|
||
ALERTS_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||
ALERTS_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||
ALERTS_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||
ALERTS_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||
ALERTS_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(100),
|
||
ALERTS_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
|
||
ALERTS_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||
ALERTS_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||
|
||
ALERTS_WORKER_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
ALERTS_WORKER_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
ALERTS_WORKER_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
ALERTS_WORKER_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
ALERTS_WORKER_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
ALERTS_WORKER_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
ALERTS_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
ALERTS_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
|
||
BILLING_LIMIT_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||
BILLING_LIMIT_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||
BILLING_LIMIT_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||
BILLING_LIMIT_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||
BILLING_LIMIT_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
|
||
BILLING_LIMIT_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(20),
|
||
BILLING_LIMIT_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||
BILLING_LIMIT_WORKER_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
BILLING_LIMIT_RECONCILE_INTERVAL_MS: z.coerce.number().int().default(90_000),
|
||
BILLING_LIMIT_WORKER_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
BILLING_LIMIT_WORKER_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
BILLING_LIMIT_WORKER_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
BILLING_LIMIT_WORKER_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
BILLING_LIMIT_WORKER_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
|
||
SCHEDULE_ENGINE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||
SCHEDULE_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||
SCHEDULE_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||
SCHEDULE_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||
SCHEDULE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||
SCHEDULE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
|
||
SCHEDULE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
|
||
SCHEDULE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(30_000),
|
||
SCHEDULE_WORKER_DISTRIBUTION_WINDOW_SECONDS: z.coerce.number().int().default(30),
|
||
|
||
SCHEDULE_WORKER_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
SCHEDULE_WORKER_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
SCHEDULE_WORKER_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
SCHEDULE_WORKER_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
SCHEDULE_WORKER_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
SCHEDULE_WORKER_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
SCHEDULE_WORKER_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
SCHEDULE_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||
|
||
TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"),
|
||
TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute
|
||
|
||
DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
|
||
BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
|
||
QUEUES_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
|
||
|
||
SLACK_BOT_TOKEN: z.string().optional(),
|
||
SLACK_SIGNUP_REASON_CHANNEL_ID: z.string().optional(),
|
||
|
||
// kapa.ai
|
||
KAPA_AI_WEBSITE_ID: z.string().optional(),
|
||
|
||
// BetterStack
|
||
BETTERSTACK_API_KEY: z.string().optional(),
|
||
BETTERSTACK_STATUS_PAGE_ID: z.string().optional(),
|
||
|
||
RUN_REPLICATION_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
RUN_REPLICATION_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
RUN_REPLICATION_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
RUN_REPLICATION_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
RUN_REPLICATION_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
RUN_REPLICATION_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
RUN_REPLICATION_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
|
||
RUN_REPLICATION_CLICKHOUSE_URL: z.string().optional(),
|
||
RUN_REPLICATION_ENABLED: z.string().default("0"),
|
||
RUN_REPLICATION_SLOT_NAME: z.string().default("task_runs_to_clickhouse_v1"),
|
||
RUN_REPLICATION_PUBLICATION_NAME: z.string().default("task_runs_to_clickhouse_v1_publication"),
|
||
RUN_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(2),
|
||
RUN_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||
RUN_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100),
|
||
RUN_REPLICATION_LEADER_LOCK_TIMEOUT_MS: z.coerce.number().int().default(30_000),
|
||
RUN_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS: z.coerce.number().int().default(10_000),
|
||
RUN_REPLICATION_ACK_INTERVAL_SECONDS: z.coerce.number().int().default(10),
|
||
RUN_REPLICATION_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||
RUN_REPLICATION_CLICKHOUSE_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
RUN_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS: z.coerce.number().int().default(10_000),
|
||
RUN_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS: z.coerce.number().int().default(500),
|
||
RUN_REPLICATION_WAIT_FOR_ASYNC_INSERT: z.string().default("0"),
|
||
RUN_REPLICATION_KEEP_ALIVE_ENABLED: z.string().default("0"),
|
||
RUN_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
|
||
RUN_REPLICATION_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
|
||
// Retry configuration for insert operations
|
||
RUN_REPLICATION_INSERT_MAX_RETRIES: z.coerce.number().int().default(3),
|
||
RUN_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100),
|
||
RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000),
|
||
RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
|
||
RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"),
|
||
RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING: z.string().default("0"),
|
||
|
||
// Connection URL for the LEGACY runs-replication source (the runs-CDC slot on the legacy runs DB, plus
|
||
// the admin recovery route). Direct, not pooled: replication can't run over a pooler. Optional; unset ->
|
||
// falls back to DATABASE_URL, so nothing changes today.
|
||
RUN_REPLICATION_LEGACY_DATABASE_URL: z
|
||
.string()
|
||
.refine(isValidDatabaseUrl, "RUN_REPLICATION_LEGACY_DATABASE_URL is invalid")
|
||
.optional(),
|
||
|
||
// --- Run-ops DB split — second replication source (the NEW dedicated run-ops DB). ---
|
||
// Cloud-only; only consulted when isSplitEnabled() is true. Self-host never sets these.
|
||
// Connection URL for the run-ops DB used by the runs-replication source. Required when the split is
|
||
// enabled (unset → boot fails via SplitReplicationMisconfiguredError, no silent fallback). Kept
|
||
// separate from the app's RUN_OPS_DATABASE_URL: replication can't run over a pooler, so this is direct.
|
||
RUN_REPLICATION_RUN_OPS_DATABASE_URL: z
|
||
.string()
|
||
.refine(isValidDatabaseUrl, "RUN_REPLICATION_RUN_OPS_DATABASE_URL is invalid")
|
||
.optional(),
|
||
RUN_REPLICATION_NEW_SLOT_NAME: z.string().default("task_runs_to_clickhouse_v2"),
|
||
RUN_REPLICATION_NEW_PUBLICATION_NAME: z
|
||
.string()
|
||
.default("task_runs_to_clickhouse_v2_publication"),
|
||
RUN_REPLICATION_NEW_ENABLED: z.string().default("0"),
|
||
// Origin generations packed into _version via composeTaskRunVersion.
|
||
// Legacy DB = 0, new dedicated run-ops DB = 1. Exposed as env so the mapping is auditable
|
||
// per-deploy, but DEFAULTS encode the canonical legacy=0 / new=1 contract.
|
||
RUN_REPLICATION_LEGACY_ORIGIN_GENERATION: z.coerce.number().int().default(0),
|
||
RUN_REPLICATION_NEW_ORIGIN_GENERATION: z.coerce.number().int().default(1),
|
||
|
||
// Run-ops id mint cutover — per-env, canary-first, OFF by default.
|
||
// Even when on, an env mints run-ops ids only if its per-org runOpsMintKind flag is
|
||
// "runOpsId" AND isSplitEnabled() is true. Cache mirrors REALTIME_BACKEND_FLAG_CACHE_*.
|
||
RUN_OPS_MINT_ENABLED: BoolEnv.default(false),
|
||
RUN_OPS_MINT_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000),
|
||
RUN_OPS_MINT_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(10_000),
|
||
// Deterministic wall-clock cutover after a runOpsMintKind flip. Must exceed the sum
|
||
// of RUN_OPS_MINT_FLAG_CACHE_TTL_MS and the control-plane cache TTL so every process
|
||
// (stale or fresh) resolves to the same kind for the whole window. See mintFlipGrace.ts.
|
||
RUN_OPS_MINT_FLIP_GRACE_MS: z.coerce.number().int().default(90_000),
|
||
|
||
// Session replication (Postgres → ClickHouse sessions_v1). Shares Redis
|
||
// with the runs replicator for leader locking but has its own slot and
|
||
// publication so the two consume independently.
|
||
SESSION_REPLICATION_CLICKHOUSE_URL: z.string().optional(),
|
||
SESSION_REPLICATION_ENABLED: z.string().default("0"),
|
||
SESSION_REPLICATION_SLOT_NAME: z.string().default("sessions_to_clickhouse_v1"),
|
||
SESSION_REPLICATION_PUBLICATION_NAME: z
|
||
.string()
|
||
.default("sessions_to_clickhouse_v1_publication"),
|
||
// Connection URL for the sessions-replication slot. Direct, not pooled: replication can't run over a
|
||
// pooler. Optional; unset -> falls back to DATABASE_URL, so nothing changes today.
|
||
SESSION_REPLICATION_DATABASE_URL: z
|
||
.string()
|
||
.refine(isValidDatabaseUrl, "SESSION_REPLICATION_DATABASE_URL is invalid")
|
||
.optional(),
|
||
SESSION_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(1),
|
||
SESSION_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||
SESSION_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100),
|
||
SESSION_REPLICATION_LEADER_LOCK_TIMEOUT_MS: z.coerce.number().int().default(30_000),
|
||
SESSION_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS: z.coerce.number().int().default(10_000),
|
||
SESSION_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS: z.coerce.number().int().default(10_000),
|
||
SESSION_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS: z.coerce.number().int().default(500),
|
||
SESSION_REPLICATION_ACK_INTERVAL_SECONDS: z.coerce.number().int().default(10),
|
||
SESSION_REPLICATION_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
SESSION_REPLICATION_CLICKHOUSE_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
SESSION_REPLICATION_WAIT_FOR_ASYNC_INSERT: z.string().default("0"),
|
||
SESSION_REPLICATION_KEEP_ALIVE_ENABLED: z.string().default("0"),
|
||
SESSION_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
|
||
SESSION_REPLICATION_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
|
||
SESSION_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
|
||
SESSION_REPLICATION_INSERT_MAX_RETRIES: z.coerce.number().int().default(3),
|
||
SESSION_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100),
|
||
SESSION_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000),
|
||
|
||
// Clickhouse
|
||
CLICKHOUSE_URL: z.string(),
|
||
// Optional read replica endpoint. Read-only clients (logs, query, admin, runsList,
|
||
// engine, realtime) default to this when their own URL is unset; writes always stay on
|
||
// CLICKHOUSE_URL. Events reads opt in separately via EVENTS_READER_CLICKHOUSE_URL (no
|
||
// fallback here). Must share storage with the CLICKHOUSE_URL warehouse.
|
||
CLICKHOUSE_READER_URL: z.string().optional(),
|
||
CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
|
||
CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
|
||
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
|
||
CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||
CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||
|
||
// Logs Query Settings
|
||
CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_000_000_000),
|
||
CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(256_000_000),
|
||
CLICKHOUSE_LOGS_LIST_MAX_THREADS: z.coerce.number().int().default(2),
|
||
CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ: z.coerce.number().int().default(10_000_000),
|
||
CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME: z.coerce.number().int().default(120),
|
||
// Bound read-in-order memory on object-storage reads: each part opens a per-column read
|
||
// stream, and the default ~1 MiB+ S3 buffers dominate peak memory. These two byte sizes
|
||
// cap the per-stream buffers and exist on every supported ClickHouse, so they are always on.
|
||
CLICKHOUSE_LOGS_LIST_PREFETCH_BUFFER_SIZE: z.coerce
|
||
.number()
|
||
.int()
|
||
.nonnegative()
|
||
.default(262_144),
|
||
CLICKHOUSE_LOGS_LIST_MAX_READ_BUFFER_SIZE: z.coerce
|
||
.number()
|
||
.int()
|
||
.nonnegative()
|
||
.default(262_144),
|
||
// The decisive lever on Cloud SharedMergeTree, but it only exists on newer ClickHouse and
|
||
// is a no-op on local-disk MergeTree, so it is opt-in: unset means it is never sent (safe on
|
||
// any self-hosted version). Set to 0 on object-storage deployments to get the memory win.
|
||
CLICKHOUSE_LOGS_LIST_FILESYSTEM_CACHE_PREFER_BIGGER_BUFFER_SIZE: z.coerce
|
||
.number()
|
||
.int()
|
||
.nonnegative()
|
||
.optional(),
|
||
|
||
// Logs list pagination tuning (page sizing + recent-first probe windows).
|
||
LOGS_LIST_DEFAULT_PAGE_SIZE: z.coerce.number().int().positive().default(50),
|
||
LOGS_LIST_MAX_PAGE_SIZE: z.coerce.number().int().positive().default(100),
|
||
// Days back from the page ceiling to probe before widening to the full requested window,
|
||
// comma-separated. Empty disables narrowing (a single full-window query).
|
||
LOGS_LIST_RECENT_FIRST_PROBE_DAYS: z
|
||
.string()
|
||
.default("1,7")
|
||
.transform((s) =>
|
||
s
|
||
.split(",")
|
||
.map((v) => Number(v.trim()))
|
||
.filter((n) => Number.isFinite(n) && n > 0)
|
||
),
|
||
|
||
// Query feature flag
|
||
QUERY_FEATURE_ENABLED: z.string().default("1"),
|
||
|
||
// AI features (Prompts, Models, AI Metrics sidebar section)
|
||
AI_FEATURES_ENABLED: z.string().default("0"),
|
||
|
||
// Logs page ClickHouse URL (for logs queries)
|
||
LOGS_CLICKHOUSE_URL: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
|
||
|
||
// Query page ClickHouse limits (for TSQL queries)
|
||
QUERY_CLICKHOUSE_URL: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
|
||
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
|
||
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
|
||
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
|
||
QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
|
||
QUERY_CLICKHOUSE_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY: z.coerce.number().int().default(0),
|
||
QUERY_CLICKHOUSE_MAX_RETURNED_ROWS: z.coerce.number().int().default(10_000),
|
||
|
||
// Query page concurrency limits
|
||
QUERY_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(3),
|
||
QUERY_GLOBAL_CONCURRENCY_LIMIT: z.coerce.number().int().default(100),
|
||
|
||
// Metric widget concurrency limits
|
||
METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(30),
|
||
|
||
// Admin ClickHouse URL (for admin dashboard queries like missing models)
|
||
ADMIN_CLICKHOUSE_URL: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
|
||
|
||
EVENTS_CLICKHOUSE_URL: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
|
||
// Events read replica (traces/spans/logs). No CLICKHOUSE_READER_URL fallback by design: this write-capable client opts in explicitly.
|
||
EVENTS_READER_CLICKHOUSE_URL: z.string().optional(),
|
||
EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
|
||
EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
|
||
EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
|
||
EVENTS_CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||
EVENTS_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||
// ClickHouse client used by @internal/run-engine's PendingVersionSystem.
|
||
// Kept on its own URL + pool so this low-QPS path can't contend with
|
||
// the main analytics client (CLICKHOUSE_URL). Falls back to the main
|
||
// URL when unset so unconfigured environments still work.
|
||
RUN_ENGINE_CLICKHOUSE_URL: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
|
||
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
|
||
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
|
||
RUN_ENGINE_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(5),
|
||
RUN_ENGINE_CLICKHOUSE_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
RUN_ENGINE_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||
// Dedicated ClickHouse pool for the native backend's tag/batch id resolution; falls back to CLICKHOUSE_URL.
|
||
REALTIME_BACKEND_NATIVE_CLICKHOUSE_URL: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
|
||
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
|
||
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.optional(),
|
||
REALTIME_BACKEND_NATIVE_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
|
||
REALTIME_BACKEND_NATIVE_CLICKHOUSE_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
REALTIME_BACKEND_NATIVE_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||
// Dedicated ClickHouse pool for the runs list (dashboard + API). Lets us point
|
||
// the highest-traffic read path at a read replica without moving ingest/replication
|
||
// writes off CLICKHOUSE_URL. Falls back to CLICKHOUSE_URL when unset.
|
||
RUNS_LIST_CLICKHOUSE_URL: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
|
||
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
|
||
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
|
||
RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
|
||
RUNS_LIST_CLICKHOUSE_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||
/**
|
||
* Dedicated ClickHouse service for queue metrics: the ingestion consumer's inserts and every
|
||
* queue-metrics read (dashboards, queue pages, run inspector, health report) go through it, so
|
||
* metrics traffic never competes with runs-list or trace reads. Unset keeps the previous
|
||
* wiring: inserts on CLICKHOUSE_URL, reads on the query pool.
|
||
*/
|
||
QUEUE_METRICS_CLICKHOUSE_URL: z.string().optional(),
|
||
/** Reader split so the consumer's inserts can never land on a read endpoint. Defaults to QUEUE_METRICS_CLICKHOUSE_URL. */
|
||
QUEUE_METRICS_CLICKHOUSE_READER_URL: z.string().optional(),
|
||
QUEUE_METRICS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
|
||
QUEUE_METRICS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
|
||
QUEUE_METRICS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
|
||
QUEUE_METRICS_CLICKHOUSE_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
QUEUE_METRICS_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
|
||
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||
METRICS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(10000),
|
||
METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||
METRICS_CLICKHOUSE_MAX_CONCURRENCY: z.coerce.number().int().default(3),
|
||
EVENTS_CLICKHOUSE_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
|
||
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
|
||
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
|
||
EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS: z.coerce.number().int().default(5000),
|
||
EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000 * 5), // 5 minutes
|
||
EVENT_REPOSITORY_DEFAULT_STORE: z
|
||
.enum(["postgres", "clickhouse", "clickhouse_v2"])
|
||
.default("postgres"),
|
||
EVENT_REPOSITORY_DEBUG_LOGS_DISABLED: BoolEnv.default(false),
|
||
EVENT_REPOSITORY_POSTGRES_WRITES_DISABLED: BoolEnv.default(false),
|
||
EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
|
||
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
|
||
|
||
// OTLP ingest transform worker pool (opt-in). When enabled, decode/convert/enrich run in a
|
||
// worker_threads pool instead of the request event loop; the single consolidated insert path
|
||
// is unchanged.
|
||
OTEL_TRANSFORM_WORKER_POOL_ENABLED: BoolEnv.default(false),
|
||
OTEL_TRANSFORM_WORKER_POOL_SIZE: z.coerce.number().int().optional(),
|
||
OTEL_TRANSFORM_WORKER_PATH: z.string().optional(),
|
||
|
||
// Organization data stores registry
|
||
ORGANIZATION_DATA_STORES_RELOAD_INTERVAL_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60 * 1000), // 1 minute
|
||
|
||
// LLM cost tracking
|
||
LLM_COST_TRACKING_ENABLED: BoolEnv.default(true),
|
||
LLM_PRICING_RELOAD_INTERVAL_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(5 * 60 * 1000), // 5 minutes
|
||
LLM_PRICING_RELOAD_CHANNEL: z.string().default("llm-registry:reload"),
|
||
LLM_PRICING_RELOAD_DEBOUNCE_MS: z.coerce.number().int().default(1000),
|
||
// Whether to subscribe this process to the LLM_PRICING_RELOAD_CHANNEL.
|
||
// Default off — only OTel-ingesting services need real-time pricing
|
||
// freshness; dashboard/worker processes are fine on the existing
|
||
// 5-minute periodic reload. In multi-service deployments, set this to
|
||
// true on the span-ingesting services.
|
||
LLM_PRICING_RELOAD_PUBSUB_ENABLED: BoolEnv.default(false),
|
||
LLM_PRICING_SEED_ON_STARTUP: BoolEnv.default(false),
|
||
LLM_PRICING_READY_TIMEOUT_MS: z.coerce.number().int().default(500),
|
||
LLM_METRICS_BATCH_SIZE: z.coerce.number().int().default(5000),
|
||
LLM_METRICS_FLUSH_INTERVAL_MS: z.coerce.number().int().default(2000),
|
||
LLM_METRICS_MAX_BATCH_SIZE: z.coerce.number().int().default(10000),
|
||
LLM_METRICS_MAX_CONCURRENCY: z.coerce.number().int().default(2),
|
||
|
||
// Bootstrap
|
||
TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"),
|
||
TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(),
|
||
TRIGGER_BOOTSTRAP_WORKER_TOKEN_PATH: z.string().optional(),
|
||
|
||
// Machine presets
|
||
MACHINE_PRESETS_OVERRIDE_PATH: z.string().optional(),
|
||
|
||
// CLI package tag (e.g. "latest", "v4-beta", "4.0.0") - used for setup commands
|
||
TRIGGER_CLI_TAG: z.string().default("latest"),
|
||
|
||
HEALTHCHECK_DATABASE_DISABLED: z.string().default("0"),
|
||
|
||
REQUEST_IDEMPOTENCY_REDIS_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||
REQUEST_IDEMPOTENCY_REDIS_READER_HOST: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||
REQUEST_IDEMPOTENCY_REDIS_READER_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) =>
|
||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||
),
|
||
REQUEST_IDEMPOTENCY_REDIS_PORT: z.coerce
|
||
.number()
|
||
.optional()
|
||
.transform(
|
||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||
),
|
||
REQUEST_IDEMPOTENCY_REDIS_USERNAME: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||
REQUEST_IDEMPOTENCY_REDIS_PASSWORD: z
|
||
.string()
|
||
.optional()
|
||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||
REQUEST_IDEMPOTENCY_REDIS_TLS_DISABLED: z
|
||
.string()
|
||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||
|
||
REQUEST_IDEMPOTENCY_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
|
||
REQUEST_IDEMPOTENCY_TTL_IN_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000 * 60 * 24),
|
||
|
||
// Bulk action
|
||
BULK_ACTION_BATCH_SIZE: z.coerce.number().int().default(100),
|
||
BULK_ACTION_BATCH_DELAY_MS: z.coerce.number().int().default(200),
|
||
BULK_ACTION_SUBBATCH_CONCURRENCY: z.coerce.number().int().default(5),
|
||
/// Max number of concurrent in-flight (PENDING) bulk replays per environment.
|
||
BULK_ACTION_MAX_CONCURRENT_REPLAYS: z.coerce.number().int().default(3),
|
||
/// Max number of explicit run IDs accepted in a single bulk action create request.
|
||
BULK_ACTION_MAX_RUN_IDS: z.coerce.number().int().default(500),
|
||
|
||
// AI Run Filter
|
||
AI_RUN_FILTER_MODEL: z.string().optional(),
|
||
|
||
EVENT_LOOP_MONITOR_THRESHOLD_MS: z.coerce.number().int().default(100),
|
||
EVENT_LOOP_MONITOR_UTILIZATION_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||
EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE: z.coerce.number().default(0.05),
|
||
EVENT_LOOP_MONITOR_NOTIFY_ENABLED: z.string().default("0"),
|
||
|
||
VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(),
|
||
|
||
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
|
||
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
|
||
REALTIME_STREAMS_S2_ENDPOINT: z.string().optional(),
|
||
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS: z.enum(["true", "false"]).default("false"),
|
||
REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.default(60_000 * 60 * 24), // 1 day
|
||
REALTIME_STREAMS_S2_LOG_LEVEL: z
|
||
.enum(["log", "error", "warn", "info", "debug"])
|
||
.default("info"),
|
||
REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS: z.coerce.number().int().default(100),
|
||
REALTIME_STREAMS_S2_MAX_RETRIES: z.coerce.number().int().default(10),
|
||
REALTIME_STREAMS_S2_WAIT_SECONDS: z.coerce.number().int().default(60),
|
||
// When "true", provision a dedicated S2 basin per org and stamp
|
||
// `streamBasinName` on new rows. Off keeps everything on the single
|
||
// basin defined by `REALTIME_STREAMS_S2_BASIN`.
|
||
REALTIME_STREAMS_PER_ORG_BASINS_ENABLED: z.enum(["true", "false"]).default("false"),
|
||
// Per-org basin name = `{prefix}-{env}-org-{orgId}`.
|
||
REALTIME_STREAMS_BASIN_NAME_PREFIX: z.string().default("triggerdotdev"),
|
||
REALTIME_STREAMS_BASIN_NAME_ENV: z.string().default("dev"),
|
||
REALTIME_STREAMS_BASIN_DEFAULT_RETENTION: durationString().default("30d"),
|
||
REALTIME_STREAMS_BASIN_STORAGE_CLASS: z.enum(["express", "standard"]).default("express"),
|
||
REALTIME_STREAMS_BASIN_DELETE_ON_EMPTY_MIN_AGE: durationString().default("1h"),
|
||
REALTIME_STREAMS_DEFAULT_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||
WAIT_UNTIL_TIMEOUT_MS: z.coerce.number().int().default(600_000),
|
||
|
||
// Private connections
|
||
PRIVATE_CONNECTIONS_ENABLED: z.string().optional(),
|
||
PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(),
|
||
|
||
// Force RBAC to not use the plugin
|
||
RBAC_FORCE_FALLBACK: BoolEnv.default(false),
|
||
|
||
// Per-process pool sizes for an RBAC plugin that owns its own database
|
||
// client (the fallback queries through Prisma and ignores these). Writes
|
||
// are rare role mutations; reads run on the per-request auth hot path.
|
||
RBAC_DATABASE_WRITER_CONNECTION_LIMIT: z.coerce.number().int().default(2),
|
||
RBAC_DATABASE_READER_CONNECTION_LIMIT: z.coerce.number().int().default(5),
|
||
|
||
// Force SSO to not use the plugin (contributors without the cloud
|
||
// plugin installed can opt in to a clean OSS-only experience).
|
||
SSO_FORCE_FALLBACK: BoolEnv.default(false),
|
||
|
||
// Per-process pool sizes for an SSO plugin that owns its own database
|
||
// client (the fallback queries through Prisma and ignores these). Writes
|
||
// are rare config mutations and webhook processing; reads run on the
|
||
// login path.
|
||
SSO_DATABASE_WRITER_CONNECTION_LIMIT: z.coerce.number().int().default(2),
|
||
SSO_DATABASE_READER_CONNECTION_LIMIT: z.coerce.number().int().default(5),
|
||
// Emit a console.log when the SSO fallback is selected because no
|
||
// plugin is installed. Default off so OSS deployments stay quiet.
|
||
SSO_LOG_FALLBACK: BoolEnv.default(false),
|
||
// Master deploy gate for the whole SSO feature. Default OFF so the
|
||
// image can ship dark and be flipped on only once the SSO plugin's
|
||
// backing services are available. When false, the SSO controller is
|
||
// forced to the OSS fallback — login link hidden, SSO login disabled,
|
||
// settings inert, and session re-validation skipped.
|
||
SSO_ENABLED: BoolEnv.default(false),
|
||
// How often (seconds) a live SSO session is re-validated against the
|
||
// identity provider. The check is single-flight per user, so this is
|
||
// the minimum interval between plugin round-trips, not a per-request
|
||
// cost. Defaults to 5 minutes: every active SSO user drives one
|
||
// billing→IdP round-trip per window, so a seconds-scale default
|
||
// exhausts vendor rate limits at trivial user counts (masked by
|
||
// fail-open, so it degrades silently).
|
||
SSO_SESSION_REVALIDATION_INTERVAL_SECONDS: z.coerce.number().int().positive().default(300),
|
||
// Hard timeout (ms) on the re-validation round-trip. If the SSO plugin
|
||
// doesn't answer within this window the check fails OPEN (session kept)
|
||
// and emits a `sso.revalidation.timeout` warn log — alert on an
|
||
// elevated rate of those to catch a slow/unhealthy SSO dependency.
|
||
SSO_SESSION_REVALIDATION_TIMEOUT_MS: z.coerce.number().int().positive().default(2000),
|
||
})
|
||
.and(GithubAppEnvSchema)
|
||
.and(S2EnvSchema)
|
||
.superRefine((env, ctx) => {
|
||
const presets = new Set(env.COMPUTE_TEMPLATE_MACHINE_PRESETS);
|
||
for (const required of env.COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED) {
|
||
if (!presets.has(required)) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ["COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED"],
|
||
message: `"${required}" is not in COMPUTE_TEMPLATE_MACHINE_PRESETS`,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||
export const env = EnvironmentSchema.parse(process.env);
|
||
|
||
if (env.ALLOW_INSECURE_DEFAULT_SECRETS) {
|
||
const insecure = (
|
||
[
|
||
["SESSION_SECRET", env.SESSION_SECRET],
|
||
["MAGIC_LINK_SECRET", env.MAGIC_LINK_SECRET],
|
||
["ENCRYPTION_KEY", env.ENCRYPTION_KEY],
|
||
["MANAGED_WORKER_SECRET", env.MANAGED_WORKER_SECRET],
|
||
] as const
|
||
)
|
||
.filter(([, value]) => INSECURE_SECRET_VALUES.includes(value))
|
||
.map(([name]) => name);
|
||
|
||
if (insecure.length > 0) {
|
||
console.warn(
|
||
`⚠️ ALLOW_INSECURE_DEFAULT_SECRETS is enabled and these secrets still use a known-insecure published default: ${insecure.join(
|
||
", "
|
||
)}. This is insecure - rotate them as soon as you can.`
|
||
);
|
||
}
|
||
}
|