fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620) - #860

Merged
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence
Aug 27, 2026
Merged

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620)#860
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2620

The defect

Both CronJobs the chart renders used restartPolicy: OnFailure. Kubernetes deletes an OnFailure Job's Pod once backoffLimit is exhausted, and failedJobsHistoryLimit retains the Job — it cannot retain a Pod that no longer exists.

So a repeatedly-failing CronJob leaves a row of Failed Jobs and no logs at all. The upstream docs say it plainly:

"your Pod running the Job will be terminated once the job backoff limit has been reached. This can make debugging the Job's executable more difficult. We suggest setting restartPolicy = 'Never'"

Measured, not theoretical

An edge's auto-upgrade CronJob had been failing hourly for 2.7 days. Five Failed Jobs were retained; every one of their Pods was gone, and the namespace event window had rolled past the first failure — so the reason was unrecoverable from the cluster.

The only surviving evidence that anything was wrong was the CronJob's lastSuccessfulTime. Each Job failed 39 seconds in, so this was never a timeout or a hang: the logs would have said exactly what it was, and they were thrown away.

The consequence was that the edge silently stopped accepting chart upgrades — including any security fix — while looking healthy from every angle anyone checks.

Both CronJobs, not just the one that broke

image-refresh carried the same policy on a 15-minute schedule — the same blind spot at four times the frequency. Fixing only the instance that happened to fail would have left the class.

The guard

scripts/tests/cronjob-failures-are-readable.sh reads every CronJob out of the rendered manifests, so a third one is covered the day it lands rather than when someone remembers. It holds no list of CronJob names.

Fails closed twice:

  • zero CronJobs rendered is a refusal, not a clean sweep — a guard that checks nothing otherwise passes;
  • a template declaring kind: CronJob that no value combination in the render matrix reaches is reported UNREACHED by name and fails, rather than passing silently as "nothing to check".

Wired into DRIFT_GUARDS (now 21 entries — the drift target counts its iterations and refuses to report green on fewer, so adding a guard without wiring it cannot pass).

Scoped to CronJob deliberately, with the argument recorded at the declaration rather than assumed: a Helm hook Job loses its Pod the same way, but its failure fails the release and is reported to whoever ran it — somebody is already looking. A CronJob's failure is reported to nobody and repeats forever, which is what makes unreadable logs fatal rather than inconvenient. Widening it would need that argument made.

Mutation evidence

Four mutations, each run with the baseline confirmed green afterwards:

mutationresult
one template reverts to OnFailureFAIL — names the template and the policy
restartPolicy removed entirelyFAIL"silence is the defect" (Kubernetes defaults it to OnFailure)
render matrix produces no CronJobsFAIL — refuses to report on zero
a new CronJob template goes unreachedFAIL — names it as [UNREACHED]

On the two tests that changed

auto_upgrade_test.yaml and image_refresh_test.yaml each explicitly asserted OnFailure. They were right to pin it — a policy that matters should be pinned — so they are updated rather than deleted, and the comment now records why the pinned value changed and where the class-level sweep lives.

Checks

  • make helm-unittest579 passed, 34 suites
  • make driftall 21 guards green (verified the new one actually executed, not just that the count matched)
  • make helm-lint, make helm-template — clean
  • shellcheck on the new guard — clean

Note on rollout

This change only takes effect on an edge once that edge receives the chart. An edge whose auto-upgrade is already wedged cannot pull it — so unwedging those is separate operational work, tracked in the ticket. The immediate mitigation there is a direct kubectl patch of the CronJob's restartPolicy, which this change makes permanent and unnecessary going forward.

Ticket: tracebloc/backend#2620


Note

Low Risk
Operational observability fix for scheduled jobs with no auth or data-path changes; slightly more failed Pods retained per Job tick, which is intentional.

Overview
Fixes backend#2620: chart CronJobs used restartPolicy: OnFailure, which lets Kubernetes delete the Pod once backoffLimit is exhausted while failedJobsHistoryLimit only keeps the Job—so repeated failures left no logs (observed on a prod edge with hourly auto-upgrade failures for 2.7 days).

auto-upgrade and image-refresh CronJob pod templates now set restartPolicy: Never so each attempt keeps a Pod for debugging. Chart bumps to 1.9.74.

A new required drift guard scripts/tests/cronjob-failures-are-readable.sh renders client/ingestor charts and requires every rendered CronJob to declare Never (or fails on missing policy / unreached CronJob templates). It is wired into DRIFT_GUARDS. Helm unittest suites for both CronJobs pin Never instead of OnFailure.

Rollout note: edges already wedged on auto-upgrade still need operational unwedging; this only applies after they receive the chart (or a kubectl patch on the CronJob).

Reviewed by Cursor Bugbot for commit 92b0f6e. Bugbot is set up for automated code reviews on this repo. Configure here.

…kend#2620)
Both of the chart's CronJobs used `restartPolicy: OnFailure`. Kubernetes DELETES
an OnFailure Job's Pod once `backoffLimit` is exhausted, and
`failedJobsHistoryLimit` retains the JOB — it cannot retain a Pod that no longer
exists. So a repeatedly-failing CronJob leaves a row of `Failed` Jobs and no
logs at all. The upstream docs say it outright: "your Pod running the Job will be
terminated once the job backoff limit has been reached. This can make debugging
the Job's executable more difficult. We suggest setting restartPolicy = 'Never'".
MEASURED, NOT THEORETICAL. A customer prod edge's auto-upgrade had been failing
hourly for 2.7 days (backend#2620). Five `Failed` Jobs were retained; every one
of their Pods was gone and the namespace event window had rolled past the first
failure, so the reason was UNRECOVERABLE FROM THE CLUSTER. The only surviving
evidence that anything was wrong was the CronJob's `lastSuccessfulTime`, which
nothing watches. Each Job failed 39 seconds in, so this was never a timeout —
the logs would have said what it was.
BOTH CronJobs, not just the one that broke. `image-refresh` carried the same
policy and is on a 15-minute schedule, so it had the same blind spot with four
times the frequency. Fixing only the instance that happened to fail would have
left the class.
`scripts/tests/cronjob-failures-are-readable.sh` sweeps every CronJob out of the
RENDERED manifests, so a third one is covered the day it lands rather than when
someone remembers. It holds no list of CronJob names. Fails closed twice: zero
CronJobs rendered is a refusal, not a clean sweep, and a template declaring
`kind: CronJob` that no value combination reaches is reported UNREACHED by name.
Wired into `DRIFT_GUARDS` (21 entries; the target counts its iterations and
refuses to report green on fewer).
Scoped to CronJob deliberately, and the argument is at the declaration: a Helm
hook Job loses its Pod the same way, but its failure fails the release and is
reported to whoever ran it — somebody is already looking. A CronJob's failure is
reported to nobody and repeats forever.
Four mutations run, each with the baseline restored green afterwards:
one template reverts to OnFailure -> FAIL, names the template
restartPolicy removed entirely -> FAIL, "silence is the defect"
render matrix produces no CronJobs -> FAIL, refuses to check nothing
a new CronJob template goes unreached -> FAIL, names it as UNREACHED
The two suites that pinned `OnFailure` are updated rather than deleted — they
were right to pin it, and the comment now records why the pinned value changed.
make helm-unittest: 579 passed, 34 suites. make drift: all 21 guards green.
shellcheck clean.
Ticket: tracebloc/backend#2620
Parent epic: tracebloc/backend#1872
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 27, 2026
`chart content ⇒ Chart.yaml version bump` is a REQUIRED check on this repo and
was red: the branch changes chart content while `version`/`appVersion` both still
read 1.9.73, which is what `develop` carries. The gate exists because the chart's
image-refresh CronJob resolves the published tag every 15 minutes, so an
unbumped chart is not a cosmetic omission -- nothing downstream can tell the new
content from the old.
Both fields moved together to 1.9.74, one patch above develop, which is the shape
the gate asks for. They are kept EQUAL deliberately: `appVersion` is what the
rendered image tags follow, so a bump of `version` alone would advertise a
release that pulls the previous images.
Verified: `helm lint` clean (only the pre-existing "icon is recommended" note),
and the WHOLE chart suite rather than the file touched -- `helm unittest client`,
34 suites, 579 tests, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The measurement is what makes this a bug report rather than a style note: five Failed Jobs retained, every Pod gone, the event window rolled past the first failure, and each Job failing 39 seconds in — so not a hang, and the logs would have said exactly what it was. "The reason was unrecoverable from the cluster" is the sentence that matters, and the consequence is worse than the mechanism: an edge silently stopped accepting chart upgrades, security fixes included, while looking healthy from every angle anyone checks.

Fixing image-refresh too — same policy, four times the frequency — is the right instinct, and "fixing only the instance that happened to fail would have left the class" is exactly the failure I flagged on client-runtime#417 an hour ago, where a guard existed in two sibling publishers and was missing from the one shipping the control plane. Good to see it applied ahead of the incident rather than after.

The guard fails closed in three places, not the two you claimed. I checked each:

  • if not seen: sys.exit("the render matrix produced ZERO CronJobs. Nothing was checked, and a guard that checks nothing passes — refusing.")
  • unreached = expected - seen, each reported by name with the reason its policy went unchecked, and folded into the exit condition
  • and the one the description doesn't mention: a Pod spec stating norestartPolicy at all lands in missing and fails, so an unstated policy isn't read as an acceptable one

The declared set coming from grep -rlE '^kind: CronJob$' is line-anchored, so a template that merely mentions CronJob in a comment can't inflate expected and manufacture a phantom UNREACHED. And the [OK] line printing both len(seen) and len(expected) means a passing run is falsifiable rather than just green.

Two judgement calls I'd have made the same way. Scoping to CronJob and recording the argument at the declaration — a Helm hook Job loses its Pod identically, but its failure fails the release in front of the person who ran it, whereas a CronJob's failure is reported to nobody and repeats forever. And the failure message telling a future maintainer to "make that argument at the declaration and narrow this guard deliberately rather than adding an exception list here" — which is the right lesson to encode, given .github just spent three PRs burning an exemption list down to empty.

Green, no threads. 👍

@LukasWodka
LukasWodka merged commit 9e3bfb9 into developAug 27, 2026
48 checks passed
@LukasWodka
LukasWodka deleted the fix/2620-cronjob-failures-leave-evidence branch August 27, 2026 07:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620) - #860

