perf(constructor): carry the applications SPOT into the bootstrap roles

The constructor parked no applications carrier between the merge block
and the token store, so every lookup in the svc-swarm-node, svc-storage,
svc-registry and svc-virt bootstrap rendered the SPOT cold, 25 to 30 s
each on a loaded host.

A carrier there was unsafe: in swarm, svc-storage-nfs-server mounts the
NFS export over the controller's DIR_VAR_LIB, so the tokens file the
render reads can appear in the middle of that section, and the cache key
did not notice. merged_applications_cache_key now includes the tokens
file's path, mtime and size, as the users cache key already did, so a
carrier rejects itself once the tokens change. A carrier parked after
the add_host tasks then serves the bootstrap roles. It is parked only in
swarm mode: in compose the section runs in about a second, so a carrier
there would add a render without saving one.

In a swarm drill of web-app-prometheus up to the known NFS probe-write
failure, "Resolve the NFS server backend flavor" went from 29.6 s to
0.07 s, "Probe-mount the NFS export" from 26.4 s to 0.4 s, "Mount
cluster-shared infinito state on controller" from 23.7 s to 0.5 s and
"Ensure Docker-In-Docker DNS works" from 30.0 s to 3.6 s; the run to
that point took 768 s instead of 1735 s.

Closes #925

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-15 03:21:17 +02:00
1 parent 2e710b35f7
commit 778c18fb23
4 files changed
+42 -12

No files matched your search

+6 -8
View File
@@ -10,14 +10,12 @@ instead of rendering::
- name: 🧊 Render the applications SPOT once and carry it into every task's worker
applications_carrier: {}
``tasks/stages/01_constructor.yml`` parks two of them. The stage-wide one
sits after the token store so the embedded user records already carry the
loaded tokens, and after the ``add_host`` tasks because 114 roles gate
services on ``group_names`` and the payload bakes those in. The
merge block opens an earlier, block-scoped one and drops it again at the
block's end, because ``merged_applications_cache_key`` reads neither
``group_names`` nor the token file: a carrier left standing across either
would be served unchanged where a fresh render belongs::
``tasks/stages/01_constructor.yml`` parks three of them: a block-scoped one
for the merge block, which it drops again at the block's end, one after the
``add_host`` tasks for the swarm bootstrap roles, parked only when
``DEPLOYMENT_MODE`` is ``swarm``, and the stage-wide one after the token store. ``merged_applications_cache_key`` reads ``group_names`` and the
tokens file's path, mtime and size, so a carrier whose groups or tokens no
longer match is rejected and the reader renders afresh::
- name: 🧊 Drop the merge-block carrier so later stages re-render
applications_carrier:
+4
View File
@@ -161,6 +161,10 @@
run_once: true
changed_when: false
- name: 🧊 Render the applications SPOT once for the bootstrap section
applications_carrier: {}
when: DEPLOYMENT_MODE == 'swarm'
- name: "🥾 Bootstrap svc-swarm-node"
ansible.builtin.include_role:
name: svc-swarm-node
+18
View File
@@ -389,6 +389,24 @@ class TestGetMergedApplicationsCarrierFact(unittest.TestCase):
merged["web-app-foo"]["services"]["foo"]["image"], "override"
)
def test_a_changed_tokens_file_rejects_the_carrier(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
tokens = Path(tmp) / "tokens.yml"
tokens.write_text("{}\n", encoding="utf-8")
variables = self._variables()
variables["FILE_TOKENS"] = str(tokens)
key = merged_applications_cache_key(variables, roles_dir=roles)
variables[APPLICATIONS_RENDERED_FACT] = {
"key": [key[0], list(key[1])],
"applications": {"web-app-foo": {"carried": True}},
}
tokens.write_text("users: {}\n", encoding="utf-8")
merged = cache_apps.get_merged_applications(
variables=variables, roles_dir=roles, templar=None
)
self.assertNotIn("carried", merged["web-app-foo"])
def test_malformed_carrier_is_ignored(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_roles(Path(tmp))
+14 -4
View File
@@ -13,7 +13,12 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from .base import _cache_key, _resolve_roles_dir, _stable_variables_signature
from .base import (
_cache_key,
_resolve_roles_dir,
_stable_variables_signature,
_tokens_file_signature,
)
if TYPE_CHECKING:
import os
@@ -30,16 +35,21 @@ def merged_applications_cache_key(
Args:
variables: The play's variable scope. Only the subset that
`_stable_variables_signature` reads influences the key.
`_stable_variables_signature` reads, and the tokens file it
resolves to, influence the key.
roles_dir: Roles tree the payload is built from; defaults to the
repository's `roles/`.
Returns:
``(resolved roles dir, stable variables signature)``.
``(resolved roles dir, stable variables signature extended by the
tokens file's path, mtime and size)``.
"""
from .users import _resolve_tokens_file
tokens = _tokens_file_signature(_resolve_tokens_file(variables))
return (
_cache_key(_resolve_roles_dir(roles_dir=roles_dir)),
_stable_variables_signature(variables),
(*_stable_variables_signature(variables), *(str(part) for part in tokens)),
)