Skip to content

Commit b27b121

Browse files
committed
feat(selfhost): defer maintenance work under runtime pressure
Adds a claim-time admission policy so periodic maintenance sweeps (contributor evidence, RAG indexing, drift scans, product rollups, notifications...) always yield to live webhook/review work and to host CPU pressure, instead of competing with it on equal footing. Denied jobs are deferred with jitter and force-admitted via a trickle ceiling so sustained load can slow maintenance down but never starve it forever. Also: classifies a bare 403 on the branch-protection probe as a permission gap (not GitHub rate-limit exhaustion) with its own metric, adds Grafana panels + Prometheus alerts for live vs. maintenance queue pressure, documents the three self-host runner deployment modes, and adds a safe Docker build-cache/image prune script (never touches volumes).
1 parent 01d043e commit b27b121

18 files changed

Lines changed: 1738 additions & 40 deletions

.env.example

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,22 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
259259
# # per-batch jobs above this so the per-login GitHub reads spread across the
260260
# # queue instead of bursting. Set 0 to disable the fan-out (single job).
261261

262+
# --- Maintenance-job backpressure (#selfhost-runtime-pressure) ---
263+
# Periodic maintenance sweeps (contributor evidence, burden forecasts, RAG re-indexing, drift scans, product
264+
# rollups, notifications...) already yield to an exhausted GitHub REST budget; these knobs add an orthogonal
265+
# check at queue-claim time -- is the BOX itself under load right now -- so maintenance never competes with
266+
# live webhook/review work for CPU/DB time. A denied maintenance job is deferred with jitter, never dropped;
267+
# TRICKLE (MAINTENANCE_ADMISSION_MAX_DEFER_AGE_MS) force-admits it once it's waited long enough regardless of
268+
# pressure, so sustained load can slow maintenance down but never starve it forever. All defaults are sane for
269+
# a small single-node box; every value is optional.
270+
# MAINTENANCE_ADMISSION_ENABLED=true # set false/0/off to fully disable this policy (old always-run behavior)
271+
# MAINTENANCE_ADMISSION_MAX_LIVE_PENDING=5 # defer maintenance once this many live (webhook/regate) jobs are queued
272+
# MAINTENANCE_ADMISSION_MAX_LIVE_AGE_MS=120000 # defer maintenance once the oldest live job has waited this long (2m)
273+
# MAINTENANCE_ADMISSION_MAX_PENDING=15 # defer NEW maintenance admissions once this many maintenance jobs are already queued
274+
# MAINTENANCE_ADMISSION_MAX_HOST_LOAD=1.5 # defer once 1-min load average per CPU core exceeds this (best-effort; see host-pressure.ts)
275+
# MAINTENANCE_ADMISSION_DEFER_MS=180000 # base defer duration on denial, before jitter (3m)
276+
# MAINTENANCE_ADMISSION_MAX_DEFER_AGE_MS=14400000 # trickle ceiling: force-admit a maintenance job that has waited this long (4h)
277+
262278
# --- Caddy HTTPS terminator (#1203; requires --profile caddy) ---
263279
# DOMAIN=gittensory.example.com # fully-qualified domain; Caddy auto-obtains a Let's Encrypt cert
264280

@@ -268,6 +284,18 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
268284
# TS_EXTRA_ARGS= # extra tailscale up flags, e.g. --advertise-tags=tag:self-host
269285