Merged
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence
Aug 27, 2026
Merged

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620)#860
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2620

The defect

Both CronJobs the chart renders used restartPolicy: OnFailure. Kubernetes deletes an OnFailure Job's Pod once backoffLimit is exhausted, and failedJobsHistoryLimit retains the Job — it cannot retain a Pod that no longer exists.

So a repeatedly-failing CronJob leaves a row of Failed Jobs and no logs at all. The upstream docs say it plainly:

"your Pod running the Job will be terminated once the job backoff limit has been reached. This can make debugging the Job's executable more difficult. We suggest setting restartPolicy = 'Never'"

Measured, not theoretical

An edge's auto-upgrade CronJob had been failing hourly for 2.7 days. Five Failed Jobs were retained; every one of their Pods was gone, and the namespace event window had rolled past the first failure — so the reason was unrecoverable from the cluster.

The only surviving evidence that anything was wrong was the CronJob's lastSuccessfulTime. Each Job failed 39 seconds in, so this was never a timeout or a hang: the logs would have said exactly what it was, and they were thrown away.

The consequence was that the edge silently stopped accepting chart upgrades — including any security fix — while looking healthy from every angle anyone checks.

Both CronJobs, not just the one that broke

image-refresh carried the same policy on a 15-minute schedule — the same blind spot at four times the frequency. Fixing only the instance that happened to fail would have left the class.

The guard

scripts/tests/cronjob-failures-are-readable.sh reads every CronJob out of the rendered manifests, so a third one is covered the day it lands rather than when someone remembers. It holds no list of CronJob names.

Fails closed twice:

  • zero CronJobs rendered is a refusal, not a clean sweep — a guard that checks nothing otherwise passes;
  • a template declaring kind: CronJob that no value combination in the render matrix reaches is reported UNREACHED by name and fails, rather than passing silently as "nothing to check".

Wired into DRIFT_GUARDS (now 21 entries — the drift target counts its iterations and refuses to report green on fewer, so adding a guard without wiring it cannot pass).

Scoped to CronJob deliberately, with the argument recorded at the declaration rather than assumed: a Helm hook Job loses its Pod the same way, but its failure fails the release and is reported to whoever ran it — somebody is already looking. A CronJob's failure is reported to nobody and repeats forever, which is what makes unreadable logs fatal rather than inconvenient. Widening it would need that argument made.

Mutation evidence

Four mutations, each run with the baseline confirmed green afterwards:

mutationresult
one template reverts to OnFailureFAIL — names the template and the policy
restartPolicy removed entirelyFAIL"silence is the defect" (Kubernetes defaults it to OnFailure)
render matrix produces no CronJobsFAIL — refuses to report on zero
a new CronJob template goes unreachedFAIL — names it as [UNREACHED]

On the two tests that changed

auto_upgrade_test.yaml and image_refresh_test.yaml each explicitly asserted OnFailure. They were right to pin it — a policy that matters should be pinned — so they are updated rather than deleted, and the comment now records why the pinned value changed and where the class-level sweep lives.

Checks

  • make helm-unittest579 passed, 34 suites
  • make driftall 21 guards green (verified the new one actually executed, not just that the count matched)
  • make helm-lint, make helm-template — clean
  • shellcheck on the new guard — clean

Note on rollout

This change only takes effect on an edge once that edge receives the chart. An edge whose auto-upgrade is already wedged cannot pull it — so unwedging those is separate operational work, tracked in the ticket. The immediate mitigation there is a direct kubectl patch of the CronJob's restartPolicy, which this change makes permanent and unnecessary going forward.

Ticket: tracebloc/backend#2620


Note

Low Risk
Operational observability fix for scheduled jobs with no auth or data-path changes; slightly more failed Pods retained per Job tick, which is intentional.

Overview
Fixes backend#2620: chart CronJobs used restartPolicy: OnFailure, which lets Kubernetes delete the Pod once backoffLimit is exhausted while failedJobsHistoryLimit only keeps the Job—so repeated failures left no logs (observed on a prod edge with hourly auto-upgrade failures for 2.7 days).

auto-upgrade and image-refresh CronJob pod templates now set restartPolicy: Never so each attempt keeps a Pod for debugging. Chart bumps to 1.9.74.

A new required drift guard scripts/tests/cronjob-failures-are-readable.sh renders client/ingestor charts and requires every rendered CronJob to declare Never (or fails on missing policy / unreached CronJob templates). It is wired into DRIFT_GUARDS. Helm unittest suites for both CronJobs pin Never instead of OnFailure.

Rollout note: edges already wedged on auto-upgrade still need operational unwedging; this only applies after they receive the chart (or a kubectl patch on the CronJob).

Reviewed by Cursor Bugbot for commit 92b0f6e. Bugbot is set up for automated code reviews on this repo. Configure here.

…kend#2620)
Both of the chart's CronJobs used `restartPolicy: OnFailure`. Kubernetes DELETES
an OnFailure Job's Pod once `backoffLimit` is exhausted, and
`failedJobsHistoryLimit` retains the JOB — it cannot retain a Pod that no longer
exists. So a repeatedly-failing CronJob leaves a row of `Failed` Jobs and no
logs at all. The upstream docs say it outright: "your Pod running the Job will be
terminated once the job backoff limit has been reached. This can make debugging
the Job's executable more difficult. We suggest setting restartPolicy = 'Never'".
MEASURED, NOT THEORETICAL. A customer prod edge's auto-upgrade had been failing
hourly for 2.7 days (backend#2620). Five `Failed` Jobs were retained; every one
of their Pods was gone and the namespace event window had rolled past the first
failure, so the reason was UNRECOVERABLE FROM THE CLUSTER. The only surviving
evidence that anything was wrong was the CronJob's `lastSuccessfulTime`, which
nothing watches. Each Job failed 39 seconds in, so this was never a timeout —
the logs would have said what it was.
BOTH CronJobs, not just the one that broke. `image-refresh` carried the same
policy and is on a 15-minute schedule, so it had the same blind spot with four
times the frequency. Fixing only the instance that happened to fail would have
left the class.
`scripts/tests/cronjob-failures-are-readable.sh` sweeps every CronJob out of the
RENDERED manifests, so a third one is covered the day it lands rather than when
someone remembers. It holds no list of CronJob names. Fails closed twice: zero
CronJobs rendered is a refusal, not a clean sweep, and a template declaring
`kind: CronJob` that no value combination reaches is reported UNREACHED by name.
Wired into `DRIFT_GUARDS` (21 entries; the target counts its iterations and
refuses to report green on fewer).
Scoped to CronJob deliberately, and the argument is at the declaration: a Helm
hook Job loses its Pod the same way, but its failure fails the release and is
reported to whoever ran it — somebody is already looking. A CronJob's failure is
reported to nobody and repeats forever.
Four mutations run, each with the baseline restored green afterwards:
one template reverts to OnFailure -> FAIL, names the template
restartPolicy removed entirely -> FAIL, "silence is the defect"
render matrix produces no CronJobs -> FAIL, refuses to check nothing
a new CronJob template goes unreached -> FAIL, names it as UNREACHED
The two suites that pinned `OnFailure` are updated rather than deleted — they
were right to pin it, and the comment now records why the pinned value changed.
make helm-unittest: 579 passed, 34 suites. make drift: all 21 guards green.
shellcheck clean.
Ticket: tracebloc/backend#2620
Parent epic: tracebloc/backend#1872
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 27, 2026
`chart content ⇒ Chart.yaml version bump` is a REQUIRED check on this repo and
was red: the branch changes chart content while `version`/`appVersion` both still
read 1.9.73, which is what `develop` carries. The gate exists because the chart's
image-refresh CronJob resolves the published tag every 15 minutes, so an
unbumped chart is not a cosmetic omission -- nothing downstream can tell the new
content from the old.
Both fields moved together to 1.9.74, one patch above develop, which is the shape
the gate asks for. They are kept EQUAL deliberately: `appVersion` is what the
rendered image tags follow, so a bump of `version` alone would advertise a
release that pulls the previous images.
Verified: `helm lint` clean (only the pre-existing "icon is recommended" note),
and the WHOLE chart suite rather than the file touched -- `helm unittest client`,
34 suites, 579 tests, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The measurement is what makes this a bug report rather than a style note: five Failed Jobs retained, every Pod gone, the event window rolled past the first failure, and each Job failing 39 seconds in — so not a hang, and the logs would have said exactly what it was. "The reason was unrecoverable from the cluster" is the sentence that matters, and the consequence is worse than the mechanism: an edge silently stopped accepting chart upgrades, security fixes included, while looking healthy from every angle anyone checks.

Fixing image-refresh too — same policy, four times the frequency — is the right instinct, and "fixing only the instance that happened to fail would have left the class" is exactly the failure I flagged on client-runtime#417 an hour ago, where a guard existed in two sibling publishers and was missing from the one shipping the control plane. Good to see it applied ahead of the incident rather than after.

The guard fails closed in three places, not the two you claimed. I checked each:

  • if not seen: sys.exit("the render matrix produced ZERO CronJobs. Nothing was checked, and a guard that checks nothing passes — refusing.")
  • unreached = expected - seen, each reported by name with the reason its policy went unchecked, and folded into the exit condition
  • and the one the description doesn't mention: a Pod spec stating norestartPolicy at all lands in missing and fails, so an unstated policy isn't read as an acceptable one

The declared set coming from grep -rlE '^kind: CronJob$' is line-anchored, so a template that merely mentions CronJob in a comment can't inflate expected and manufacture a phantom UNREACHED. And the [OK] line printing both len(seen) and len(expected) means a passing run is falsifiable rather than just green.

Two judgement calls I'd have made the same way. Scoping to CronJob and recording the argument at the declaration — a Helm hook Job loses its Pod identically, but its failure fails the release in front of the person who ran it, whereas a CronJob's failure is reported to nobody and repeats forever. And the failure message telling a future maintainer to "make that argument at the declaration and narrow this guard deliberately rather than adding an exception list here" — which is the right lesson to encode, given .github just spent three PRs burning an exemption list down to empty.

Green, no threads. 👍

@LukasWodka
LukasWodka merged commit 9e3bfb9 into developAug 27, 2026
48 checks passed
@LukasWodka
LukasWodka deleted the fix/2620-cronjob-failures-leave-evidence branch August 27, 2026 07:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620) - #860

