Skip to content

Commit 86aeefa

Browse files
fix(server): stop webhook ingestion failing silently (#1501)
Bounds every webhook stream by disk as well as by age, so a stream can no longer fill the host volume and wedge the broker. Puts the ingestion, consumer and review health checks into the readiness group they were written for, so an ingestion outage stops reporting healthy. Counts webhooks actually lost rather than how full a stream is, configures the broker to carry what the receiver admits, and expires message-queue consumers after 30 days with nothing connected. Removes NATS_JS_MAX_FILE in favour of NATS_JS_MAX_FILE_BYTES; see MIGRATION.md.
1 parent 50fa5ed commit 86aeefa

55 files changed

Lines changed: 3488 additions & 191 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/reap-disposable-nats-consumers.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22
"hephaestus": minor
33
---
44

5-
Deployments that are deleted rather than shut down can now hand their NATS consumers back automatically. Set `HEPHAESTUS_INTEGRATION_CONSUMER_INACTIVE_THRESHOLD` (for example `72h`) on any stack that shares a NATS server but is disposable — a pull request preview, a throwaway test environment — and its consumers are removed once nothing has been bound for that long, instead of accumulating on the shared stream one generation per deleted stack. Left unset, which is the default and what a long-lived deployment wants, consumers keep their position across restarts exactly as before.
5+
A deployment's message-queue consumers no longer outlive the deployment itself. Consumers were kept forever, and nothing else removes one, so every stack that shared a broker and was deleted rather than shut down — a pull request preview, a throwaway test environment — left its consumers, and backlogs that would never drain, behind for good. They now expire on their own once nothing has been connected to them for long enough.
6+
7+
**Operators:** `HEPHAESTUS_INTEGRATION_CONSUMER_INACTIVE_THRESHOLD` defaults to `30d`, where it previously meant never. It measures time with nothing connected, not time without traffic — a running deployment resets it continuously even while its queues are silent, so no restart, deploy or incident reaches it. Set something far shorter, such as `72h`, on any disposable stack that shares a broker. If your deployment may be offline for longer than the threshold and must resume exactly where it left off, set `0s` to switch expiry off. Values between `0s` and `1h` are now rejected at startup rather than quietly expiring a consumer across an ordinary restart.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
An ingestion outage is no longer silent. The readiness check reported only that the process had started: the checks that know whether webhooks can be received, whether the message consumer is connected and whether reviews can run were meant to be part of it and never were. A deployment whose message broker had stopped accepting writes kept answering healthy while it dropped every delivery from GitHub, GitLab, Slack and Outline. Readiness now reports all of them, which is what makes an alert on it possible.
6+
7+
The server also counts webhooks that were lost rather than how close a stream is to being full: if a message is deleted before the consumer that needed it has read it, that is recorded, named, and logged as an error — the one thing nobody can recover from afterwards.
8+
9+
**Operators:** readiness now fails while the message broker is unreachable, which on a container that also serves the app takes it out of load-balancer rotation until the broker recovers. If you treated readiness as a liveness signal, it now reports operational dependencies too. Alert on `webhook.stream.unacknowledged.deletions` — any increase is webhook data that is gone for good — and on `webhook.stream.poll.age` beside it, because a check that cannot reach the broker reports no loss and no loss the same way.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
Webhook message streams can no longer fill the disk and take ingestion down with them. They were bounded only by a message count, which says nothing about storage: one deployment's GitHub stream reached 32.3 GB at exactly its cap, filled the host, stopped the broker writing, and dropped every inbound webhook until the broker was restarted by hand.
6+
7+
Each stream now states both of its bounds: how long a delivery is kept at most, and a disk ceiling under that. Which one you actually get depends on your traffic — at low volume the time limit is delivered in full, at high volume the disk ceiling recycles the stream sooner — and the server reports the answer for your deployment as the age of the oldest message it still holds. It refuses to start if the streams together are allowed more than the broker's own budget. Lowering a limit also takes effect on a stream that already exists, instead of being reported and ignored; a change that would delete messages already stored is held back and logged with exactly what it would cost until you allow it, and a change that would leave a stream with no limit at all is held back regardless.
8+
9+
Deliveries larger than the broker will carry are no longer accepted and then lost at publish: the broker is configured to take everything the receiver admits, and the receiver says so loudly if the two disagree.
10+
11+
**Operators:** `NATS_JS_MAX_FILE` is **removed** and nothing reads it any more. Replace it with `NATS_JS_MAX_FILE_BYTES`, in bytes — a deployment that leaves the old variable set silently drops to the new 16 GiB default instead of the 50 GB it had. Set it below the free space on the broker's volume, and keep the per-stream ceilings totalling under it or the server will not start. The 180-day retention limit is unchanged, but a busy GitHub stream now recycles on disk well before that; `HEPHAESTUS_WEBHOOK_STREAM_MAX_BYTES` and the new `HEPHAESTUS_WEBHOOK_STREAM_MAX_BYTES_GITHUB` set that ceiling. A stream already larger than its new ceiling stays as it is and logs what bounding it would delete, until you set `HEPHAESTUS_WEBHOOK_STREAM_ALLOW_DESTRUCTIVE_LIMIT_UPDATES=true` once. See MIGRATION.md.

.github/workflows/ci-quality-gates.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ jobs:
113113
pnpm install --frozen-lockfile
114114
115115
ISSUES_FOUND=()
116-
FMT_OK=true; BIOME_OK=true; PIN_OK=true; TESTS_OK=true; TYPES_OK=true; SCRIPT_TYPES_OK=true; CONTRACTS_OK=true
116+
FMT_OK=true; BIOME_OK=true; PIN_OK=true; TESTS_OK=true; TYPES_OK=true; SCRIPT_TYPES_OK=true; CONTRACTS_OK=true; ENV_OK=true
117117
118118
pnpm run format:java:check || { FMT_OK=false; ISSUES_FOUND+=("Java formatting failed. Run: pnpm run format:java"); }
119119
# Runs before the Biome step so a version skew is reported as a version skew rather than
@@ -136,6 +136,10 @@ jobs:
136136
pnpm run typecheck:scripts \
137137
|| { SCRIPT_TYPES_OK=false; ISSUES_FOUND+=("scripts/ TypeScript errors. Run: pnpm run typecheck:scripts"); }
138138
pnpm run check:contracts || { CONTRACTS_OK=false; ISSUES_FOUND+=("Artifact-source contract validation failed. Run: pnpm run check:contracts"); }
139+
# Reads application.yml against the compose files, both behind this job's path filter. It
140+
# was reachable only from the pre-push hook, which --no-verify skips, so the gate written
141+
# for a defect that reached production could not fail a required build.
142+
pnpm run check:env || { ENV_OK=false; ISSUES_FOUND+=("A setting does not reach a container that can read it. Run: pnpm run check:env"); }
139143
140144
if [ ${#ISSUES_FOUND[@]} -gt 0 ]; then
141145
echo "### App Server Quality" >> $GITHUB_STEP_SUMMARY
@@ -148,6 +152,7 @@ jobs:
148152
[[ "$TYPES_OK" == "false" ]] && echo "| Agent typecheck | :x: Failed | \`pnpm run typecheck:agents\` |" >> $GITHUB_STEP_SUMMARY
149153
[[ "$SCRIPT_TYPES_OK" == "false" ]] && echo "| scripts/ typecheck | :x: Failed | \`pnpm run typecheck:scripts\` |" >> $GITHUB_STEP_SUMMARY
150154
[[ "$CONTRACTS_OK" == "false" ]] && echo "| Artifact-source contracts | :x: Failed | \`pnpm run check:contracts\` |" >> $GITHUB_STEP_SUMMARY
155+
[[ "$ENV_OK" == "false" ]] && echo "| Env delivery by runtime role | :x: Failed | \`pnpm run check:env\` |" >> $GITHUB_STEP_SUMMARY
151156
for issue in "${ISSUES_FOUND[@]}"; do echo "::error::$issue"; done
152157
exit 1
153158
fi
@@ -164,7 +169,8 @@ jobs:
164169
BIOME_OK=true
165170
TYPES_OK=true
166171
# The webapp-scoped tree gates run here as well as in the pre-push hook, because a hook is
167-
# skippable and a merge is not. check:env stays out: it reads server files this leg omits.
172+
# skippable and a merge is not. check:env runs on the App Server leg, which has the server
173+
# and compose files it reads.
168174
COMPONENTS_OK=true
169175
STORIES_OK=true
170176

.github/workflows/cicd.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ jobs:
6767
- 'pnpm-lock.yaml'
6868
# check:biome-pin compares this file's $schema against the pinned version.
6969
- 'webapp/biome.json'
70+
# check:env compares application.yml against the deployment that has to deliver it, so
71+
# a change to either side has to be able to trigger the job that runs it.
72+
- 'docker/compose.app.yaml'
73+
- 'docker/compose.core.yaml'
7074
agent-images:
7175
- 'docker/agents/**'
7276
docs:

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
.ai/
66
# and can be added to the global gitignore or merged into this file. For a more nuclear
77
application-live-local.yml
8+
application-live-local.yaml
89
application-local.yml
10+
# Spring reads both extensions, so a .yaml typo is a config file that works and is tracked.
911
application-local.yaml
1012
**/atlassian-ide-plugin.xml
1113
# auto-import.

MIGRATION.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,128 @@ A preview now derives its agent image from its own commit, and CI publishes one
122122
when the pull request changed the agent tree or a workflow. Previously such a preview silently used
123123
the last release's image. Set `HEPHAESTUS_AGENT_IMAGE_REFERENCE` in the preview's `.env` to the agent
124124
image you want it to exercise — `docker/preview/.env.example` shows the line.
125+
#### 🔴 `NATS_JS_MAX_FILE` is gone, and webhook streams now have a disk bound
126+
127+
**Affected**: every deployment.
128+
129+
**Do this before upgrading:**
130+
131+
1. **If you set `NATS_JS_MAX_FILE`, replace it with `NATS_JS_MAX_FILE_BYTES`, in bytes.** The old
132+
variable is no longer read by anything. Nothing warns you: a deployment that had `100G` silently
133+
drops to the new 16 GiB default. `50G` becomes `NATS_JS_MAX_FILE_BYTES=53687091200`.
134+
2. **Check your current stream sizes** with `nats stream report`. A stream already larger than its
135+
new ceiling is left exactly as it is and logs an error on every start until you decide — see
136+
below.
137+
3. **Keep the per-stream ceilings totalling under `NATS_JS_MAX_FILE_BYTES`**, or the receiver refuses
138+
to start.
139+
140+
Everything else in this entry is context.
141+
142+
---
143+
144+
Webhook streams were bounded only by message count, which says nothing about disk: on one deployment
145+
2,000,000 GitHub deliveries came to 32.3 GB, filled the host, stopped the broker writing, and every
146+
inbound webhook was dropped until the broker was restarted by hand.
147+
148+
**Two bounds now, not three.** `HEPHAESTUS_WEBHOOK_STREAM_MAX_AGE` stays at 180 days: it is the
149+
*ceiling*, the longest a delivery is kept if disk allows. `HEPHAESTUS_WEBHOOK_STREAM_MAX_BYTES`
150+
(1 GiB per stream) and `HEPHAESTUS_WEBHOOK_STREAM_MAX_BYTES_GITHUB` (10 GiB) are the *floor* under
151+
it, and on a busy deployment they are what actually decides retention. Both are true; which one binds
152+
is a function of your volume, and the server now publishes the answer as
153+
`webhook.stream.oldest.message.age{stream}`, in seconds, so you can read your own effective retention
154+
rather than infer it. The message-count bound is gone: a count describes neither disk nor time.
155+
156+
The byte ceiling is sized against what a shed message costs, not against the age ceiling. The nightly
157+
reconciliation sync re-fetches the last `MONITORING_TIMEFRAME` days from the provider API, so a
158+
webhook shed inside that window is recoverable by other means and one shed outside it is recoverable
159+
by nothing. If you raise `MONITORING_TIMEFRAME`, raise the byte ceilings with it.
160+
161+
`NATS_JS_MAX_FILE_BYTES` sets the broker's own budget and the application's from one value, and the
162+
server refuses to start if the per-stream bounds sum above it:
163+
164+
```
165+
Webhook stream bounds total 21474836480 bytes, over the 17179869184-byte broker storage budget
166+
```
167+
168+
Set it below the free space on the broker's volume. This is the difference between the broker filling
169+
its own budget — where it refuses new messages and recovers by itself — and filling the filesystem,
170+
where it cannot write its own metadata and stays wedged even after space is freed.
171+
172+
**A stream that already exceeds its new bound is left exactly as it is.** Bounding it deletes the
173+
excess the moment it applies, so startup withholds the change and logs what it would cost:
174+
175+
```
176+
Stream github limit change withheld because it would delete stored messages:
177+
[maxBytes -1 -> 10737418240 (32300000000 bytes stored, 21562581760 would be deleted)] —
178+
set hephaestus.webhook.stream.allow-destructive-limit-updates=true to apply it
179+
Stream github bound removal withheld because it would leave the stream unbounded:
180+
[maxMessages 2000000 -> -1 (no byte bound is in force to replace it)] —
181+
get hephaestus.webhook.stream.max-bytes[-by-stream] applied first
182+
```
183+
184+
Decide the bound first — raise `HEPHAESTUS_WEBHOOK_STREAM_MAX_BYTES_GITHUB` if the size the log
185+
reports is legitimate for your traffic, keeping the total under `NATS_JS_MAX_FILE_BYTES`. Then set
186+
`HEPHAESTUS_WEBHOOK_STREAM_ALLOW_DESTRUCTIVE_LIMIT_UPDATES=true` for one start-up to apply it, and
187+
unset it again. Until you do, the stream keeps the message-count cap it already has: the count cap is
188+
only released once a byte ceiling is in force to replace it, so an upgrade cannot leave a stream with
189+
no limit at all.
190+
191+
Streams that already fit inside the bound are bounded automatically, with nothing deleted. Subjects,
192+
retention mode, storage and discard policy are never rewritten by a deployment. A stream whose shape
193+
has drifted from what this deployment expects is left entirely alone and logged — repair it with
194+
`nats stream edit` before the bound can be applied.
195+
196+
Full metric roster and the wedged-broker procedure:
197+
[Webhook ingestion operations](https://ls1intum.github.io/Hephaestus/admin/webhook-ingestion-operations).
198+
199+
200+
#### 🟡 Readiness now answers for more than "the process started"
201+
202+
**Affected**: every deployment that monitors `/actuator/health/readiness`, and in particular any that
203+
runs webhook receiving in the same container as the application — a single-container install, or the
204+
preview stacks.
205+
206+
`/actuator/health/readiness` was the stock probe group on every container: the process's own
207+
availability state and nothing else. The group meant to add the message-consumer, practice-review and
208+
webhook checks was written under a property path Spring does not bind, so it was silently ignored, and
209+
a container whose broker had stopped accepting writes answered 200 throughout. Those checks are now in
210+
the probe. Expect readiness to follow broker availability, and on a combined-role container expect a
211+
broker outage to take it out of load-balancer rotation until the broker returns.
212+
213+
**Migration**: none, but re-read what you alert on. If your dashboards treated readiness as a liveness
214+
signal, it now reports operational dependencies as well, which is the point.
215+
216+
#### 🟡 Message-queue consumers now expire after 30 days with nothing connected
217+
218+
**Affected**: deployments that may be offline for more than 30 consecutive days and must resume
219+
exactly where they left off. Everything else needs no action.
220+
221+
`HEPHAESTUS_INTEGRATION_CONSUMER_INACTIVE_THRESHOLD` previously defaulted to *never expire*. Nothing
222+
else removes a consumer, so any stack that shared a broker and was deleted rather than shut down — a
223+
preview, a test environment — left its consumers and their undrained backlogs on that broker
224+
permanently, one generation per deleted stack.
225+
226+
**What it measures is connection, not traffic.** A running deployment holds standing requests against
227+
its consumers, so it resets the clock continuously even while its queues are completely silent. Only a
228+
deployment that no longer exists ages out, which is why 30 days is safe: no restart, deploy or incident
229+
reaches it.
230+
231+
**Migration**: nothing to do, unless your deployment can be down for more than 30 days and must not
232+
skip what arrived meanwhile. In that case set `HEPHAESTUS_INTEGRATION_CONSUMER_INACTIVE_THRESHOLD=0s`
233+
before upgrading, which switches expiry off exactly as before. A consumer that does expire is recreated
234+
pointing at new messages only, so it skips anything that arrived while the deployment was gone.
235+
236+
`0s` preserves the consumer's *position*, not the *messages*. Those expire on the stream's own
237+
retention, independently, so a deployment offline past the stream's byte or age ceiling comes back to
238+
a cursor pointing at messages the stream no longer holds — the loss counter will say so on the first
239+
poll. Switching expiry off buys you the stream's retention window, not an unbounded one.
240+
241+
Values between `0s` and `1h` are now rejected at startup — a threshold that short expires a consumer
242+
across an ordinary restart, which is the data loss the setting exists to avoid:
243+
244+
```
245+
inactive-threshold (PT30M) must be 0 to disable reaping, or at least PT1H
246+
```
125247

126248
#### 🟡 Reviewer-side practices keep the old wording until you update them
127249

docker/.env.example

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,37 @@ HEPHAESTUS_SECURITY_ENCRYPTION_KEY=
9898
NATS_ENABLED=true
9999
NATS_DURABLE_CONSUMER_NAME=hephaestus-consumer
100100

101+
# How long a durable consumer may go with nothing CONNECTED to it before JetStream deletes it — not
102+
# how long it goes without traffic. Set 0s on a deployment that may be offline longer than this and
103+
# must resume exactly where it left off; between 0s and 1h is rejected at startup.
104+
HEPHAESTUS_INTEGRATION_CONSUMER_INACTIVE_THRESHOLD=30d
105+
106+
# What the broker may hold for ALL webhook streams together, in bytes. One value sets both the
107+
# broker's own JetStream budget and the bound the server checks its stream limits against. Keep it
108+
# below the free space on the nats-data volume. Replaces NATS_JS_MAX_FILE, which was a size string
109+
# and is no longer read — see MIGRATION.md.
110+
NATS_JS_MAX_FILE_BYTES=17179869184
111+
112+
# Largest single message the broker will carry. Must be at least what the receiver accepts
113+
# (hephaestus.webhook.http.max-payload-bytes, 25 MiB) — anything above it is verified, admitted and
114+
# then lost at publish. NATS' own default is 1MB, which is far below that.
115+
NATS_MAX_PAYLOAD_BYTES=26214400
116+
117+
# How long a webhook stream keeps a delivery, and the disk ceiling under that. MAX_AGE is the
118+
# ceiling; MAX_BYTES is the floor under it, and which of the two binds is a function of your volume.
119+
# Read your own answer off `webhook.stream.oldest.message.age` rather than inferring it. The four
120+
# streams' ceilings must total under NATS_JS_MAX_FILE_BYTES or the receiver refuses to start.
121+
# https://ls1intum.github.io/Hephaestus/admin/webhook-ingestion-operations
122+
HEPHAESTUS_WEBHOOK_STREAM_MAX_AGE=180d
123+
HEPHAESTUS_WEBHOOK_STREAM_MAX_BYTES=1GB
124+
HEPHAESTUS_WEBHOOK_STREAM_MAX_BYTES_GITHUB=10GB
125+
126+
# Startup keeps an existing stream's limits in step with configuration, but withholds any change
127+
# that would delete messages the stream already holds and logs what it would cost. Set true for one
128+
# deliberate start-up to apply it — for example when first bounding a stream that has already grown
129+
# past the new limit.
130+
HEPHAESTUS_WEBHOOK_STREAM_ALLOW_DESTRUCTIVE_LIMIT_UPDATES=false
131+
101132
# -----------------------------------------------------------------------------
102133
# MONITORING & SYNC
103134
# -----------------------------------------------------------------------------

docker/compose.app.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ services:
202202
# Default on: the integration event flow needs NATS in prod (the code-level fallback is off for local dev).
203203
NATS_ENABLED: ${NATS_ENABLED:-true}
204204
NATS_DURABLE_CONSUMER_NAME: ${NATS_DURABLE_CONSUMER_NAME:-hephaestus}
205+
# How long this deployment's durables may go with nothing bound to them before JetStream
206+
# deletes them. See docker/.env.example for what to set it to and when.
207+
HEPHAESTUS_INTEGRATION_CONSUMER_INACTIVE_THRESHOLD: ${HEPHAESTUS_INTEGRATION_CONSUMER_INACTIVE_THRESHOLD:-30d}
205208
MONITORING_TIMEFRAME: ${MONITORING_TIMEFRAME:-7}
206209
MONITORING_RUN_ON_STARTUP: ${MONITORING_RUN_ON_STARTUP:-true}
207210
MONITORING_SYNC_CRON: ${MONITORING_SYNC_CRON:-0 0 * * * *}

0 commit comments

Comments
 (0)