270286
# --- Self-hosted GitHub Actions runner (#1205; requires --profile runners) ---
287+
# NOT part of the default recommended stack -- it is an OPTIONAL profile. Pick a deployment mode:
288+
# 1. App-only VPS (recommended default): gittensory + Postgres/Redis/Qdrant/observability, no runners here.
289+
# Use GitHub-hosted CI for your own repos, or a separate runner host (below).
290+
# 2. Separate runner host: run `--profile runners` on a DIFFERENT machine from the review stack. CI load
291+
# never contends with the app for CPU/disk/network.
292+
# 3. GitHub-hosted CI: simplest and safest for small teams -- no runner host to manage at all.
293+
# CI runners sharing a VPS with the review stack CAN starve reviews: a burst of CI jobs (vitest coverage,
294+
# wrangler builds, migration checks...) will happily consume every spare CPU cycle the app needs to process
295+
# webhooks and AI review calls, discovered running exactly this pattern in production -- 3 uncapped runner
296+
# containers on an 8-vCPU box left the app starved under load. If you DO need runners on this same host
297+
# (mode 2 above, but co-located anyway, or you're testing this locally), see docker-compose.override.yml.example
298+
# for a proven CPU-priority pattern (cpu_shares + a per-container cpus ceiling) before scaling replicas up.
271299
# Blank tokens/URLs are valid until --profile runners is enabled.
272300
# RUNNER_TOKEN= # runner registration token (Settings → Actions → Runners → New)
273301
# RUNNER_REPO_URL=https://github.com/org/repo
@@ -276,6 +304,12 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
276304
# RUNNER_NAME=gittensory-runner
277305
# RUNNER_LABELS=self-hosted,linux
278306

307+
# --- Docker disk hygiene (#selfhost-runtime-pressure) ---
308+
# Build cache and unused images accumulate fast on a box that builds from source or runs CI runners; a root
309+
# disk over ~80-85% full slows down the WHOLE host (fsync latency, container scheduling), not just Docker.
310+
# ./scripts/docker-prune.sh reports usage read-only by default; pass --yes to actually reclaim space (never
311+
# touches volumes/application data). See the script's header for a cron example.
312+
279313
# --- Observability: metrics + alerts + logs (#1206; requires --profile observability) ---
280314
# The observability profile starts Prometheus (scrapes /metrics) + Alertmanager (alert rules in
281315
# prometheus/rules/, routing in alertmanager/alertmanager.yml — silent until you fill in a receiver) +

apps/gittensory-ui/src/lib/selfhost-env-reference.ts

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
1111
},
1212
{
1313
name: "AI_EMBED_API_KEY",
14-
firstReference: "src/server.ts:417",
14+
firstReference: "src/server.ts:419",
1515
},
1616
{
1717
name: "AI_EMBED_BASE_URL",
18-
firstReference: "src/server.ts:414",
18+
firstReference: "src/server.ts:416",
1919
},
2020
{
2121
name: "AI_EMBED_MODEL",
@@ -43,7 +43,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
4343
},
4444
{
4545
name: "BACKUP_ACKNOWLEDGED",
46-
firstReference: "src/server.ts:356",
46+
firstReference: "src/server.ts:358",
4747
},
4848
{
4949
name: "BROWSER_WS_ENDPOINT",
@@ -75,11 +75,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
7575
},
7676
{
7777
name: "CRON_INTERVAL_MS",
78-
firstReference: "src/server.ts:819",
78+
firstReference: "src/server.ts:839",
7979
},
8080
{
8181
name: "DATABASE_PATH",
82-
firstReference: "src/server.ts:239",
82+
firstReference: "src/server.ts:241",
8383
},
8484
{
8585
name: "DATABASE_URL",
@@ -103,19 +103,23 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
103103
},
104104
{
105105
name: "GITHUB_CACHE_TTL_SECONDS",
106-
firstReference: "src/server.ts:485",
106+
firstReference: "src/server.ts:487",
107107
},
108108
{
109109
name: "GITTENSORY_REPO_CONFIG_DIR",
110-
firstReference: "src/server.ts:273",
110+
firstReference: "src/server.ts:275",
111111
},
112112
{
113113
name: "GITTENSORY_VERSION",
114114
firstReference: "src/selfhost/health.ts:29",
115115
},
116+
{
117+
name: "MAINTENANCE_ADMISSION_ENABLED",
118+
firstReference: "src/selfhost/maintenance-admission.ts:83",
119+
},
116120
{
117121
name: "MIGRATIONS_DIR",
118-
firstReference: "src/server.ts:369",
122+
firstReference: "src/server.ts:371",
119123
},
120124
{
121125
name: "OBSERVABILITY_SMOKE_POLL_MS",
@@ -175,7 +179,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
175179
},
176180
{
177181
name: "ORB_BROKER_URL",
178-
firstReference: "src/server.ts:863",
182+
firstReference: "src/server.ts:883",
179183
},
180184
{
181185
name: "ORB_COLLECTOR_TOKEN",
@@ -191,7 +195,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
191195
},
192196
{
193197
name: "ORB_RELAY_MODE",
194-
firstReference: "src/server.ts:865",
198+
firstReference: "src/server.ts:885",
195199
},
196200
{
197201
name: "OTEL_EXPORTER_OTLP_ENDPOINT",
@@ -223,11 +227,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
223227
},
224228
{
225229
name: "PGVECTOR_ENABLED",
226-
firstReference: "src/server.ts:219",
230+
firstReference: "src/server.ts:221",
227231
},
228232
{
229233
name: "PORT",
230-
firstReference: "src/server.ts:623",
234+
firstReference: "src/server.ts:643",
231235
},
232236
{
233237
name: "PUBLIC_API_ORIGIN",
@@ -243,7 +247,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
243247
},
244248
{
245249
name: "QDRANT_URL",
246-
firstReference: "src/server.ts:504",
250+
firstReference: "src/server.ts:506",
247251
},
248252
{
249253
name: "QUEUE_BACKGROUND_CONCURRENCY",
@@ -255,7 +259,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
255259
},
256260
{
257261
name: "REVIEW_AUDIT_DIR",
258-
firstReference: "src/server.ts:549",
262+
firstReference: "src/server.ts:551",
259263
},
260264
{
261265
name: "SELFHOST_BUNDLE_ALL",
@@ -291,41 +295,42 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
291295
},
292296
{
293297
name: "SETUP_OUTPUT_PATH",
294-
firstReference: "src/server.ts:740",
298+
firstReference: "src/server.ts:760",
295299
},
296300
];
297301