Merged
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence
Aug 27, 2026
Merged

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620)#860
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2620

The defect

Both CronJobs the chart renders used restartPolicy: OnFailure. Kubernetes deletes an OnFailure Job's Pod once backoffLimit is exhausted, and failedJobsHistoryLimit retains the Job — it cannot retain a Pod that no longer exists.

So a repeatedly-failing CronJob leaves a row of Failed Jobs and no logs at all. The upstream docs say it plainly:

"your Pod running the Job will be terminated once the job backoff limit has been reached. This can make debugging the Job's executable more difficult. We suggest setting restartPolicy = 'Never'"

Measured, not theoretical

An edge's auto-upgrade CronJob had been failing hourly for 2.7 days. Five Failed Jobs were retained; every one of their Pods was gone, and the namespace event window had rolled past the first failure — so the reason was unrecoverable from the cluster.

The only surviving evidence that anything was wrong was the CronJob's lastSuccessfulTime. Each Job failed 39 seconds in, so this was never a timeout or a hang: the logs would have said exactly what it was, and they were thrown away.

The consequence was that the edge silently stopped accepting chart upgrades — including any security fix — while looking healthy from every angle anyone checks.

Both CronJobs, not just the one that broke

image-refresh carried the same policy on a 15-minute schedule — the same blind spot at four times the frequency. Fixing only the instance that happened to fail would have left the class.

The guard

scripts/tests/cronjob-failures-are-readable.sh reads every CronJob out of the rendered manifests, so a third one is covered the day it lands rather than when someone remembers. It holds no list of CronJob names.

Fails closed twice:

  • zero CronJobs rendered is a refusal, not a clean sweep — a guard that checks nothing otherwise passes;
  • a template declaring kind: CronJob that no value combination in the render matrix reaches is reported UNREACHED by name and fails, rather than passing silently as "nothing to check".

Wired into DRIFT_GUARDS (now 21 entries — the drift target counts its iterations and refuses to report green on fewer, so adding a guard without wiring it cannot pass).

Scoped to CronJob deliberately, with the argument recorded at the declaration rather than assumed: a Helm hook Job loses its Pod the same way, but its failure fails the release and is reported to whoever ran it — somebody is already looking. A CronJob's failure is reported to nobody and repeats forever, which is what makes unreadable logs fatal rather than inconvenient. Widening it would need that argument made.

Mutation evidence

Four mutations, each run with the baseline confirmed green afterwards:

mutationresult
one template reverts to OnFailureFAIL — names the template and the policy
restartPolicy removed entirelyFAIL"silence is the defect" (Kubernetes defaults it to OnFailure)
render matrix produces no CronJobsFAIL — refuses to report on zero
a new CronJob template goes unreachedFAIL — names it as [UNREACHED]

On the two tests that changed

auto_upgrade_test.yaml and image_refresh_test.yaml each explicitly asserted OnFailure. They were right to pin it — a policy that matters should be pinned — so they are updated rather than deleted, and the comment now records why the pinned value changed and where the class-level sweep lives.

Checks

  • make helm-unittest579 passed, 34 suites
  • make driftall 21 guards green (verified the new one actually executed, not just that the count matched)
  • make helm-lint, make helm-template — clean
  • shellcheck on the new guard — clean

Note on rollout

This change only takes effect on an edge once that edge receives the chart. An edge whose auto-upgrade is already wedged cannot pull it — so unwedging those is separate operational work, tracked in the ticket. The immediate mitigation there is a direct kubectl patch of the CronJob's restartPolicy, which this change makes permanent and unnecessary going forward.

Ticket: tracebloc/backend#2620


Note

Low Risk
Operational observability fix for scheduled jobs with no auth or data-path changes; slightly more failed Pods retained per Job tick, which is intentional.

Overview
Fixes backend#2620: chart CronJobs used restartPolicy: OnFailure, which lets Kubernetes delete the Pod once backoffLimit is exhausted while failedJobsHistoryLimit only keeps the Job—so repeated failures left no logs (observed on a prod edge with hourly auto-upgrade failures for 2.7 days).

auto-upgrade and image-refresh CronJob pod templates now set restartPolicy: Never so each attempt keeps a Pod for debugging. Chart bumps to 1.9.74.

A new required drift guard scripts/tests/cronjob-failures-are-readable.sh renders client/ingestor charts and requires every rendered CronJob to declare Never (or fails on missing policy / unreached CronJob templates). It is wired into DRIFT_GUARDS. Helm unittest suites for both CronJobs pin Never instead of OnFailure.

Rollout note: edges already wedged on auto-upgrade still need operational unwedging; this only applies after they receive the chart (or a kubectl patch on the CronJob).

Reviewed by Cursor Bugbot for commit 92b0f6e. Bugbot is set up for automated code reviews on this repo. Configure here.

…kend#2620)
Both of the chart's CronJobs used `restartPolicy: OnFailure`. Kubernetes DELETES
an OnFailure Job's Pod once `backoffLimit` is exhausted, and
`failedJobsHistoryLimit` retains the JOB — it cannot retain a Pod that no longer
exists. So a repeatedly-failing CronJob leaves a row of `Failed` Jobs and no
logs at all. The upstream docs say it outright: "your Pod running the Job will be
terminated once the job backoff limit has been reached. This can make debugging
the Job's executable more difficult. We suggest setting restartPolicy = 'Never'".
MEASURED, NOT THEORETICAL. A customer prod edge's auto-upgrade had been failing
hourly for 2.7 days (backend#2620). Five `Failed` Jobs were retained; every one
of their Pods was gone and the namespace event window had rolled past the first
failure, so the reason was UNRECOVERABLE FROM THE CLUSTER. The only surviving
evidence that anything was wrong was the CronJob's `lastSuccessfulTime`, which
nothing watches. Each Job failed 39 seconds in, so this was never a timeout —
the logs would have said what it was.
BOTH CronJobs, not just the one that broke. `image-refresh` carried the same
policy and is on a 15-minute schedule, so it had the same blind spot with four
times the frequency. Fixing only the instance that happened to fail would have
left the class.
`scripts/tests/cronjob-failures-are-readable.sh` sweeps every CronJob out of the
RENDERED manifests, so a third one is covered the day it lands rather than when
someone remembers. It holds no list of CronJob names. Fails closed twice: zero
CronJobs rendered is a refusal, not a clean sweep, and a template declaring
`kind: CronJob` that no value combination reaches is reported UNREACHED by name.
Wired into `DRIFT_GUARDS` (21 entries; the target counts its iterations and
refuses to report green on fewer).
Scoped to CronJob deliberately, and the argument is at the declaration: a Helm
hook Job loses its Pod the same way, but its failure fails the release and is
reported to whoever ran it — somebody is already looking. A CronJob's failure is
reported to nobody and repeats forever.
Four mutations run, each with the baseline restored green afterwards:
one template reverts to OnFailure -> FAIL, names the template
restartPolicy removed entirely -> FAIL, "silence is the defect"
render matrix produces no CronJobs -> FAIL, refuses to check nothing
a new CronJob template goes unreached -> FAIL, names it as UNREACHED
The two suites that pinned `OnFailure` are updated rather than deleted — they
were right to pin it, and the comment now records why the pinned value changed.
make helm-unittest: 579 passed, 34 suites. make drift: all 21 guards green.
shellcheck clean.
Ticket: tracebloc/backend#2620
Parent epic: tracebloc/backend#1872
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 27, 2026
`chart content ⇒ Chart.yaml version bump` is a REQUIRED check on this repo and
was red: the branch changes chart content while `version`/`appVersion` both still
read 1.9.73, which is what `develop` carries. The gate exists because the chart's
image-refresh CronJob resolves the published tag every 15 minutes, so an
unbumped chart is not a cosmetic omission -- nothing downstream can tell the new
content from the old.
Both fields moved together to 1.9.74, one patch above develop, which is the shape
the gate asks for. They are kept EQUAL deliberately: `appVersion` is what the
rendered image tags follow, so a bump of `version` alone would advertise a
release that pulls the previous images.
Verified: `helm lint` clean (only the pre-existing "icon is recommended" note),
and the WHOLE chart suite rather than the file touched -- `helm unittest client`,
34 suites, 579 tests, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The measurement is what makes this a bug report rather than a style note: five Failed Jobs retained, every Pod gone, the event window rolled past the first failure, and each Job failing 39 seconds in — so not a hang, and the logs would have said exactly what it was. "The reason was unrecoverable from the cluster" is the sentence that matters, and the consequence is worse than the mechanism: an edge silently stopped accepting chart upgrades, security fixes included, while looking healthy from every angle anyone checks.

Fixing image-refresh too — same policy, four times the frequency — is the right instinct, and "fixing only the instance that happened to fail would have left the class" is exactly the failure I flagged on client-runtime#417 an hour ago, where a guard existed in two sibling publishers and was missing from the one shipping the control plane. Good to see it applied ahead of the incident rather than after.

The guard fails closed in three places, not the two you claimed. I checked each:

  • if not seen: sys.exit("the render matrix produced ZERO CronJobs. Nothing was checked, and a guard that checks nothing passes — refusing.")
  • unreached = expected - seen, each reported by name with the reason its policy went unchecked, and folded into the exit condition
  • and the one the description doesn't mention: a Pod spec stating norestartPolicy at all lands in missing and fails, so an unstated policy isn't read as an acceptable one

The declared set coming from grep -rlE '^kind: CronJob$' is line-anchored, so a template that merely mentions CronJob in a comment can't inflate expected and manufacture a phantom UNREACHED. And the [OK] line printing both len(seen) and len(expected) means a passing run is falsifiable rather than just green.

Two judgement calls I'd have made the same way. Scoping to CronJob and recording the argument at the declaration — a Helm hook Job loses its Pod identically, but its failure fails the release in front of the person who ran it, whereas a CronJob's failure is reported to nobody and repeats forever. And the failure message telling a future maintainer to "make that argument at the declaration and narrow this guard deliberately rather than adding an exception list here" — which is the right lesson to encode, given .github just spent three PRs burning an exemption list down to empty.

Green, no threads. 👍

@LukasWodka
LukasWodka merged commit 9e3bfb9 into developAug 27, 2026
48 checks passed
@LukasWodka
LukasWodka deleted the fix/2620-cronjob-failures-leave-evidence branch August 27, 2026 07:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620) - #860

Merged
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence
Aug 27, 2026
Merged

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620)#860
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2620

