Skip to content

Commit 93b02f3

Browse files
ersin-erdalclaudecursoragent
authored
[9.5] [ResponseOps] Skip unchanged alerts-as-data component templates and ILM policy on install (#278126) (#291078)
# Backport This will backport the following commits from `main` to `9.5`: - [[ResponseOps] Skip unchanged alerts-as-data component templates and ILM policy on install (#278126)](#278126) <!--- Backport version: 12.0.4 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Ersin Erdal","email":"92688503+ersin-erdal@users.noreply.github.com"},"sourceCommit":{"committedDate":"2026-08-20T06:59:58Z","message":"[ResponseOps] Skip unchanged alerts-as-data component templates and ILM policy on install (#278126)\n\n## Summary\n\nTowards: https://github.com/elastic/kibana/issues/246016\n\nThe alerts-as-data (`.alerts-*`) resource installer PUT the ILM policy\nand every component template **unconditionally on each boot of each\nKibana node**, even when nothing had changed. Elasticsearch recognises\nan identical PUT and leaves the cluster state alone, so these writes\nwere not publishing new cluster states — but the body still crosses the\nwire from every node, and for component templates the master still\nqueues and runs a cluster-state update task that parses and compresses\nthe mappings before concluding nothing changed. Across ~17 resources x N\nnodes on every boot that is avoidable master work, against the startup\nchurn behind #246016. (For the ILM policy the no-op check happens before\nany task is queued, so there the saving is just the request payload.)\n\nThis stamps a **content hash** into each resource's `_meta` and\nGET-checks it before writing: on a positive hash match the write is\nskipped; anything else (missing stamp, 404, error) falls through to the\nnormal PUT.\n\nThis is the first slice of the \"smarter alert resource installation\"\nwork (#246016). It intentionally covers the highest-leverage,\nlowest-risk resources first: the ILM policy and all component templates\n(including the 83.6 KiB ECS mappings template, by far the largest).\nIndex templates and the monotonic-version rule are a deliberate\nfollow-up.\n\n## What changed\n\n- New\n[`resource_hash.ts`](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/resource_hash.ts):\n`computeResourceHash(body)` — a deterministic hash (recursively sorted\nkeys, `undefined` dropped, array order preserved) via Node `crypto` +\nSHA-256, plus the `_meta.content_hash` field name. No new dependency.\n- `create_or_update_component_template.ts` — stamps `_meta.content_hash`\n(computed over the template body, excluding `_meta`), GETs the installed\ntemplate, and skips the PUT on a hash match. The existing field-limit\nfailure/retry path is untouched; it only runs when we actually PUT.\n- `create_or_update_ilm_policy.ts` — same pattern, hashing the policy\nbody (excluding `_meta`).\n- Because `createOrUpdateComponentTemplate` is shared with the\n`rule_registry` `ResourceInstaller` and covers both common and context\ncomponent templates, all of them benefit from the same change.\n\n### Fail-safe by design\n\nSkipping happens **only** on a positive hash match. A 404, a missing\nstamp (resources installed before this change), or any GET failure —\npermissions, an exhausted retry — leaves the installed content unknown\nand falls through to the normal PUT, so the check can never block an\ninstall that would otherwise have succeeded. The worst case is today's\nbehavior: a redundant write.\n\nOne deliberate behavior change: the skip keys off the stamp, not the\nlive body. If someone hand-edits a managed `.alerts-*` resource but\nleaves `_meta.content_hash` intact, Kibana keeps skipping it, where\nbefore this PR every boot silently repaired the drift. Any edit that\nalso drops or changes `_meta.content_hash` still converges on the next\nboot. For framework-managed `.alerts-*` resources that trade seems\nright, but it is a real change and not covered by \"never a missed\nupdate\".\n\n### Effect\n\nOn a no-change restart, the three shared component templates + the ILM\npolicy collapse from unconditional PUTs to one cheap GET each, per node.\nFirst boot, version upgrades (field maps change → hash changes), and\nmanual edits that touch `_meta` all still PUT.\n\n## How to test manually\n\nThe change makes installs **skip the write when nothing changed**, so\nthe thing to verify is *behavior* (skipped vs written), not a settings\nvalue. The signals are the Kibana debug log, the `_meta.content_hash`\nstamp, and — for component templates — the Elasticsearch master log.\n\n### Setup\n- Run ES with `path.data=../your-local-data-path` so resources survive\nrestarts.\n- Enable debug logging for alerting in `kibana.yml`:\n ```yaml\n logging.loggers:\n - name: plugins.alerting\n level: debug\n ```\n\n### Test 1 — first install stamps the content hash\n1. Start ES + Kibana on a clean data path.\n2. In Dev Tools, confirm the shared resources exist and each carries a\nstamp:\n ```\nGET _component_template/.alerts-framework-mappings → _meta.content_hash\npresent\nGET _component_template/.alerts-legacy-alert-mappings →\n_meta.content_hash present\nGET _component_template/.alerts-ecs-mappings → _meta.content_hash\npresent\nGET _ilm/policy/.alerts-ilm-policy → _meta.content_hash present\n ```\n\n### Test 2 — no-change restart skips every write (the fix)\n1. Stop Kibana, restart with no code/config change.\n2. Logs show a skip line per resource:\n ```\nSkipping install of component template .alerts-framework-mappings;\ncontent unchanged (<hash>)\nSkipping install of ILM policy .alerts-ilm-policy; content unchanged\n(<hash>)\n ```\n3. Note that the ILM policy `version` is **not** a useful signal here:\nElasticsearch no-ops an identical `putLifecycle`, so `version` stays put\non `main` too. The Kibana debug lines above are the discriminator. For\ncomponent templates you can corroborate on the Elasticsearch side: the\nmaster logs `updating component template [...]` at INFO only when the\ncontent actually differs, so on `main` that line appears on every boot\nand here it does not.\n4. On `main` all four are PUT on every boot (and discarded by ES as\nno-ops); here the request is never sent.\n\n### Test 3 — a real change still installs, at per-resource granularity\n1. Force a component-template change: add a field to a rule type's\n`alerts.mappings` (e.g. the custom threshold rule type, per the fixture\nin #216719).\n2. Restart Kibana.\n3. Logs show the affected component template **installing** (no skip\nline), while unchanged resources (ILM policy, legacy-alert template)\nstill skip.\n4. `GET _component_template/<changed-template>` → `_meta.content_hash`\ndiffers from Test 1.\n\n### Test 4 — pre-upgrade resources (no stamp) install once, then skip\n1. Simulate a resource installed before this change by removing\n`_meta.content_hash` from one template and PUTting it back.\n2. Restart Kibana → that template **installs** (missing stamp →\nfail-safe PUT, re-adds the hash).\n3. Restart again → now it **skips**. Confirms unstamped resources\nconverge rather than being skipped incorrectly.\n\n### Test 5 — data streams (serverless path)\n1. Run with serverless config (`useDataStreamForAlerts` true).\n2. Repeat Test 2. The ILM policy is skipped entirely as before (early\nreturn on data streams); component templates skip on a no-change restart\nexactly as in stateful mode.\n\n## Testing\n\n- `node scripts/jest\nx-pack/platform/plugins/shared/alerting/server/alerts_service` — new\n`resource_hash` suite + updated `create_or_update_component_template` /\n`create_or_update_ilm_policy` suites (skip-on-match, PUT-on-change,\nPUT-on-404/missing-stamp, PUT-when-unreadable), and\n`alerts_service.test.ts` all pass.\n- `node scripts/jest\nx-pack/platform/plugins/shared/rule_registry/server/rule_data_plugin_service`\n— pass.\n- scoped type check and eslint clean.\n\n## Notes / follow-ups\n\n- **Scope:** index templates (`createOrUpdateIndexTemplate`) are\nintentionally not included — that path carries the field-limit\npreservation logic and always-PUT (composed_of + simulate) semantics\nthat need separate handling. Follow-up in #280154.\n- **Kill switch:** none added; the fail-safe design keeps the risk low.\nHappy to add a `skipUnchangedResources` config opt-out if reviewers\nprefer an escape hatch.\n- **Observability:** a `puts_skipped` / `puts_executed` metric (phase 0\nof #246016) would make the skip observable in production and is the only\nway to demonstrate the saving in a real deployment. Planned as a\nseparate change alongside the index-template slice.\n\nContributes to #246016\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---------\n\nCo-authored-by: Claude Opus 4.8 <noreply@anthropic.com>\nCo-authored-by: Cursor <cursoragent@cursor.com>","sha":"2c8f0181e11a512e95659bad6b99916d908a7d87","branchLabelMapping":{"^v9.6.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:Alerting","release_note:skip","backport:skip","Team:ResponseOps","v9.6.0"],"title":"[ResponseOps] Skip unchanged alerts-as-data component templates and ILM policy on install","number":278126,"url":"https://github.com/elastic/kibana/pull/278126","mergeCommit":{"message":"[ResponseOps] Skip unchanged alerts-as-data component templates and ILM policy on install (#278126)\n\n## Summary\n\nTowards: https://github.com/elastic/kibana/issues/246016\n\nThe alerts-as-data (`.alerts-*`) resource installer PUT the ILM policy\nand every component template **unconditionally on each boot of each\nKibana node**, even when nothing had changed. Elasticsearch recognises\nan identical PUT and leaves the cluster state alone, so these writes\nwere not publishing new cluster states — but the body still crosses the\nwire from every node, and for component templates the master still\nqueues and runs a cluster-state update task that parses and compresses\nthe mappings before concluding nothing changed. Across ~17 resources x N\nnodes on every boot that is avoidable master work, against the startup\nchurn behind #246016. (For the ILM policy the no-op check happens before\nany task is queued, so there the saving is just the request payload.)\n\nThis stamps a **content hash** into each resource's `_meta` and\nGET-checks it before writing: on a positive hash match the write is\nskipped; anything else (missing stamp, 404, error) falls through to the\nnormal PUT.\n\nThis is the first slice of the \"smarter alert resource installation\"\nwork (#246016). It intentionally covers the highest-leverage,\nlowest-risk resources first: the ILM policy and all component templates\n(including the 83.6 KiB ECS mappings template, by far the largest).\nIndex templates and the monotonic-version rule are a deliberate\nfollow-up.\n\n## What changed\n\n- New\n[`resource_hash.ts`](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/resource_hash.ts):\n`computeResourceHash(body)` — a deterministic hash (recursively sorted\nkeys, `undefined` dropped, array order preserved) via Node `crypto` +\nSHA-256, plus the `_meta.content_hash` field name. No new dependency.\n- `create_or_update_component_template.ts` — stamps `_meta.content_hash`\n(computed over the template body, excluding `_meta`), GETs the installed\ntemplate, and skips the PUT on a hash match. The existing field-limit\nfailure/retry path is untouched; it only runs when we actually PUT.\n- `create_or_update_ilm_policy.ts` — same pattern, hashing the policy\nbody (excluding `_meta`).\n- Because `createOrUpdateComponentTemplate` is shared with the\n`rule_registry` `ResourceInstaller` and covers both common and context\ncomponent templates, all of them benefit from the same change.\n\n### Fail-safe by design\n\nSkipping happens **only** on a positive hash match. A 404, a missing\nstamp (resources installed before this change), or any GET failure —\npermissions, an exhausted retry — leaves the installed content unknown\nand falls through to the normal PUT, so the check can never block an\ninstall that would otherwise have succeeded. The worst case is today's\nbehavior: a redundant write.\n\nOne deliberate behavior change: the skip keys off the stamp, not the\nlive body. If someone hand-edits a managed `.alerts-*` resource but\nleaves `_meta.content_hash` intact, Kibana keeps skipping it, where\nbefore this PR every boot silently repaired the drift. Any edit that\nalso drops or changes `_meta.content_hash` still converges on the next\nboot. For framework-managed `.alerts-*` resources that trade seems\nright, but it is a real change and not covered by \"never a missed\nupdate\".\n\n### Effect\n\nOn a no-change restart, the three shared component templates + the ILM\npolicy collapse from unconditional PUTs to one cheap GET each, per node.\nFirst boot, version upgrades (field maps change → hash changes), and\nmanual edits that touch `_meta` all still PUT.\n\n## How to test manually\n\nThe change makes installs **skip the write when nothing changed**, so\nthe thing to verify is *behavior* (skipped vs written), not a settings\nvalue. The signals are the Kibana debug log, the `_meta.content_hash`\nstamp, and — for component templates — the Elasticsearch master log.\n\n### Setup\n- Run ES with `path.data=../your-local-data-path` so resources survive\nrestarts.\n- Enable debug logging for alerting in `kibana.yml`:\n ```yaml\n logging.loggers:\n - name: plugins.alerting\n level: debug\n ```\n\n### Test 1 — first install stamps the content hash\n1. Start ES + Kibana on a clean data path.\n2. In Dev Tools, confirm the shared resources exist and each carries a\nstamp:\n ```\nGET _component_template/.alerts-framework-mappings → _meta.content_hash\npresent\nGET _component_template/.alerts-legacy-alert-mappings →\n_meta.content_hash present\nGET _component_template/.alerts-ecs-mappings → _meta.content_hash\npresent\nGET _ilm/policy/.alerts-ilm-policy → _meta.content_hash present\n ```\n\n### Test 2 — no-change restart skips every write (the fix)\n1. Stop Kibana, restart with no code/config change.\n2. Logs show a skip line per resource:\n ```\nSkipping install of component template .alerts-framework-mappings;\ncontent unchanged (<hash>)\nSkipping install of ILM policy .alerts-ilm-policy; content unchanged\n(<hash>)\n ```\n3. Note that the ILM policy `version` is **not** a useful signal here:\nElasticsearch no-ops an identical `putLifecycle`, so `version` stays put\non `main` too. The Kibana debug lines above are the discriminator. For\ncomponent templates you can corroborate on the Elasticsearch side: the\nmaster logs `updating component template [...]` at INFO only when the\ncontent actually differs, so on `main` that line appears on every boot\nand here it does not.\n4. On `main` all four are PUT on every boot (and discarded by ES as\nno-ops); here the request is never sent.\n\n### Test 3 — a real change still installs, at per-resource granularity\n1. Force a component-template change: add a field to a rule type's\n`alerts.mappings` (e.g. the custom threshold rule type, per the fixture\nin #216719).\n2. Restart Kibana.\n3. Logs show the affected component template **installing** (no skip\nline), while unchanged resources (ILM policy, legacy-alert template)\nstill skip.\n4. `GET _component_template/<changed-template>` → `_meta.content_hash`\ndiffers from Test 1.\n\n### Test 4 — pre-upgrade resources (no stamp) install once, then skip\n1. Simulate a resource installed before this change by removing\n`_meta.content_hash` from one template and PUTting it back.\n2. Restart Kibana → that template **installs** (missing stamp →\nfail-safe PUT, re-adds the hash).\n3. Restart again → now it **skips**. Confirms unstamped resources\nconverge rather than being skipped incorrectly.\n\n### Test 5 — data streams (serverless path)\n1. Run with serverless config (`useDataStreamForAlerts` true).\n2. Repeat Test 2. The ILM policy is skipped entirely as before (early\nreturn on data streams); component templates skip on a no-change restart\nexactly as in stateful mode.\n\n## Testing\n\n- `node scripts/jest\nx-pack/platform/plugins/shared/alerting/server/alerts_service` — new\n`resource_hash` suite + updated `create_or_update_component_template` /\n`create_or_update_ilm_policy` suites (skip-on-match, PUT-on-change,\nPUT-on-404/missing-stamp, PUT-when-unreadable), and\n`alerts_service.test.ts` all pass.\n- `node scripts/jest\nx-pack/platform/plugins/shared/rule_registry/server/rule_data_plugin_service`\n— pass.\n- scoped type check and eslint clean.\n\n## Notes / follow-ups\n\n- **Scope:** index templates (`createOrUpdateIndexTemplate`) are\nintentionally not included — that path carries the field-limit\npreservation logic and always-PUT (composed_of + simulate) semantics\nthat need separate handling. Follow-up in #280154.\n- **Kill switch:** none added; the fail-safe design keeps the risk low.\nHappy to add a `skipUnchangedResources` config opt-out if reviewers\nprefer an escape hatch.\n- **Observability:** a `puts_skipped` / `puts_executed` metric (phase 0\nof #246016) would make the skip observable in production and is the only\nway to demonstrate the saving in a real deployment. Planned as a\nseparate change alongside the index-template slice.\n\nContributes to #246016\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---------\n\nCo-authored-by: Claude Opus 4.8 <noreply@anthropic.com>\nCo-authored-by: Cursor <cursoragent@cursor.com>","sha":"2c8f0181e11a512e95659bad6b99916d908a7d87"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.6.0","branchLabelMappingKey":"^v9.6.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/278126","number":278126,"mergeCommit":{"message":"[ResponseOps] Skip unchanged alerts-as-data component templates and ILM policy on install (#278126)\n\n## Summary\n\nTowards: https://github.com/elastic/kibana/issues/246016\n\nThe alerts-as-data (`.alerts-*`) resource installer PUT the ILM policy\nand every component template **unconditionally on each boot of each\nKibana node**, even when nothing had changed. Elasticsearch recognises\nan identical PUT and leaves the cluster state alone, so these writes\nwere not publishing new cluster states — but the body still crosses the\nwire from every node, and for component templates the master still\nqueues and runs a cluster-state update task that parses and compresses\nthe mappings before concluding nothing changed. Across ~17 resources x N\nnodes on every boot that is avoidable master work, against the startup\nchurn behind #246016. (For the ILM policy the no-op check happens before\nany task is queued, so there the saving is just the request payload.)\n\nThis stamps a **content hash** into each resource's `_meta` and\nGET-checks it before writing: on a positive hash match the write is\nskipped; anything else (missing stamp, 404, error) falls through to the\nnormal PUT.\n\nThis is the first slice of the \"smarter alert resource installation\"\nwork (#246016). It intentionally covers the highest-leverage,\nlowest-risk resources first: the ILM policy and all component templates\n(including the 83.6 KiB ECS mappings template, by far the largest).\nIndex templates and the monotonic-version rule are a deliberate\nfollow-up.\n\n## What changed\n\n- New\n[`resource_hash.ts`](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/resource_hash.ts):\n`computeResourceHash(body)` — a deterministic hash (recursively sorted\nkeys, `undefined` dropped, array order preserved) via Node `crypto` +\nSHA-256, plus the `_meta.content_hash` field name. No new dependency.\n- `create_or_update_component_template.ts` — stamps `_meta.content_hash`\n(computed over the template body, excluding `_meta`), GETs the installed\ntemplate, and skips the PUT on a hash match. The existing field-limit\nfailure/retry path is untouched; it only runs when we actually PUT.\n- `create_or_update_ilm_policy.ts` — same pattern, hashing the policy\nbody (excluding `_meta`).\n- Because `createOrUpdateComponentTemplate` is shared with the\n`rule_registry` `ResourceInstaller` and covers both common and context\ncomponent templates, all of them benefit from the same change.\n\n### Fail-safe by design\n\nSkipping happens **only** on a positive hash match. A 404, a missing\nstamp (resources installed before this change), or any GET failure —\npermissions, an exhausted retry — leaves the installed content unknown\nand falls through to the normal PUT, so the check can never block an\ninstall that would otherwise have succeeded. The worst case is today's\nbehavior: a redundant write.\n\nOne deliberate behavior change: the skip keys off the stamp, not the\nlive body. If someone hand-edits a managed `.alerts-*` resource but\nleaves `_meta.content_hash` intact, Kibana keeps skipping it, where\nbefore this PR every boot silently repaired the drift. Any edit that\nalso drops or changes `_meta.content_hash` still converges on the next\nboot. For framework-managed `.alerts-*` resources that trade seems\nright, but it is a real change and not covered by \"never a missed\nupdate\".\n\n### Effect\n\nOn a no-change restart, the three shared component templates + the ILM\npolicy collapse from unconditional PUTs to one cheap GET each, per node.\nFirst boot, version upgrades (field maps change → hash changes), and\nmanual edits that touch `_meta` all still PUT.\n\n## How to test manually\n\nThe change makes installs **skip the write when nothing changed**, so\nthe thing to verify is *behavior* (skipped vs written), not a settings\nvalue. The signals are the Kibana debug log, the `_meta.content_hash`\nstamp, and — for component templates — the Elasticsearch master log.\n\n### Setup\n- Run ES with `path.data=../your-local-data-path` so resources survive\nrestarts.\n- Enable debug logging for alerting in `kibana.yml`:\n ```yaml\n logging.loggers:\n - name: plugins.alerting\n level: debug\n ```\n\n### Test 1 — first install stamps the content hash\n1. Start ES + Kibana on a clean data path.\n2. In Dev Tools, confirm the shared resources exist and each carries a\nstamp:\n ```\nGET _component_template/.alerts-framework-mappings → _meta.content_hash\npresent\nGET _component_template/.alerts-legacy-alert-mappings →\n_meta.content_hash present\nGET _component_template/.alerts-ecs-mappings → _meta.content_hash\npresent\nGET _ilm/policy/.alerts-ilm-policy → _meta.content_hash present\n ```\n\n### Test 2 — no-change restart skips every write (the fix)\n1. Stop Kibana, restart with no code/config change.\n2. Logs show a skip line per resource:\n ```\nSkipping install of component template .alerts-framework-mappings;\ncontent unchanged (<hash>)\nSkipping install of ILM policy .alerts-ilm-policy; content unchanged\n(<hash>)\n ```\n3. Note that the ILM policy `version` is **not** a useful signal here:\nElasticsearch no-ops an identical `putLifecycle`, so `version` stays put\non `main` too. The Kibana debug lines above are the discriminator. For\ncomponent templates you can corroborate on the Elasticsearch side: the\nmaster logs `updating component template [...]` at INFO only when the\ncontent actually differs, so on `main` that line appears on every boot\nand here it does not.\n4. On `main` all four are PUT on every boot (and discarded by ES as\nno-ops); here the request is never sent.\n\n### Test 3 — a real change still installs, at per-resource granularity\n1. Force a component-template change: add a field to a rule type's\n`alerts.mappings` (e.g. the custom threshold rule type, per the fixture\nin #216719).\n2. Restart Kibana.\n3. Logs show the affected component template **installing** (no skip\nline), while unchanged resources (ILM policy, legacy-alert template)\nstill skip.\n4. `GET _component_template/<changed-template>` → `_meta.content_hash`\ndiffers from Test 1.\n\n### Test 4 — pre-upgrade resources (no stamp) install once, then skip\n1. Simulate a resource installed before this change by removing\n`_meta.content_hash` from one template and PUTting it back.\n2. Restart Kibana → that template **installs** (missing stamp →\nfail-safe PUT, re-adds the hash).\n3. Restart again → now it **skips**. Confirms unstamped resources\nconverge rather than being skipped incorrectly.\n\n### Test 5 — data streams (serverless path)\n1. Run with serverless config (`useDataStreamForAlerts` true).\n2. Repeat Test 2. The ILM policy is skipped entirely as before (early\nreturn on data streams); component templates skip on a no-change restart\nexactly as in stateful mode.\n\n## Testing\n\n- `node scripts/jest\nx-pack/platform/plugins/shared/alerting/server/alerts_service` — new\n`resource_hash` suite + updated `create_or_update_component_template` /\n`create_or_update_ilm_policy` suites (skip-on-match, PUT-on-change,\nPUT-on-404/missing-stamp, PUT-when-unreadable), and\n`alerts_service.test.ts` all pass.\n- `node scripts/jest\nx-pack/platform/plugins/shared/rule_registry/server/rule_data_plugin_service`\n— pass.\n- scoped type check and eslint clean.\n\n## Notes / follow-ups\n\n- **Scope:** index templates (`createOrUpdateIndexTemplate`) are\nintentionally not included — that path carries the field-limit\npreservation logic and always-PUT (composed_of + simulate) semantics\nthat need separate handling. Follow-up in #280154.\n- **Kill switch:** none added; the fail-safe design keeps the risk low.\nHappy to add a `skipUnchangedResources` config opt-out if reviewers\nprefer an escape hatch.\n- **Observability:** a `puts_skipped` / `puts_executed` metric (phase 0\nof #246016) would make the skip observable in production and is the only\nway to demonstrate the saving in a real deployment. Planned as a\nseparate change alongside the index-template slice.\n\nContributes to #246016\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---------\n\nCo-authored-by: Claude Opus 4.8 <noreply@anthropic.com>\nCo-authored-by: Cursor <cursoragent@cursor.com>","sha":"2c8f0181e11a512e95659bad6b99916d908a7d87"}}]}] BACKPORT--> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 256db9c commit 93b02f3

8 files changed

Lines changed: 396 additions & 17 deletions

File tree

x-pack/platform/plugins/shared/alerting/server/alerts_service/alerts_service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ const IlmPutBody = {
110110
policy: {
111111
_meta: {
112112
managed: true,
113+
content_hash: expect.stringMatching(/^[0-9a-f]{16}$/),
113114
},
114115
phases: {
115116
hot: {

x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/create_or_update_component_template.test.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,110 @@ describe('createOrUpdateComponentTemplate', () => {
4242
jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayMultiplier);
4343
});
4444

45-
it(`should call esClient to put component template`, async () => {
45+
const stampedComponentTemplate = {
46+
...ComponentTemplate,
47+
_meta: {
48+
...ComponentTemplate._meta,
49+
content_hash: expect.stringMatching(/^[0-9a-f]{16}$/),
50+
},
51+
};
52+
53+
it(`should call esClient to put component template, stamped with a content hash`, async () => {
54+
await createOrUpdateComponentTemplate({
55+
logger,
56+
esClient: clusterClient,
57+
template: ComponentTemplate,
58+
totalFieldsLimit: 2500,
59+
});
60+
61+
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledWith(
62+
stampedComponentTemplate
63+
);
64+
});
65+
66+
it(`should skip the PUT when the installed content hash matches`, async () => {
67+
// First install to capture the hash this template stamps.
68+
await createOrUpdateComponentTemplate({
69+
logger,
70+
esClient: clusterClient,
71+
template: ComponentTemplate,
72+
totalFieldsLimit: 2500,
73+
});
74+
const installedHash = (
75+
clusterClient.cluster.putComponentTemplate.mock.calls[0][0] as unknown as {
76+
_meta: { content_hash: string };
77+
}
78+
)._meta.content_hash;
79+
clusterClient.cluster.putComponentTemplate.mockClear();
80+
81+
clusterClient.cluster.getComponentTemplate.mockResolvedValue({
82+
component_templates: [
83+
{ name: 'test-mappings', component_template: { _meta: { content_hash: installedHash } } },
84+
],
85+
} as unknown as Awaited<ReturnType<typeof clusterClient.cluster.getComponentTemplate>>);
86+
87+
await createOrUpdateComponentTemplate({
88+
logger,
89+
esClient: clusterClient,
90+
template: ComponentTemplate,
91+
totalFieldsLimit: 2500,
92+
});
93+
94+
expect(clusterClient.cluster.putComponentTemplate).not.toHaveBeenCalled();
95+
});
96+
97+
it(`should PUT when the installed content hash differs`, async () => {
98+
clusterClient.cluster.getComponentTemplate.mockResolvedValue({
99+
component_templates: [
100+
{ name: 'test-mappings', component_template: { _meta: { content_hash: 'stale-hash' } } },
101+
],
102+
} as unknown as Awaited<ReturnType<typeof clusterClient.cluster.getComponentTemplate>>);
103+
104+
await createOrUpdateComponentTemplate({
105+
logger,
106+
esClient: clusterClient,
107+
template: ComponentTemplate,
108+
totalFieldsLimit: 2500,
109+
});
110+
111+
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledWith(
112+
stampedComponentTemplate
113+
);
114+
});
115+
116+
it(`should PUT when the installed template carries no content hash`, async () => {
117+
clusterClient.cluster.getComponentTemplate.mockResolvedValue({
118+
component_templates: [
119+
{ name: 'test-mappings', component_template: { _meta: { managed: true } } },
120+
],
121+
} as unknown as Awaited<ReturnType<typeof clusterClient.cluster.getComponentTemplate>>);
122+
123+
await createOrUpdateComponentTemplate({
124+
logger,
125+
esClient: clusterClient,
126+
template: ComponentTemplate,
127+
totalFieldsLimit: 2500,
128+
});
129+
130+
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(1);
131+
});
132+
133+
it(`should PUT when the installed template cannot be read`, async () => {
134+
clusterClient.cluster.getComponentTemplate.mockRejectedValue(new Error('security_exception'));
135+
46136
await createOrUpdateComponentTemplate({
47137
logger,
48138
esClient: clusterClient,
49139
template: ComponentTemplate,
50140
totalFieldsLimit: 2500,
51141
});
52142

53-
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledWith(ComponentTemplate);
143+
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledWith(
144+
stampedComponentTemplate
145+
);
146+
expect(logger.debug).toHaveBeenCalledWith(
147+
`Could not read installed component template test-mappings content hash; will install (security_exception)`
148+
);
54149
});
55150

56151
it(`should retry on transient ES errors`, async () => {

x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/create_or_update_component_template.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { Logger, ElasticsearchClient } from '@kbn/core/server';
1313
import { asyncForEach } from '@kbn/std';
1414
import { retryTransientEsErrors } from '../../lib/retry_transient_es_errors';
1515
import { updateIndexTemplateFieldsLimit } from './update_index_template_fields_limit';
16+
import { computeResourceHash, RESOURCE_CONTENT_HASH_META_FIELD } from './resource_hash';
1617

1718
interface CreateOrUpdateComponentTemplateOpts {
1819
logger: Logger;
@@ -21,6 +22,35 @@ interface CreateOrUpdateComponentTemplateOpts {
2122
totalFieldsLimit: number;
2223
}
2324

25+
/**
26+
* Reads the content hash stamped in `_meta` on the currently-installed component
27+
* template, or `undefined` if the template does not exist, carries no stamp, or
28+
* cannot be read.
29+
*/
30+
const getInstalledComponentTemplateHash = async (
31+
esClient: ElasticsearchClient,
32+
name: string,
33+
logger: Logger
34+
): Promise<string | undefined> => {
35+
try {
36+
const response = await retryTransientEsErrors(
37+
() => esClient.cluster.getComponentTemplate({ name }),
38+
{ logger }
39+
);
40+
const existing = (response?.component_templates ?? []).find((ct) => ct.name === name);
41+
const meta = existing?.component_template?._meta;
42+
return meta?.[RESOURCE_CONTENT_HASH_META_FIELD];
43+
} catch (err) {
44+
// Any failure reading the installed hash (404, permissions, exhausted
45+
// retries) leaves the installed content unknown, which falls through to the
46+
// PUT. The check must never block an install that would otherwise succeed.
47+
logger.debug(
48+
`Could not read installed component template ${name} content hash; will install (${err.message})`
49+
);
50+
return undefined;
51+
}
52+
};
53+
2454
const getIndexTemplatesUsingComponentTemplate = async (
2555
esClient: ElasticsearchClient,
2656
componentTemplateName: string,
@@ -108,8 +138,34 @@ export const createOrUpdateComponentTemplate = async ({
108138
}: CreateOrUpdateComponentTemplateOpts) => {
109139
logger.debug(`Installing component template ${template.name}`);
110140

141+
// Stamp the content hash so a later install can detect an unchanged template
142+
// and skip the cluster-state write. The hash covers the template body only
143+
// (settings + mappings); `_meta` is excluded so it never hashes itself.
144+
const contentHash = computeResourceHash(template.template);
145+
const stampedTemplate: ClusterPutComponentTemplateRequest = {
146+
...template,
147+
_meta: {
148+
...template._meta,
149+
[RESOURCE_CONTENT_HASH_META_FIELD]: contentHash,
150+
},
151+
};
152+
111153
try {
112-
await createOrUpdateComponentTemplateHelper(esClient, template, totalFieldsLimit, logger);
154+
// Skip only on a positive hash match; any missing stamp / error falls through to the PUT.
155+
const installedHash = await getInstalledComponentTemplateHash(esClient, template.name, logger);
156+
if (installedHash === contentHash) {
157+
logger.debug(
158+
`Skipping install of component template ${template.name}; content unchanged (${contentHash})`
159+
);
160+
return;
161+
}
162+
163+
await createOrUpdateComponentTemplateHelper(
164+
esClient,
165+
stampedTemplate,
166+
totalFieldsLimit,
167+
logger
168+
);
113169
} catch (err) {
114170
logger.error(`Error installing component template ${template.name} - ${err.message}`);
115171
throw err;

x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/create_or_update_ilm_policy.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('createOrUpdateIlmPolicy', () => {
3636
jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayMultiplier);
3737
});
3838

39-
it(`should call esClient to put ILM policy`, async () => {
39+
it(`should call esClient to put ILM policy, stamped with a content hash`, async () => {
4040
await createOrUpdateIlmPolicy({
4141
logger,
4242
esClient: clusterClient,
@@ -46,9 +46,95 @@ describe('createOrUpdateIlmPolicy', () => {
4646
});
4747

4848
expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith({
49+
name: 'test-policy',
50+
policy: {
51+
...IlmPolicy,
52+
_meta: {
53+
managed: true,
54+
content_hash: expect.stringMatching(/^[0-9a-f]{16}$/),
55+
},
56+
},
57+
});
58+
});
59+
60+
it(`should skip the PUT when the installed content hash matches`, async () => {
61+
// First install to capture the hash this policy stamps.
62+
await createOrUpdateIlmPolicy({
63+
logger,
64+
esClient: clusterClient,
65+
name: 'test-policy',
66+
policy: IlmPolicy,
67+
dataStreamAdapter,
68+
});
69+
const installedHash = (
70+
clusterClient.ilm.putLifecycle.mock.calls[0][0] as unknown as {
71+
policy: { _meta: { content_hash: string } };
72+
}
73+
).policy._meta.content_hash;
74+
clusterClient.ilm.putLifecycle.mockClear();
75+
76+
clusterClient.ilm.getLifecycle.mockResolvedValue({
77+
'test-policy': { policy: { _meta: { managed: true, content_hash: installedHash } } },
78+
} as unknown as Awaited<ReturnType<typeof clusterClient.ilm.getLifecycle>>);
79+
80+
await createOrUpdateIlmPolicy({
81+
logger,
82+
esClient: clusterClient,
83+
name: 'test-policy',
84+
policy: IlmPolicy,
85+
dataStreamAdapter,
86+
});
87+
88+
expect(clusterClient.ilm.putLifecycle).not.toHaveBeenCalled();
89+
});
90+
91+
it(`should PUT when the installed content hash differs`, async () => {
92+
clusterClient.ilm.getLifecycle.mockResolvedValue({
93+
'test-policy': { policy: { _meta: { managed: true, content_hash: 'stale-hash' } } },
94+
} as unknown as Awaited<ReturnType<typeof clusterClient.ilm.getLifecycle>>);
95+
96+
await createOrUpdateIlmPolicy({
97+
logger,
98+
esClient: clusterClient,
99+
name: 'test-policy',
100+
policy: IlmPolicy,
101+
dataStreamAdapter,
102+
});
103+
104+
expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1);
105+
});
106+
107+
it(`should PUT when the installed policy carries no content hash`, async () => {
108+
clusterClient.ilm.getLifecycle.mockResolvedValue({
109+
'test-policy': { policy: { _meta: { managed: true } } },
110+
} as unknown as Awaited<ReturnType<typeof clusterClient.ilm.getLifecycle>>);
111+
112+
await createOrUpdateIlmPolicy({
113+
logger,
114+
esClient: clusterClient,
49115
name: 'test-policy',
50116
policy: IlmPolicy,
117+
dataStreamAdapter,
51118
});
119+
120+
expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1);
121+
});
122+
123+
it(`should PUT when the installed policy cannot be read`, async () => {
124+
clusterClient.ilm.getLifecycle.mockRejectedValue(new Error('security_exception'));
125+
126+
await createOrUpdateIlmPolicy({
127+
logger,
128+
esClient: clusterClient,
129+
name: 'test-policy',
130+
policy: IlmPolicy,
131+
dataStreamAdapter,
132+
});
133+
134+
expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1);
135+
expect(logger.debug).toHaveBeenCalledWith(
136+
`Could not read installed ILM policy test-policy content hash; will install (security_exception)`
137+
);
52138
});
53139

54140
it(`should retry on transient ES errors`, async () => {

x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/create_or_update_ilm_policy.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
import type { IlmPolicy } from '@elastic/elasticsearch/lib/api/types';
99
import type { Logger, ElasticsearchClient } from '@kbn/core/server';
10+
import { omit } from 'lodash';
1011
import { retryTransientEsErrors } from '../../lib/retry_transient_es_errors';
1112
import type { DataStreamAdapter } from './data_stream_adapter';
13+
import { computeResourceHash, RESOURCE_CONTENT_HASH_META_FIELD } from './resource_hash';
1214

1315
interface CreateOrUpdateIlmPolicyOpts {
1416
logger: Logger;
@@ -17,6 +19,34 @@ interface CreateOrUpdateIlmPolicyOpts {
1719
policy: IlmPolicy;
1820
dataStreamAdapter: DataStreamAdapter;
1921
}
22+
23+
/**
24+
* Reads the content hash stamped in `_meta` on the currently-installed ILM
25+
* policy, or `undefined` if the policy does not exist, carries no stamp, or
26+
* cannot be read.
27+
*/
28+
const getInstalledIlmPolicyHash = async (
29+
esClient: ElasticsearchClient,
30+
name: string,
31+
logger: Logger
32+
): Promise<string | undefined> => {
33+
try {
34+
const response = await retryTransientEsErrors(() => esClient.ilm.getLifecycle({ name }), {
35+
logger,
36+
});
37+
const meta = response?.[name]?.policy?._meta;
38+
return meta?.[RESOURCE_CONTENT_HASH_META_FIELD];
39+
} catch (err) {
40+
// Any failure reading the installed hash (404, permissions, exhausted
41+
// retries) leaves the installed content unknown, which falls through to the
42+
// PUT. The check must never block an install that would otherwise succeed.
43+
logger.debug(
44+
`Could not read installed ILM policy ${name} content hash; will install (${err.message})`
45+
);
46+
return undefined;
47+
}
48+
};
49+
2050
/**
2151
* Creates ILM policy if it doesn't already exist, updates it if it does
2252
*/
@@ -31,8 +61,28 @@ export const createOrUpdateIlmPolicy = async ({
3161

3262
logger.debug(`Installing ILM policy ${name}`);
3363

64+
// Stamp the content hash (over the policy body, excluding `_meta`) so a later
65+
// install can detect an unchanged policy and skip the write.
66+
const contentHash = computeResourceHash(omit(policy, '_meta'));
67+
const stampedPolicy: IlmPolicy = {
68+
...policy,
69+
_meta: {
70+
...policy._meta,
71+
[RESOURCE_CONTENT_HASH_META_FIELD]: contentHash,
72+
},
73+
};
74+
3475
try {
35-
await retryTransientEsErrors(() => esClient.ilm.putLifecycle({ name, policy }), { logger });
76+
// Skip only on a positive hash match; any missing stamp / error falls through to the PUT.
77+
const installedHash = await getInstalledIlmPolicyHash(esClient, name, logger);
78+
if (installedHash === contentHash) {
79+
logger.debug(`Skipping install of ILM policy ${name}; content unchanged (${contentHash})`);
80+
return;
81+
}
82+
83+
await retryTransientEsErrors(() => esClient.ilm.putLifecycle({ name, policy: stampedPolicy }), {
84+
logger,
85+
});
3686
} catch (err) {
3787
logger.error(`Error installing ILM policy ${name} - ${err.message}`);
3888
throw err;

0 commit comments

Comments
 (0)