298302
export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
299303
"| Name | First reference |",
300304
"| --- | --- |",
301305
"| `AI_COMBINE` | `src/selfhost/ai.ts:848` |",
302-
"| `AI_EMBED_API_KEY` | `src/server.ts:417` |",
303-
"| `AI_EMBED_BASE_URL` | `src/server.ts:414` |",
306+
"| `AI_EMBED_API_KEY` | `src/server.ts:419` |",
307+
"| `AI_EMBED_BASE_URL` | `src/server.ts:416` |",
304308
"| `AI_EMBED_MODEL` | `src/selfhost/ai.ts:744` |",
305309
"| `AI_ON_MERGE` | `src/selfhost/ai.ts:850` |",
306310
"| `AI_PROVIDER` | `src/selfhost/ai-config.ts:43` |",
307311
"| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts:748` |",
308312
"| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts:57` |",
309313
"| `ANTHROPIC_API_KEY` | `src/selfhost/ai.ts:747` |",
310-
"| `BACKUP_ACKNOWLEDGED` | `src/server.ts:356` |",
314+
"| `BACKUP_ACKNOWLEDGED` | `src/server.ts:358` |",
311315
"| `BROWSER_WS_ENDPOINT` | `src/selfhost/stubs/puppeteer.ts:11` |",
312316
"| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts:108` |",
313317
"| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts:49` |",
314318
"| `CLAUDE_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:108` |",
315319
"| `CODEX_AI_EFFORT` | `src/selfhost/ai.ts:112` |",
316320
"| `CODEX_AI_MODEL` | `src/selfhost/ai.ts:53` |",
317321
"| `CODEX_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:112` |",
318-
"| `CRON_INTERVAL_MS` | `src/server.ts:819` |",
319-
"| `DATABASE_PATH` | `src/server.ts:239` |",
322+
"| `CRON_INTERVAL_MS` | `src/server.ts:839` |",
323+
"| `DATABASE_PATH` | `src/server.ts:241` |",
320324
"| `DATABASE_URL` | `src/selfhost/preflight.ts:201` |",
321325
"| `DISCORD_REPO_WEBHOOKS` | `src/selfhost/discord-notify.ts:31` |",
322326
"| `DISCORD_WEBHOOK_URL` | `src/selfhost/discord-notify.ts:40` |",
323327
"| `GITHUB_APP_ID` | `src/selfhost/orb-collector.ts:59` |",
324328
"| `GITHUB_APP_PRIVATE_KEY` | `src/selfhost/orb-collector.ts:166` |",
325-
"| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:485` |",
326-
"| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:273` |",
329+
"| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:487` |",
330+
"| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:275` |",
327331
"| `GITTENSORY_VERSION` | `src/selfhost/health.ts:29` |",
328-
"| `MIGRATIONS_DIR` | `src/server.ts:369` |",
332+
"| `MAINTENANCE_ADMISSION_ENABLED` | `src/selfhost/maintenance-admission.ts:83` |",
333+
"| `MIGRATIONS_DIR` | `src/server.ts:371` |",
329334
"| `OBSERVABILITY_SMOKE_POLL_MS` | `scripts/smoke-observability-traces.mjs:8` |",
330335
"| `OBSERVABILITY_SMOKE_TIMEOUT_MS` | `scripts/smoke-observability-traces.mjs:6` |",
331336
"| `OLLAMA_AI_API_KEY` | `src/selfhost/ai.ts:741` |",
@@ -340,27 +345,27 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
340345
"| `ORB_AIR_GAP` | `src/selfhost/orb-collector.ts:161` |",
341346
"| `ORB_ANONYMIZE` | `src/selfhost/orb-collector.ts:174` |",
342347
"| `ORB_APP_ID` | `src/selfhost/orb-collector.ts:59` |",
343-
"| `ORB_BROKER_URL` | `src/server.ts:863` |",
348+
"| `ORB_BROKER_URL` | `src/server.ts:883` |",
344349
"| `ORB_COLLECTOR_TOKEN` | `src/selfhost/orb-collector.ts:205` |",
345350
"| `ORB_COLLECTOR_URL` | `src/selfhost/orb-collector.ts:172` |",
346351
"| `ORB_ENROLLMENT_SECRET` | `src/selfhost/orb-collector.ts:165` |",
347-
"| `ORB_RELAY_MODE` | `src/server.ts:865` |",
352+
"| `ORB_RELAY_MODE` | `src/server.ts:885` |",
348353
"| `OTEL_EXPORTER_OTLP_ENDPOINT` | `src/selfhost/otel.ts:47` |",
349354
"| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `src/selfhost/otel.ts:45` |",
350355
"| `OTEL_SERVICE_ENVIRONMENT` | `src/selfhost/otel.ts:60` |",
351356
"| `OTEL_SERVICE_NAME` | `src/selfhost/otel.ts:59` |",
352357
"| `OTEL_TRACES_EXPORTER` | `src/selfhost/otel.ts:40` |",
353358
"| `OTEL_TRACES_SAMPLER` | `src/selfhost/otel.ts:74` |",
354359
"| `OTEL_TRACES_SAMPLER_ARG` | `src/selfhost/otel.ts:76` |",
355-
"| `PGVECTOR_ENABLED` | `src/server.ts:219` |",
356-
"| `PORT` | `src/server.ts:623` |",
360+
"| `PGVECTOR_ENABLED` | `src/server.ts:221` |",
361+
"| `PORT` | `src/server.ts:643` |",
357362
"| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts:192` |",
358363
"| `QDRANT_API_KEY` | `src/selfhost/qdrant-vectorize.ts:50` |",
359364
"| `QDRANT_DIM` | `src/selfhost/qdrant-vectorize.ts:71` |",
360-
"| `QDRANT_URL` | `src/server.ts:504` |",
365+
"| `QDRANT_URL` | `src/server.ts:506` |",
361366
"| `QUEUE_BACKGROUND_CONCURRENCY` | `src/selfhost/queue-common.ts:102` |",
362367
"| `REDIS_URL` | `src/selfhost/preflight.ts:144` |",
363-
"| `REVIEW_AUDIT_DIR` | `src/server.ts:549` |",
368+
"| `REVIEW_AUDIT_DIR` | `src/server.ts:551` |",
364369
"| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.mjs:13` |",
365370
"| `SELFHOST_SERVICE` | `scripts/smoke-observability-traces.mjs:5` |",
366371
"| `SELFHOST_SETUP_TOKEN` | `src/selfhost/preflight.ts:186` |",
@@ -369,5 +374,5 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
369374
"| `SENTRY_RELEASE` | `src/selfhost/otel.ts:62` |",
370375
"| `SENTRY_SERVER_NAME` | `src/selfhost/sentry.ts:373` |",
371376
"| `SENTRY_TRACES_SAMPLE_RATE` | `src/selfhost/sentry.ts:161` |",
372-
"| `SETUP_OUTPUT_PATH` | `src/server.ts:740` |",
377+
"| `SETUP_OUTPUT_PATH` | `src/server.ts:760` |",
373378
].join("\n");