The defect

Both CronJobs the chart renders used restartPolicy: OnFailure. Kubernetes deletes an OnFailure Job's Pod once backoffLimit is exhausted, and failedJobsHistoryLimit retains the Job — it cannot retain a Pod that no longer exists.

So a repeatedly-failing CronJob leaves a row of Failed Jobs and no logs at all. The upstream docs say it plainly:

"your Pod running the Job will be terminated once the job backoff limit has been reached. This can make debugging the Job's executable more difficult. We suggest setting restartPolicy = 'Never'"

Measured, not theoretical

An edge's auto-upgrade CronJob had been failing hourly for 2.7 days. Five Failed Jobs were retained; every one of their Pods was gone, and the namespace event window had rolled past the first failure — so the reason was unrecoverable from the cluster.

The only surviving evidence that anything was wrong was the CronJob's lastSuccessfulTime. Each Job failed 39 seconds in, so this was never a timeout or a hang: the logs would have said exactly what it was, and they were thrown away.

The consequence was that the edge silently stopped accepting chart upgrades — including any security fix — while looking healthy from every angle anyone checks.

Both CronJobs, not just the one that broke

image-refresh carried the same policy on a 15-minute schedule — the same blind spot at four times the frequency. Fixing only the instance that happened to fail would have left the class.

The guard

scripts/tests/cronjob-failures-are-readable.sh reads every CronJob out of the rendered manifests, so a third one is covered the day it lands rather than when someone remembers. It holds no list of CronJob names.

Fails closed twice:

  • zero CronJobs rendered is a refusal, not a clean sweep — a guard that checks nothing otherwise passes;
  • a template declaring kind: CronJob that no value combination in the render matrix reaches is reported UNREACHED by name and fails, rather than passing silently as "nothing to check".

Wired into DRIFT_GUARDS (now 21 entries — the drift target counts its iterations and refuses to report green on fewer, so adding a guard without wiring it cannot pass).

Scoped to CronJob deliberately, with the argument recorded at the declaration rather than assumed: a Helm hook Job loses its Pod the same way, but its failure fails the release and is reported to whoever ran it — somebody is already looking. A CronJob's failure is reported to nobody and repeats forever, which is what makes unreadable logs fatal rather than inconvenient. Widening it would need that argument made.

Mutation evidence

Four mutations, each run with the baseline confirmed green afterwards:

mutationresult
one template reverts to OnFailureFAIL — names the template and the policy
restartPolicy removed entirelyFAIL"silence is the defect" (Kubernetes defaults it to OnFailure)
render matrix produces no CronJobsFAIL — refuses to report on zero
a new CronJob template goes unreachedFAIL — names it as [UNREACHED]

On the two tests that changed

auto_upgrade_test.yaml and image_refresh_test.yaml each explicitly asserted OnFailure. They were right to pin it — a policy that matters should be pinned — so they are updated rather than deleted, and the comment now records why the pinned value changed and where the class-level sweep lives.

Checks

  • make helm-unittest579 passed, 34 suites
  • make driftall 21 guards green (verified the new one actually executed, not just that the count matched)
  • make helm-lint, make helm-template — clean
  • shellcheck on the new guard — clean

Note on rollout

This change only takes effect on an edge once that edge receives the chart. An edge whose auto-upgrade is already wedged cannot pull it — so unwedging those is separate operational work, tracked in the ticket. The immediate mitigation there is a direct kubectl patch of the CronJob's restartPolicy, which this change makes permanent and unnecessary going forward.

Ticket: tracebloc/backend#2620


Note

Low Risk
Operational observability fix for scheduled jobs with no auth or data-path changes; slightly more failed Pods retained per Job tick, which is intentional.

Overview
Fixes backend#2620: chart CronJobs used restartPolicy: OnFailure, which lets Kubernetes delete the Pod once backoffLimit is exhausted while failedJobsHistoryLimit only keeps the Job—so repeated failures left no logs (observed on a prod edge with hourly auto-upgrade failures for 2.7 days).

auto-upgrade and image-refresh CronJob pod templates now set restartPolicy: Never so each attempt keeps a Pod for debugging. Chart bumps to 1.9.74.

A new required drift guard scripts/tests/cronjob-failures-are-readable.sh renders client/ingestor charts and requires every rendered CronJob to declare Never (or fails on missing policy / unreached CronJob templates). It is wired into DRIFT_GUARDS. Helm unittest suites for both CronJobs pin Never instead of OnFailure.

Rollout note: edges already wedged on auto-upgrade still need operational unwedging; this only applies after they receive the chart (or a kubectl patch on the CronJob).

Reviewed by Cursor Bugbot for commit 92b0f6e. Bugbot is set up for automated code reviews on this repo. Configure here.

…kend#2620)
Both of the chart's CronJobs used `restartPolicy: OnFailure`. Kubernetes DELETES
an OnFailure Job's Pod once `backoffLimit` is exhausted, and
`failedJobsHistoryLimit` retains the JOB — it cannot retain a Pod that no longer
exists. So a repeatedly-failing CronJob leaves a row of `Failed` Jobs and no
logs at all. The upstream docs say it outright: "your Pod running the Job will be
terminated once the job backoff limit has been reached. This can make debugging
the Job's executable more difficult. We suggest setting restartPolicy = 'Never'".
MEASURED, NOT THEORETICAL. A customer prod edge's auto-upgrade had been failing
hourly for 2.7 days (backend#2620). Five `Failed` Jobs were retained; every one
of their Pods was gone and the namespace event window had rolled past the first
failure, so the reason was UNRECOVERABLE FROM THE CLUSTER. The only surviving
evidence that anything was wrong was the CronJob's `lastSuccessfulTime`, which
nothing watches. Each Job failed 39 seconds in, so this was never a timeout —
the logs would have said what it was.
BOTH CronJobs, not just the one that broke. `image-refresh` carried the same
policy and is on a 15-minute schedule, so it had the same blind spot with four
times the frequency. Fixing only the instance that happened to fail would have
left the class.
`scripts/tests/cronjob-failures-are-readable.sh` sweeps every CronJob out of the
RENDERED manifests, so a third one is covered the day it lands rather than when
someone remembers. It holds no list of CronJob names. Fails closed twice: zero
CronJobs rendered is a refusal, not a clean sweep, and a template declaring
`kind: CronJob` that no value combination reaches is reported UNREACHED by name.
Wired into `DRIFT_GUARDS` (21 entries; the target counts its iterations and
refuses to report green on fewer).
Scoped to CronJob deliberately, and the argument is at the declaration: a Helm
hook Job loses its Pod the same way, but its failure fails the release and is
reported to whoever ran it — somebody is already looking. A CronJob's failure is
reported to nobody and repeats forever.
Four mutations run, each with the baseline restored green afterwards:
one template reverts to OnFailure -> FAIL, names the template
restartPolicy removed entirely -> FAIL, "silence is the defect"
render matrix produces no CronJobs -> FAIL, refuses to check nothing
a new CronJob template goes unreached -> FAIL, names it as UNREACHED
The two suites that pinned `OnFailure` are updated rather than deleted — they
were right to pin it, and the comment now records why the pinned value changed.
make helm-unittest: 579 passed, 34 suites. make drift: all 21 guards green.
shellcheck clean.
Ticket: tracebloc/backend#2620
Parent epic: tracebloc/backend#1872
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 27, 2026
`chart content ⇒ Chart.yaml version bump` is a REQUIRED check on this repo and
was red: the branch changes chart content while `version`/`appVersion` both still
read 1.9.73, which is what `develop` carries. The gate exists because the chart's
image-refresh CronJob resolves the published tag every 15 minutes, so an
unbumped chart is not a cosmetic omission -- nothing downstream can tell the new
content from the old.
Both fields moved together to 1.9.74, one patch above develop, which is the shape
the gate asks for. They are kept EQUAL deliberately: `appVersion` is what the
rendered image tags follow, so a bump of `version` alone would advertise a
release that pulls the previous images.
Verified: `helm lint` clean (only the pre-existing "icon is recommended" note),
and the WHOLE chart suite rather than the file touched -- `helm unittest client`,
34 suites, 579 tests, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The measurement is what makes this a bug report rather than a style note: five Failed Jobs retained, every Pod gone, the event window rolled past the first failure, and each Job failing 39 seconds in — so not a hang, and the logs would have said exactly what it was. "The reason was unrecoverable from the cluster" is the sentence that matters, and the consequence is worse than the mechanism: an edge silently stopped accepting chart upgrades, security fixes included, while looking healthy from every angle anyone checks.

Fixing image-refresh too — same policy, four times the frequency — is the right instinct, and "fixing only the instance that happened to fail would have left the class" is exactly the failure I flagged on client-runtime#417 an hour ago, where a guard existed in two sibling publishers and was missing from the one shipping the control plane. Good to see it applied ahead of the incident rather than after.

The guard fails closed in three places, not the two you claimed. I checked each:

  • if not seen: sys.exit("the render matrix produced ZERO CronJobs. Nothing was checked, and a guard that checks nothing passes — refusing.")
  • unreached = expected - seen, each reported by name with the reason its policy went unchecked, and folded into the exit condition
  • and the one the description doesn't mention: a Pod spec stating norestartPolicy at all lands in missing and fails, so an unstated policy isn't read as an acceptable one

The declared set coming from grep -rlE '^kind: CronJob$' is line-anchored, so a template that merely mentions CronJob in a comment can't inflate expected and manufacture a phantom UNREACHED. And the [OK] line printing both len(seen) and len(expected) means a passing run is falsifiable rather than just green.

Two judgement calls I'd have made the same way. Scoping to CronJob and recording the argument at the declaration — a Helm hook Job loses its Pod identically, but its failure fails the release in front of the person who ran it, whereas a CronJob's failure is reported to nobody and repeats forever. And the failure message telling a future maintainer to "make that argument at the declaration and narrow this guard deliberately rather than adding an exception list here" — which is the right lesson to encode, given .github just spent three PRs burning an exemption list down to empty.

Green, no threads. 👍

@LukasWodka
LukasWodka merged commit 9e3bfb9 into developAug 27, 2026
48 checks passed
@LukasWodka
LukasWodka deleted the fix/2620-cronjob-failures-leave-evidence branch August 27, 2026 07:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620) - #860

Merged
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence
Aug 27, 2026
Merged

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620)#860
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2620

