Configuration internals
This page is the engineering side of Configuration. It assumes you already know the three paths and the resolution order. It documents the machinery that makes them deterministic and cheap.
The contract
Components fall in two categories.
Multi-instance components (Lock, TaskLock, LeaderElection, CircuitBreaker) take a positional name because an application typically holds several of each (Lock("cart"), Lock("checkout")):
| Surface | Form | Intent |
|---|---|---|
__init__(name, **kwargs) |
Positional name + optional fields | Programmatic and environmental construction |
from_config(name, config) |
Positional name + frozen config | Declarative construction from a settings tree |
Single-instance components (HealthChecks, Log, Trace, Metrics, RateLimitFilter, DuplicateFilter, log.configure) drop the positional name because the application typically holds one:
| Surface | Form | Intent |
|---|---|---|
__init__(**kwargs) |
Optional fields only | Programmatic and environmental construction |
from_config(config) |
Frozen config only | Declarative construction from a settings tree |
Variant-driven components (RateLimiter) substitute the __init__ surface with factory classmethods (RateLimiter.token_bucket(name, ...), RateLimiter.sliding_window(name, ...)) but keep from_config(name, config) unchanged.
The Config Pydantic class carries settings only. For multi-instance components the identity lives on the component, never inside the config object. This matches the Map<name, Settings> shape that YAML and pydantic-settings aggregations produce naturally.
resolve_config()
All merging happens once in grelmicro._config.resolve_config():
config = resolve_config(
LockConfig,
explicit=None,
kwargs={"lease_duration": lease_duration, ...},
env_prefix=env_prefix or default_env_prefix("LOCK", name),
env_load=env_load,
)
The function returns a frozen LockConfig. From that point on, the component reads fields off self._config directly. There is no per-call merging, no env lookup, and no validation on the hot path.
Name normalisation
Instance names are normalised before they enter an env prefix so that natural identifiers produce valid POSIX environment variables. The rule, implemented as grelmicro._config.env_segment:
- Upper-case the name.
- Replace any character outside
[A-Z0-9_]with_. - Collapse runs of underscores into one.
- Strip leading and trailing underscores.
A name that produces an empty segment or one starting with a digit is rejected at construction with an actionable error.
The default instance owns the bare prefix
grelmicro._config.default_env_prefix builds the prefix from the component and the name. The default instance drops the name segment, so a Lock("default") reads GREL_LOCK_LEASE_DURATION, not GREL_LOCK_DEFAULT_LEASE_DURATION. A named instance keeps it: Lock("cart") reads GREL_LOCK_CART_LEASE_DURATION.
| Instance | Prefix |
|---|---|
Lock("default") |
GREL_LOCK_ |
Lock("cart") |
GREL_LOCK_CART_ |
The trade-off: the default instance owns the bare GREL_{COMPONENT}_ namespace, so a named instance whose name collides with a field prefix can alias a default field. A Lock("lease") reads GREL_LOCK_LEASE_DURATION, the same key the default instance uses for its lease_duration field. This is rare in practice. The rule: the default instance owns the bare prefix, so name your other instances to avoid field-name collisions.
Hot-path discipline
The config model is a frozen BaseModel with extra="forbid". The hot path holds one reference to that instance and reads attributes off it. This is the budget the design protects:
- Validation runs once at construction, never on a request.
- Env reads happen at construction, never on a request.
- Resolution (kwargs > env > default) materialises into
self._configand is never re-evaluated.
Runtime reconfiguration, when added, will atomically swap the self._config pointer without touching the resolution machinery.
Why we don't copy fields to plain instance attrs
Pydantic model attribute access is measurably slower than a plain instance attribute lookup, because field reads go through Pydantic's customized __getattribute__ and __pydantic_fields__ machinery. The shortcut is to mirror every frozen field onto the component (self._name = self._config.name, ...) so hot paths read self._name. We don't take it, on purpose.
Typical numbers from benchmarks/config_attr_benchmark.py (Issue #113) on a developer laptop:
| Read pattern | ns / field | Ratio |
|---|---|---|
Pydantic attr (self._config.x) |
~11 | 1.5× |
Plain attr (self._x) |
~7 | 1.0× |
| Frozen slotted dataclass | ~8 | 1.2× |
Realistic hot path: RateLimitFilter.filter per log record (~250 ns total):
| ns / call | Share | |
|---|---|---|
| Total call | ~250 | 100% |
| Minus the one config read | ~248 | ~99% |
| Config read cost | ~2 | <1% |
A ~2 ns saving per call disappears in the surrounding dict, lock, and math work. Against that:
- Duplicated state. Each mirrored field stores its value twice (once on the frozen
BaseModel, once in the component's__dict__). Trivial in absolute bytes, but two sources of truth where one would do. - Code surface. 43 hot-path reads across 5 modules become 43 mirror copies plus a duplication rule every new field has to follow.
- Desync risk. A contributor who updates the Pydantic field and forgets the mirror introduces silent drift between
cb.config.xand the cachedcb._x.
We keep self._config as the single source of truth. If a future profile shows config attribute access on the critical path of a tight loop where it actually matters, revisit per-call site, not as a sweeping refactor.
Why from_config skips the env layer
from_config(name, cfg) is the declarative path. The caller has already merged whatever sources they want (YAML, Vault, pydantic-settings). Re-reading env on top would silently invert the priority and make composition non-deterministic. The contract is: what you pass is what runs.
Where Config classes live
| Class | Module |
|---|---|
LockConfig |
grelmicro.coordination.lock |
TaskLockConfig |
grelmicro.coordination.tasklock |
LeaderElectionConfig |
grelmicro.coordination.leaderelection |
CircuitBreakerConfig |
grelmicro.resilience.circuitbreaker |
RateLimiterConfig (discriminated union) |
grelmicro.resilience.ratelimiter |
RateLimitFilterConfig |
grelmicro.log |
DuplicateFilterConfig |
grelmicro.log |
HealthChecksConfig |
grelmicro.health |
LogConfig |
grelmicro.log |
Each is a BaseModel, frozen=True, extra="forbid". Field docs live in Annotated[T, Doc("...")] blocks and surface in IDEs and the API reference.
Related
- Configuration: the user-facing guide for the three paths, prefix table, and recipes.
- Backend Registry: companion contract for runtime-pluggable backends.