docker-compose.override.yml.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@
2525
# strongly you want review work to win contention (8:1 below is deliberately aggressive), and keep each
2626
# runner replica's `cpus` ceiling at roughly (host vCPUs) / (number of runner replicas you plan to run),
2727
# so a single busy runner can't monopolize the box even before shares kick in.
28+
#
29+
# This is the HOST-level (Docker CPU scheduling) half of the fix. The APPLICATION-level half is the
30+
# MAINTENANCE_ADMISSION_* knobs in .env.example: gittensory itself defers its own maintenance sweeps
31+
# (contributor evidence, RAG indexing, drift scans...) under queue/host pressure so live webhook/review
32+
# work always wins there too. Also see ./scripts/docker-prune.sh for the disk-usage side of running CI
33+
# builds on the same box (build cache/image growth, not just CPU).
34+
# The RECOMMENDED alternative to all of this: don't co-locate runners with the review stack at all -- use
35+
# GitHub-hosted CI, or a separate dedicated runner host (see the runner service's comment in
36+
# docker-compose.yml).
2837

2938
services:
3039
gittensory:

docker-compose.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,12 +609,17 @@ services:
609609
network_mode: host # Tailscale needs host networking to advertise the host's address
610610

611611
# ── Self-hosted GitHub Actions runner (--profile runners) ─────────────────
612+
# NOT part of the default recommended stack -- purely optional. Three deployment modes (see .env.example
613+
# for the full explanation): (1) app-only VPS + GitHub-hosted CI (recommended default, no runner here at
614+
# all), (2) a separate dedicated runner host, (3) co-located runners on this SAME box (this profile).
612615
# Runs `runs-on: self-hosted` jobs on this machine. Set RUNNER_TOKEN= (or ACCESS_TOKEN=)
613616
# and RUNNER_REPO_URL= (e.g. https://github.com/your-org/your-repo) in .env.
614617
# Get a token at: https://github.com/<owner>/<repo>/settings/actions/runners/new
615618
# Running this alongside the main `gittensory` app on the SAME host? A burst of CI jobs can starve the
616-
# app of CPU with no limits set (the default below). See docker-compose.override.yml.example for a
617-
# proven CPU-priority pattern (relative cpu_shares + a per-container cpus ceiling) before scaling this up.
619+
# app of CPU with no limits set (the default below) -- confirmed in production: 3 uncapped runner
620+
# containers on an 8-vCPU box left the app starved under load. See docker-compose.override.yml.example
621+
# for a proven CPU-priority pattern (relative cpu_shares + a per-container cpus ceiling) before scaling
622+
# this up, and ./scripts/docker-prune.sh for reclaiming the disk space CI builds accumulate.
618623
runner:
619624
# Pin to a specific version tag — `latest` is mutable. Find a digest via:
620625
# docker pull myoung34/github-runner:ubuntu-jammy && docker inspect --format='{{index .RepoDigests 0}}' myoung34/github-runner:ubuntu-jammy

0 commit comments

Comments
 (0)