The defect

Both CronJobs the chart renders used restartPolicy: OnFailure. Kubernetes deletes an OnFailure Job's Pod once backoffLimit is exhausted, and failedJobsHistoryLimit retains the Job — it cannot retain a Pod that no longer exists.

So a repeatedly-failing CronJob leaves a row of Failed Jobs and no logs at all. The upstream docs say it plainly:

"your Pod running the Job will be terminated once the job backoff limit has been reached. This can make debugging the Job's executable more difficult. We suggest setting restartPolicy = 'Never'"

Measured, not theoretical

An edge's auto-upgrade CronJob had been failing hourly for 2.7 days. Five Failed Jobs were retained; every one of their Pods was gone, and the namespace event window had rolled past the first failure — so the reason was unrecoverable from the cluster.

The only surviving evidence that anything was wrong was the CronJob's lastSuccessfulTime. Each Job failed 39 seconds in, so this was never a timeout or a hang: the logs would have said exactly what it was, and they were thrown away.

The consequence was that the edge silently stopped accepting chart upgrades — including any security fix — while looking healthy from every angle anyone checks.

Both CronJobs, not just the one that broke

image-refresh carried the same policy on a 15-minute schedule — the same blind spot at four times the frequency. Fixing only the instance that happened to fail would have left the class.

The guard

scripts/tests/cronjob-failures-are-readable.sh reads every CronJob out of the rendered manifests, so a third one is covered the day it lands rather than when someone remembers. It holds no list of CronJob names.

Fails closed twice:

  • zero CronJobs rendered is a refusal, not a clean sweep — a guard that checks nothing otherwise passes;
  • a template declaring kind: CronJob that no value combination in the render matrix reaches is reported UNREACHED by name and fails, rather than passing silently as "nothing to check".

Wired into DRIFT_GUARDS (now 21 entries — the drift target counts its iterations and refuses to report green on fewer, so adding a guard without wiring it cannot pass).

Scoped to CronJob deliberately, with the argument recorded at the declaration rather than assumed: a Helm hook Job loses its Pod the same way, but its failure fails the release and is reported to whoever ran it — somebody is already looking. A CronJob's failure is reported to nobody and repeats forever, which is what makes unreadable logs fatal rather than inconvenient. Widening it would need that argument made.

Mutation evidence

Four mutations, each run with the baseline confirmed green afterwards:

mutationresult
one template reverts to OnFailureFAIL — names the template and the policy
restartPolicy removed entirelyFAIL"silence is the defect" (Kubernetes defaults it to OnFailure)
render matrix produces no CronJobsFAIL — refuses to report on zero
a new CronJob template goes unreachedFAIL — names it as [UNREACHED]

On the two tests that changed

auto_upgrade_test.yaml and image_refresh_test.yaml each explicitly asserted OnFailure. They were right to pin it — a policy that matters should be pinned — so they are updated rather than deleted, and the comment now records why the pinned value changed and where the class-level sweep lives.

Checks

  • make helm-unittest579 passed, 34 suites
  • make driftall 21 guards green (verified the new one actually executed, not just that the count matched)
  • make helm-lint, make helm-template — clean
  • shellcheck on the new guard — clean

Note on rollout

This change only takes effect on an edge once that edge receives the chart. An edge whose auto-upgrade is already wedged cannot pull it — so unwedging those is separate operational work, tracked in the ticket. The immediate mitigation there is a direct kubectl patch of the CronJob's restartPolicy, which this change makes permanent and unnecessary going forward.

Ticket: tracebloc/backend#2620


Note

Low Risk
Operational observability fix for scheduled jobs with no auth or data-path changes; slightly more failed Pods retained per Job tick, which is intentional.

Overview
Fixes backend#2620: chart CronJobs used restartPolicy: OnFailure, which lets Kubernetes delete the Pod once backoffLimit is exhausted while failedJobsHistoryLimit only keeps the Job—so repeated failures left no logs (observed on a prod edge with hourly auto-upgrade failures for 2.7 days).

auto-upgrade and image-refresh CronJob pod templates now set restartPolicy: Never so each attempt keeps a Pod for debugging. Chart bumps to 1.9.74.

A new required drift guard scripts/tests/cronjob-failures-are-readable.sh renders client/ingestor charts and requires every rendered CronJob to declare Never (or fails on missing policy / unreached CronJob templates). It is wired into DRIFT_GUARDS. Helm unittest suites for both CronJobs pin Never instead of OnFailure.

Rollout note: edges already wedged on auto-upgrade still need operational unwedging; this only applies after they receive the chart (or a kubectl patch on the CronJob).

Reviewed by Cursor Bugbot for commit 92b0f6e. Bugbot is set up for automated code reviews on this repo. Configure here.

…kend#2620)
Both of the chart's CronJobs used `restartPolicy: OnFailure`. Kubernetes DELETES
an OnFailure Job's Pod once `backoffLimit` is exhausted, and
`failedJobsHistoryLimit` retains the JOB — it cannot retain a Pod that no longer
exists. So a repeatedly-failing CronJob leaves a row of `Failed` Jobs and no
logs at all. The upstream docs say it outright: "your Pod running the Job will be
terminated once the job backoff limit has been reached. This can make debugging
the Job's executable more difficult. We suggest setting restartPolicy = 'Never'".
MEASURED, NOT THEORETICAL. A customer prod edge's auto-upgrade had been failing
hourly for 2.7 days (backend#2620). Five `Failed` Jobs were retained; every one
of their Pods was gone and the namespace event window had rolled past the first
failure, so the reason was UNRECOVERABLE FROM THE CLUSTER. The only surviving
evidence that anything was wrong was the CronJob's `lastSuccessfulTime`, which
nothing watches. Each Job failed 39 seconds in, so this was never a timeout —
the logs would have said what it was.
BOTH CronJobs, not just the one that broke. `image-refresh` carried the same
policy and is on a 15-minute schedule, so it had the same blind spot with four
times the frequency. Fixing only the instance that happened to fail would have
left the class.
`scripts/tests/cronjob-failures-are-readable.sh` sweeps every CronJob out of the
RENDERED manifests, so a third one is covered the day it lands rather than when
someone remembers. It holds no list of CronJob names. Fails closed twice: zero
CronJobs rendered is a refusal, not a clean sweep, and a template declaring
`kind: CronJob` that no value combination reaches is reported UNREACHED by name.
Wired into `DRIFT_GUARDS` (21 entries; the target counts its iterations and
refuses to report green on fewer).
Scoped to CronJob deliberately, and the argument is at the declaration: a Helm
hook Job loses its Pod the same way, but its failure fails the release and is
reported to whoever ran it — somebody is already looking. A CronJob's failure is
reported to nobody and repeats forever.
Four mutations run, each with the baseline restored green afterwards:
one template reverts to OnFailure -> FAIL, names the template
restartPolicy removed entirely -> FAIL, "silence is the defect"
render matrix produces no CronJobs -> FAIL, refuses to check nothing
a new CronJob template goes unreached -> FAIL, names it as UNREACHED
The two suites that pinned `OnFailure` are updated rather than deleted — they
were right to pin it, and the comment now records why the pinned value changed.
make helm-unittest: 579 passed, 34 suites. make drift: all 21 guards green.
shellcheck clean.
Ticket: tracebloc/backend#2620
Parent epic: tracebloc/backend#1872
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 27, 2026
`chart content ⇒ Chart.yaml version bump` is a REQUIRED check on this repo and
was red: the branch changes chart content while `version`/`appVersion` both still
read 1.9.73, which is what `develop` carries. The gate exists because the chart's
image-refresh CronJob resolves the published tag every 15 minutes, so an
unbumped chart is not a cosmetic omission -- nothing downstream can tell the new
content from the old.
Both fields moved together to 1.9.74, one patch above develop, which is the shape
the gate asks for. They are kept EQUAL deliberately: `appVersion` is what the
rendered image tags follow, so a bump of `version` alone would advertise a
release that pulls the previous images.
Verified: `helm lint` clean (only the pre-existing "icon is recommended" note),
and the WHOLE chart suite rather than the file touched -- `helm unittest client`,
34 suites, 579 tests, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The measurement is what makes this a bug report rather than a style note: five Failed Jobs retained, every Pod gone, the event window rolled past the first failure, and each Job failing 39 seconds in — so not a hang, and the logs would have said exactly what it was. "The reason was unrecoverable from the cluster" is the sentence that matters, and the consequence is worse than the mechanism: an edge silently stopped accepting chart upgrades, security fixes included, while looking healthy from every angle anyone checks.

Fixing image-refresh too — same policy, four times the frequency — is the right instinct, and "fixing only the instance that happened to fail would have left the class" is exactly the failure I flagged on client-runtime#417 an hour ago, where a guard existed in two sibling publishers and was missing from the one shipping the control plane. Good to see it applied ahead of the incident rather than after.

The guard fails closed in three places, not the two you claimed. I checked each:

  • if not seen: sys.exit("the render matrix produced ZERO CronJobs. Nothing was checked, and a guard that checks nothing passes — refusing.")
  • unreached = expected - seen, each reported by name with the reason its policy went unchecked, and folded into the exit condition
  • and the one the description doesn't mention: a Pod spec stating norestartPolicy at all lands in missing and fails, so an unstated policy isn't read as an acceptable one

The declared set coming from grep -rlE '^kind: CronJob$' is line-anchored, so a template that merely mentions CronJob in a comment can't inflate expected and manufacture a phantom UNREACHED. And the [OK] line printing both len(seen) and len(expected) means a passing run is falsifiable rather than just green.

Two judgement calls I'd have made the same way. Scoping to CronJob and recording the argument at the declaration — a Helm hook Job loses its Pod identically, but its failure fails the release in front of the person who ran it, whereas a CronJob's failure is reported to nobody and repeats forever. And the failure message telling a future maintainer to "make that argument at the declaration and narrow this guard deliberately rather than adding an exception list here" — which is the right lesson to encode, given .github just spent three PRs burning an exemption list down to empty.

Green, no threads. 👍

@LukasWodka
LukasWodka merged commit 9e3bfb9 into developAug 27, 2026
48 checks passed
@LukasWodka
LukasWodka deleted the fix/2620-cronjob-failures-leave-evidence branch August 27, 2026 07:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620) - #860

Merged
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence
Aug 27, 2026
Merged

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620)#860
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2620

The defect

Both CronJobs the chart renders used restartPolicy: OnFailure. Kubernetes deletes an OnFailure Job's Pod once backoffLimit is exhausted, and failedJobsHistoryLimit retains the Job — it cannot retain a Pod that no longer exists.

So a repeatedly-failing CronJob leaves a row of Failed Jobs and no logs at all. The upstream docs say it plainly:

"your Pod running the Job will be terminated once the job backoff limit has been reached. This can make debugging the Job's executable more difficult. We suggest setting restartPolicy = 'Never'"

Measured, not theoretical

An edge's auto-upgrade CronJob had been failing hourly for 2.7 days. Five Failed Jobs were retained; every one of their Pods was gone, and the namespace event window had rolled past the first failure — so the reason was unrecoverable from the cluster.

The only surviving evidence that anything was wrong was the CronJob's lastSuccessfulTime. Each Job failed 39 seconds in, so this was never a timeout or a hang: the logs would have said exactly what it was, and they were thrown away.

The consequence was that the edge silently stopped accepting chart upgrades — including any security fix — while looking healthy from every angle anyone checks.

Both CronJobs, not just the one that broke

image-refresh carried the same policy on a 15-minute schedule — the same blind spot at four times the frequency. Fixing only the instance that happened to fail would have left the class.

The guard

scripts/tests/cronjob-failures-are-readable.sh reads every CronJob out of the rendered manifests, so a third one is covered the day it lands rather than when someone remembers. It holds no list of CronJob names.

Fails closed twice:

  • zero CronJobs rendered is a refusal, not a clean sweep — a guard that checks nothing otherwise passes;
  • a template declaring kind: CronJob that no value combination in the render matrix reaches is reported UNREACHED by name and fails, rather than passing silently as "nothing to check".

Wired into DRIFT_GUARDS (now 21 entries — the drift target counts its iterations and refuses to report green on fewer, so adding a guard without wiring it cannot pass).

Scoped to CronJob deliberately, with the argument recorded at the declaration rather than assumed: a Helm hook Job loses its Pod the same way, but its failure fails the release and is reported to whoever ran it — somebody is already looking. A CronJob's failure is reported to nobody and repeats forever, which is what makes unreadable logs fatal rather than inconvenient. Widening it would need that argument made.

Mutation evidence

Four mutations, each run with the baseline confirmed green afterwards:

mutationresult
one template reverts to OnFailureFAIL — names the template and the policy
restartPolicy removed entirelyFAIL"silence is the defect" (Kubernetes defaults it to OnFailure)
render matrix produces no CronJobsFAIL — refuses to report on zero
a new CronJob template goes unreachedFAIL — names it as [UNREACHED]

On the two tests that changed

auto_upgrade_test.yaml and image_refresh_test.yaml each explicitly asserted OnFailure. They were right to pin it — a policy that matters should be pinned — so they are updated rather than deleted, and the comment now records why the pinned value changed and where the class-level sweep lives.

Checks

  • make helm-unittest579 passed, 34 suites
  • make driftall 21 guards green (verified the new one actually executed, not just that the count matched)
  • make helm-lint, make helm-template — clean
  • shellcheck on the new guard — clean

Note on rollout

This change only takes effect on an edge once that edge receives the chart. An edge whose auto-upgrade is already wedged cannot pull it — so unwedging those is separate operational work, tracked in the ticket. The immediate mitigation there is a direct kubectl patch of the CronJob's restartPolicy, which this change makes permanent and unnecessary going forward.

Ticket: tracebloc/backend#2620


Note

Low Risk
Operational observability fix for scheduled jobs with no auth or data-path changes; slightly more failed Pods retained per Job tick, which is intentional.

Overview
Fixes backend#2620: chart CronJobs used restartPolicy: OnFailure, which lets Kubernetes delete the Pod once backoffLimit is exhausted while failedJobsHistoryLimit only keeps the Job—so repeated failures left no logs (observed on a prod edge with hourly auto-upgrade failures for 2.7 days).

auto-upgrade and image-refresh CronJob pod templates now set restartPolicy: Never so each attempt keeps a Pod for debugging. Chart bumps to 1.9.74.

A new required drift guard scripts/tests/cronjob-failures-are-readable.sh renders client/ingestor charts and requires every rendered CronJob to declare Never (or fails on missing policy / unreached CronJob templates). It is wired into DRIFT_GUARDS. Helm unittest suites for both CronJobs pin Never instead of OnFailure.

Rollout note: edges already wedged on auto-upgrade still need operational unwedging; this only applies after they receive the chart (or a kubectl patch on the CronJob).

Reviewed by Cursor Bugbot for commit 92b0f6e. Bugbot is set up for automated code reviews on this repo. Configure here.

…kend#2620)
Both of the chart's CronJobs used `restartPolicy: OnFailure`. Kubernetes DELETES
an OnFailure Job's Pod once `backoffLimit` is exhausted, and
`failedJobsHistoryLimit` retains the JOB — it cannot retain a Pod that no longer
exists. So a repeatedly-failing CronJob leaves a row of `Failed` Jobs and no
logs at all. The upstream docs say it outright: "your Pod running the Job will be
terminated once the job backoff limit has been reached. This can make debugging
the Job's executable more difficult. We suggest setting restartPolicy = 'Never'".
MEASURED, NOT THEORETICAL. A customer prod edge's auto-upgrade had been failing
hourly for 2.7 days (backend#2620). Five `Failed` Jobs were retained; every one
of their Pods was gone and the namespace event window had rolled past the first
failure, so the reason was UNRECOVERABLE FROM THE CLUSTER. The only surviving
evidence that anything was wrong was the CronJob's `lastSuccessfulTime`, which
nothing watches. Each Job failed 39 seconds in, so this was never a timeout —
the logs would have said what it was.
BOTH CronJobs, not just the one that broke. `image-refresh` carried the same
policy and is on a 15-minute schedule, so it had the same blind spot with four
times the frequency. Fixing only the instance that happened to fail would have
left the class.
`scripts/tests/cronjob-failures-are-readable.sh` sweeps every CronJob out of the
RENDERED manifests, so a third one is covered the day it lands rather than when
someone remembers. It holds no list of CronJob names. Fails closed twice: zero
CronJobs rendered is a refusal, not a clean sweep, and a template declaring
`kind: CronJob` that no value combination reaches is reported UNREACHED by name.
Wired into `DRIFT_GUARDS` (21 entries; the target counts its iterations and
refuses to report green on fewer).
Scoped to CronJob deliberately, and the argument is at the declaration: a Helm
hook Job loses its Pod the same way, but its failure fails the release and is
reported to whoever ran it — somebody is already looking. A CronJob's failure is
reported to nobody and repeats forever.
Four mutations run, each with the baseline restored green afterwards:
one template reverts to OnFailure -> FAIL, names the template
restartPolicy removed entirely -> FAIL, "silence is the defect"
render matrix produces no CronJobs -> FAIL, refuses to check nothing
a new CronJob template goes unreached -> FAIL, names it as UNREACHED
The two suites that pinned `OnFailure` are updated rather than deleted — they
were right to pin it, and the comment now records why the pinned value changed.
make helm-unittest: 579 passed, 34 suites. make drift: all 21 guards green.
shellcheck clean.
Ticket: tracebloc/backend#2620
Parent epic: tracebloc/backend#1872
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 27, 2026
`chart content ⇒ Chart.yaml version bump` is a REQUIRED check on this repo and
was red: the branch changes chart content while `version`/`appVersion` both still
read 1.9.73, which is what `develop` carries. The gate exists because the chart's
image-refresh CronJob resolves the published tag every 15 minutes, so an
unbumped chart is not a cosmetic omission -- nothing downstream can tell the new
content from the old.
Both fields moved together to 1.9.74, one patch above develop, which is the shape
the gate asks for. They are kept EQUAL deliberately: `appVersion` is what the
rendered image tags follow, so a bump of `version` alone would advertise a
release that pulls the previous images.
Verified: `helm lint` clean (only the pre-existing "icon is recommended" note),
and the WHOLE chart suite rather than the file touched -- `helm unittest client`,
34 suites, 579 tests, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The measurement is what makes this a bug report rather than a style note: five Failed Jobs retained, every Pod gone, the event window rolled past the first failure, and each Job failing 39 seconds in — so not a hang, and the logs would have said exactly what it was. "The reason was unrecoverable from the cluster" is the sentence that matters, and the consequence is worse than the mechanism: an edge silently stopped accepting chart upgrades, security fixes included, while looking healthy from every angle anyone checks.

Fixing image-refresh too — same policy, four times the frequency — is the right instinct, and "fixing only the instance that happened to fail would have left the class" is exactly the failure I flagged on client-runtime#417 an hour ago, where a guard existed in two sibling publishers and was missing from the one shipping the control plane. Good to see it applied ahead of the incident rather than after.

The guard fails closed in three places, not the two you claimed. I checked each:

  • if not seen: sys.exit("the render matrix produced ZERO CronJobs. Nothing was checked, and a guard that checks nothing passes — refusing.")
  • unreached = expected - seen, each reported by name with the reason its policy went unchecked, and folded into the exit condition
  • and the one the description doesn't mention: a Pod spec stating norestartPolicy at all lands in missing and fails, so an unstated policy isn't read as an acceptable one

The declared set coming from grep -rlE '^kind: CronJob$' is line-anchored, so a template that merely mentions CronJob in a comment can't inflate expected and manufacture a phantom UNREACHED. And the [OK] line printing both len(seen) and len(expected) means a passing run is falsifiable rather than just green.

Two judgement calls I'd have made the same way. Scoping to CronJob and recording the argument at the declaration — a Helm hook Job loses its Pod identically, but its failure fails the release in front of the person who ran it, whereas a CronJob's failure is reported to nobody and repeats forever. And the failure message telling a future maintainer to "make that argument at the declaration and narrow this guard deliberately rather than adding an exception list here" — which is the right lesson to encode, given .github just spent three PRs burning an exemption list down to empty.

Green, no threads. 👍

@LukasWodka
LukasWodka merged commit 9e3bfb9 into developAug 27, 2026
48 checks passed
@LukasWodka
LukasWodka deleted the fix/2620-cronjob-failures-leave-evidence branch August 27, 2026 07:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620) - #860

Merged
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence
Aug 27, 2026
Merged

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620)#860
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2620

The defect

Both CronJobs the chart renders used restartPolicy: OnFailure. Kubernetes deletes an OnFailure Job's Pod once backoffLimit is exhausted, and failedJobsHistoryLimit retains the Job — it cannot retain a Pod that no longer exists.

So a repeatedly-failing CronJob leaves a row of Failed Jobs and no logs at all. The upstream docs say it plainly:

"your Pod running the Job will be terminated once the job backoff limit has been reached. This can make debugging the Job's executable more difficult. We suggest setting restartPolicy = 'Never'"

Measured, not theoretical

An edge's auto-upgrade CronJob had been failing hourly for 2.7 days. Five Failed Jobs were retained; every one of their Pods was gone, and the namespace event window had rolled past the first failure — so the reason was unrecoverable from the cluster.

The only surviving evidence that anything was wrong was the CronJob's lastSuccessfulTime. Each Job failed 39 seconds in, so this was never a timeout or a hang: the logs would have said exactly what it was, and they were thrown away.

The consequence was that the edge silently stopped accepting chart upgrades — including any security fix — while looking healthy from every angle anyone checks.

Both CronJobs, not just the one that broke

image-refresh carried the same policy on a 15-minute schedule — the same blind spot at four times the frequency. Fixing only the instance that happened to fail would have left the class.

The guard

scripts/tests/cronjob-failures-are-readable.sh reads every CronJob out of the rendered manifests, so a third one is covered the day it lands rather than when someone remembers. It holds no list of CronJob names.

Fails closed twice:

  • zero CronJobs rendered is a refusal, not a clean sweep — a guard that checks nothing otherwise passes;
  • a template declaring kind: CronJob that no value combination in the render matrix reaches is reported UNREACHED by name and fails, rather than passing silently as "nothing to check".

Wired into DRIFT_GUARDS (now 21 entries — the drift target counts its iterations and refuses to report green on fewer, so adding a guard without wiring it cannot pass).

Scoped to CronJob deliberately, with the argument recorded at the declaration rather than assumed: a Helm hook Job loses its Pod the same way, but its failure fails the release and is reported to whoever ran it — somebody is already looking. A CronJob's failure is reported to nobody and repeats forever, which is what makes unreadable logs fatal rather than inconvenient. Widening it would need that argument made.

Mutation evidence

Four mutations, each run with the baseline confirmed green afterwards:

mutationresult
one template reverts to OnFailureFAIL — names the template and the policy
restartPolicy removed entirelyFAIL"silence is the defect" (Kubernetes defaults it to OnFailure)
render matrix produces no CronJobsFAIL — refuses to report on zero
a new CronJob template goes unreachedFAIL — names it as [UNREACHED]

On the two tests that changed

auto_upgrade_test.yaml and image_refresh_test.yaml each explicitly asserted OnFailure. They were right to pin it — a policy that matters should be pinned — so they are updated rather than deleted, and the comment now records why the pinned value changed and where the class-level sweep lives.

Checks

  • make helm-unittest579 passed, 34 suites
  • make driftall 21 guards green (verified the new one actually executed, not just that the count matched)
  • make helm-lint, make helm-template — clean
  • shellcheck on the new guard — clean

Note on rollout

This change only takes effect on an edge once that edge receives the chart. An edge whose auto-upgrade is already wedged cannot pull it — so unwedging those is separate operational work, tracked in the ticket. The immediate mitigation there is a direct kubectl patch of the CronJob's restartPolicy, which this change makes permanent and unnecessary going forward.

Ticket: tracebloc/backend#2620


Note

Low Risk
Operational observability fix for scheduled jobs with no auth or data-path changes; slightly more failed Pods retained per Job tick, which is intentional.

Overview
Fixes backend#2620: chart CronJobs used restartPolicy: OnFailure, which lets Kubernetes delete the Pod once backoffLimit is exhausted while failedJobsHistoryLimit only keeps the Job—so repeated failures left no logs (observed on a prod edge with hourly auto-upgrade failures for 2.7 days).

auto-upgrade and image-refresh CronJob pod templates now set restartPolicy: Never so each attempt keeps a Pod for debugging. Chart bumps to 1.9.74.

A new required drift guard scripts/tests/cronjob-failures-are-readable.sh renders client/ingestor charts and requires every rendered CronJob to declare Never (or fails on missing policy / unreached CronJob templates). It is wired into DRIFT_GUARDS. Helm unittest suites for both CronJobs pin Never instead of OnFailure.

Rollout note: edges already wedged on auto-upgrade still need operational unwedging; this only applies after they receive the chart (or a kubectl patch on the CronJob).

Reviewed by Cursor Bugbot for commit 92b0f6e. Bugbot is set up for automated code reviews on this repo. Configure here.

…kend#2620)
Both of the chart's CronJobs used `restartPolicy: OnFailure`. Kubernetes DELETES
an OnFailure Job's Pod once `backoffLimit` is exhausted, and
`failedJobsHistoryLimit` retains the JOB — it cannot retain a Pod that no longer
exists. So a repeatedly-failing CronJob leaves a row of `Failed` Jobs and no
logs at all. The upstream docs say it outright: "your Pod running the Job will be
terminated once the job backoff limit has been reached. This can make debugging
the Job's executable more difficult. We suggest setting restartPolicy = 'Never'".
MEASURED, NOT THEORETICAL. A customer prod edge's auto-upgrade had been failing
hourly for 2.7 days (backend#2620). Five `Failed` Jobs were retained; every one
of their Pods was gone and the namespace event window had rolled past the first
failure, so the reason was UNRECOVERABLE FROM THE CLUSTER. The only surviving
evidence that anything was wrong was the CronJob's `lastSuccessfulTime`, which
nothing watches. Each Job failed 39 seconds in, so this was never a timeout —
the logs would have said what it was.
BOTH CronJobs, not just the one that broke. `image-refresh` carried the same
policy and is on a 15-minute schedule, so it had the same blind spot with four
times the frequency. Fixing only the instance that happened to fail would have
left the class.
`scripts/tests/cronjob-failures-are-readable.sh` sweeps every CronJob out of the
RENDERED manifests, so a third one is covered the day it lands rather than when
someone remembers. It holds no list of CronJob names. Fails closed twice: zero
CronJobs rendered is a refusal, not a clean sweep, and a template declaring
`kind: CronJob` that no value combination reaches is reported UNREACHED by name.
Wired into `DRIFT_GUARDS` (21 entries; the target counts its iterations and
refuses to report green on fewer).
Scoped to CronJob deliberately, and the argument is at the declaration: a Helm
hook Job loses its Pod the same way, but its failure fails the release and is
reported to whoever ran it — somebody is already looking. A CronJob's failure is
reported to nobody and repeats forever.
Four mutations run, each with the baseline restored green afterwards:
one template reverts to OnFailure -> FAIL, names the template
restartPolicy removed entirely -> FAIL, "silence is the defect"
render matrix produces no CronJobs -> FAIL, refuses to check nothing
a new CronJob template goes unreached -> FAIL, names it as UNREACHED
The two suites that pinned `OnFailure` are updated rather than deleted — they
were right to pin it, and the comment now records why the pinned value changed.
make helm-unittest: 579 passed, 34 suites. make drift: all 21 guards green.
shellcheck clean.
Ticket: tracebloc/backend#2620
Parent epic: tracebloc/backend#1872
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 27, 2026
`chart content ⇒ Chart.yaml version bump` is a REQUIRED check on this repo and
was red: the branch changes chart content while `version`/`appVersion` both still
read 1.9.73, which is what `develop` carries. The gate exists because the chart's
image-refresh CronJob resolves the published tag every 15 minutes, so an
unbumped chart is not a cosmetic omission -- nothing downstream can tell the new
content from the old.
Both fields moved together to 1.9.74, one patch above develop, which is the shape
the gate asks for. They are kept EQUAL deliberately: `appVersion` is what the
rendered image tags follow, so a bump of `version` alone would advertise a
release that pulls the previous images.
Verified: `helm lint` clean (only the pre-existing "icon is recommended" note),
and the WHOLE chart suite rather than the file touched -- `helm unittest client`,
34 suites, 579 tests, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The measurement is what makes this a bug report rather than a style note: five Failed Jobs retained, every Pod gone, the event window rolled past the first failure, and each Job failing 39 seconds in — so not a hang, and the logs would have said exactly what it was. "The reason was unrecoverable from the cluster" is the sentence that matters, and the consequence is worse than the mechanism: an edge silently stopped accepting chart upgrades, security fixes included, while looking healthy from every angle anyone checks.

Fixing image-refresh too — same policy, four times the frequency — is the right instinct, and "fixing only the instance that happened to fail would have left the class" is exactly the failure I flagged on client-runtime#417 an hour ago, where a guard existed in two sibling publishers and was missing from the one shipping the control plane. Good to see it applied ahead of the incident rather than after.

The guard fails closed in three places, not the two you claimed. I checked each:

  • if not seen: sys.exit("the render matrix produced ZERO CronJobs. Nothing was checked, and a guard that checks nothing passes — refusing.")
  • unreached = expected - seen, each reported by name with the reason its policy went unchecked, and folded into the exit condition
  • and the one the description doesn't mention: a Pod spec stating norestartPolicy at all lands in missing and fails, so an unstated policy isn't read as an acceptable one

The declared set coming from grep -rlE '^kind: CronJob$' is line-anchored, so a template that merely mentions CronJob in a comment can't inflate expected and manufacture a phantom UNREACHED. And the [OK] line printing both len(seen) and len(expected) means a passing run is falsifiable rather than just green.

Two judgement calls I'd have made the same way. Scoping to CronJob and recording the argument at the declaration — a Helm hook Job loses its Pod identically, but its failure fails the release in front of the person who ran it, whereas a CronJob's failure is reported to nobody and repeats forever. And the failure message telling a future maintainer to "make that argument at the declaration and narrow this guard deliberately rather than adding an exception list here" — which is the right lesson to encode, given .github just spent three PRs burning an exemption list down to empty.

Green, no threads. 👍

@LukasWodka
LukasWodka merged commit 9e3bfb9 into developAug 27, 2026
48 checks passed
@LukasWodka
LukasWodka deleted the fix/2620-cronjob-failures-leave-evidence branch August 27, 2026 07:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620) - #860

Merged
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence
Aug 27, 2026
Merged

fix(cronjobs): a failing tick must leave a Pod somebody can read (backend#2620)#860
LukasWodka merged 2 commits into
developfrom
fix/2620-cronjob-failures-leave-evidence

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2620

The defect

Both CronJobs the chart renders used restartPolicy: OnFailure. Kubernetes deletes an OnFailure Job's Pod once backoffLimit is exhausted, and failedJobsHistoryLimit retains the Job — it cannot retain a Pod that no longer exists.

So a repeatedly-failing CronJob leaves a row of Failed Jobs and no logs at all. The upstream docs say it plainly:

"your Pod running the Job will be terminated once the job backoff limit has been reached. This can make debugging the Job's executable more difficult. We suggest setting restartPolicy = 'Never'"

Measured, not theoretical

An edge's auto-upgrade CronJob had been failing hourly for 2.7 days. Five Failed Jobs were retained; every one of their Pods was gone, and the namespace event window had rolled past the first failure — so the reason was unrecoverable from the cluster.

The only surviving evidence that anything was wrong was the CronJob's lastSuccessfulTime. Each Job failed 39 seconds in, so this was never a timeout or a hang: the logs would have said exactly what it was, and they were thrown away.

The consequence was that the edge silently stopped accepting chart upgrades — including any security fix — while looking healthy from every angle anyone checks.

Both CronJobs, not just the one that broke

image-refresh carried the same policy on a 15-minute schedule — the same blind spot at four times the frequency. Fixing only the instance that happened to fail would have left the class.

The guard

scripts/tests/cronjob-failures-are-readable.sh reads every CronJob out of the rendered manifests, so a third one is covered the day it lands rather than when someone remembers. It holds no list of CronJob names.

Fails closed twice:

  • zero CronJobs rendered is a refusal, not a clean sweep — a guard that checks nothing otherwise passes;
  • a template declaring kind: CronJob that no value combination in the render matrix reaches is reported UNREACHED by name and fails, rather than passing silently as "nothing to check".

Wired into DRIFT_GUARDS (now 21 entries — the drift target counts its iterations and refuses to report green on fewer, so adding a guard without wiring it cannot pass).

Scoped to CronJob deliberately, with the argument recorded at the declaration rather than assumed: a Helm hook Job loses its Pod the same way, but its failure fails the release and is reported to whoever ran it — somebody is already looking. A CronJob's failure is reported to nobody and repeats forever, which is what makes unreadable logs fatal rather than inconvenient. Widening it would need that argument made.

Mutation evidence

Four mutations, each run with the baseline confirmed green afterwards:

mutationresult
one template reverts to OnFailureFAIL — names the template and the policy
restartPolicy removed entirelyFAIL"silence is the defect" (Kubernetes defaults it to OnFailure)
render matrix produces no CronJobsFAIL — refuses to report on zero
a new CronJob template goes unreachedFAIL — names it as [UNREACHED]

On the two tests that changed

auto_upgrade_test.yaml and image_refresh_test.yaml each explicitly asserted OnFailure. They were right to pin it — a policy that matters should be pinned — so they are updated rather than deleted, and the comment now records why the pinned value changed and where the class-level sweep lives.

Checks

  • make helm-unittest579 passed, 34 suites
  • make driftall 21 guards green (verified the new one actually executed, not just that the count matched)
  • make helm-lint, make helm-template — clean
  • shellcheck on the new guard — clean

Note on rollout

This change only takes effect on an edge once that edge receives the chart. An edge whose auto-upgrade is already wedged cannot pull it — so unwedging those is separate operational work, tracked in the ticket. The immediate mitigation there is a direct kubectl patch of the CronJob's restartPolicy, which this change makes permanent and unnecessary going forward.

Ticket: tracebloc/backend#2620


Note

Low Risk
Operational observability fix for scheduled jobs with no auth or data-path changes; slightly more failed Pods retained per Job tick, which is intentional.

Overview
Fixes backend#2620: chart CronJobs used restartPolicy: OnFailure, which lets Kubernetes delete the Pod once backoffLimit is exhausted while failedJobsHistoryLimit only keeps the Job—so repeated failures left no logs (observed on a prod edge with hourly auto-upgrade failures for 2.7 days).

auto-upgrade and image-refresh CronJob pod templates now set restartPolicy: Never so each attempt keeps a Pod for debugging. Chart bumps to 1.9.74.

A new required drift guard scripts/tests/cronjob-failures-are-readable.sh renders client/ingestor charts and requires every rendered CronJob to declare Never (or fails on missing policy / unreached CronJob templates). It is wired into DRIFT_GUARDS. Helm unittest suites for both CronJobs pin Never instead of OnFailure.

Rollout note: edges already wedged on auto-upgrade still need operational unwedging; this only applies after they receive the chart (or a kubectl patch on the CronJob).

Reviewed by Cursor Bugbot for commit 92b0f6e. Bugbot is set up for automated code reviews on this repo. Configure here.

…kend#2620)
Both of the chart's CronJobs used `restartPolicy: OnFailure`. Kubernetes DELETES
an OnFailure Job's Pod once `backoffLimit` is exhausted, and
`failedJobsHistoryLimit` retains the JOB — it cannot retain a Pod that no longer
exists. So a repeatedly-failing CronJob leaves a row of `Failed` Jobs and no
logs at all. The upstream docs say it outright: "your Pod running the Job will be
terminated once the job backoff limit has been reached. This can make debugging
the Job's executable more difficult. We suggest setting restartPolicy = 'Never'".
MEASURED, NOT THEORETICAL. A customer prod edge's auto-upgrade had been failing
hourly for 2.7 days (backend#2620). Five `Failed` Jobs were retained; every one
of their Pods was gone and the namespace event window had rolled past the first
failure, so the reason was UNRECOVERABLE FROM THE CLUSTER. The only surviving
evidence that anything was wrong was the CronJob's `lastSuccessfulTime`, which
nothing watches. Each Job failed 39 seconds in, so this was never a timeout —
the logs would have said what it was.
BOTH CronJobs, not just the one that broke. `image-refresh` carried the same
policy and is on a 15-minute schedule, so it had the same blind spot with four
times the frequency. Fixing only the instance that happened to fail would have
left the class.
`scripts/tests/cronjob-failures-are-readable.sh` sweeps every CronJob out of the
RENDERED manifests, so a third one is covered the day it lands rather than when
someone remembers. It holds no list of CronJob names. Fails closed twice: zero
CronJobs rendered is a refusal, not a clean sweep, and a template declaring
`kind: CronJob` that no value combination reaches is reported UNREACHED by name.
Wired into `DRIFT_GUARDS` (21 entries; the target counts its iterations and
refuses to report green on fewer).
Scoped to CronJob deliberately, and the argument is at the declaration: a Helm
hook Job loses its Pod the same way, but its failure fails the release and is
reported to whoever ran it — somebody is already looking. A CronJob's failure is
reported to nobody and repeats forever.
Four mutations run, each with the baseline restored green afterwards:
one template reverts to OnFailure -> FAIL, names the template
restartPolicy removed entirely -> FAIL, "silence is the defect"
render matrix produces no CronJobs -> FAIL, refuses to check nothing
a new CronJob template goes unreached -> FAIL, names it as UNREACHED
The two suites that pinned `OnFailure` are updated rather than deleted — they
were right to pin it, and the comment now records why the pinned value changed.
make helm-unittest: 579 passed, 34 suites. make drift: all 21 guards green.
shellcheck clean.
Ticket: tracebloc/backend#2620
Parent epic: tracebloc/backend#1872
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 27, 2026
`chart content ⇒ Chart.yaml version bump` is a REQUIRED check on this repo and
was red: the branch changes chart content while `version`/`appVersion` both still
read 1.9.73, which is what `develop` carries. The gate exists because the chart's
image-refresh CronJob resolves the published tag every 15 minutes, so an
unbumped chart is not a cosmetic omission -- nothing downstream can tell the new
content from the old.
Both fields moved together to 1.9.74, one patch above develop, which is the shape
the gate asks for. They are kept EQUAL deliberately: `appVersion` is what the
rendered image tags follow, so a bump of `version` alone would advertise a
release that pulls the previous images.
Verified: `helm lint` clean (only the pre-existing "icon is recommended" note),
and the WHOLE chart suite rather than the file touched -- `helm unittest client`,
34 suites, 579 tests, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The measurement is what makes this a bug report rather than a style note: five Failed Jobs retained, every Pod gone, the event window rolled past the first failure, and each Job failing 39 seconds in — so not a hang, and the logs would have said exactly what it was. "The reason was unrecoverable from the cluster" is the sentence that matters, and the consequence is worse than the mechanism: an edge silently stopped accepting chart upgrades, security fixes included, while looking healthy from every angle anyone checks.

Fixing image-refresh too — same policy, four times the frequency — is the right instinct, and "fixing only the instance that happened to fail would have left the class" is exactly the failure I flagged on client-runtime#417 an hour ago, where a guard existed in two sibling publishers and was missing from the one shipping the control plane. Good to see it applied ahead of the incident rather than after.

The guard fails closed in three places, not the two you claimed. I checked each:

  • if not seen: sys.exit("the render matrix produced ZERO CronJobs. Nothing was checked, and a guard that checks nothing passes — refusing.")
  • unreached = expected - seen, each reported by name with the reason its policy went unchecked, and folded into the exit condition
  • and the one the description doesn't mention: a Pod spec stating norestartPolicy at all lands in missing and fails, so an unstated policy isn't read as an acceptable one

The declared set coming from grep -rlE '^kind: CronJob$' is line-anchored, so a template that merely mentions CronJob in a comment can't inflate expected and manufacture a phantom UNREACHED. And the [OK] line printing both len(seen) and len(expected) means a passing run is falsifiable rather than just green.

Two judgement calls I'd have made the same way. Scoping to CronJob and recording the argument at the declaration — a Helm hook Job loses its Pod identically, but its failure fails the release in front of the person who ran it, whereas a CronJob's failure is reported to nobody and repeats forever. And the failure message telling a future maintainer to "make that argument at the declaration and narrow this guard deliberately rather than adding an exception list here" — which is the right lesson to encode, given .github just spent three PRs burning an exemption list down to empty.

Green, no threads. 👍

@LukasWodka
LukasWodka merged commit 9e3bfb9 into developAug 27, 2026
48 checks passed
@LukasWodka
LukasWodka deleted the fix/2620-cronjob-failures-leave-evidence branch August 27, 2026 07:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal