Repository navigation
Releases: kidoz/hostloom
Release list
HostLoom 0.16.0
The seventeenth release of HostLoom. Its headline is a new package family — lease-based node-ID leasing over the distributed lock, for the services that must tell their instances apart by a small integer, a Snowflake-style worker ID or a shard slot, without ever letting two holders share one. Alongside it, a mapping registration a host may reach twice without failing, a bound on the queue a Redis invalidation subscription fans into, and a core package that no longer draws the ASP.NET Core shared framework.
HostLoom.NodeLeasing leases a pool of small integer IDs to the instances of a service. One elector per pool hands out the IDs zero to NodeLeasing:PoolSize minus one, scanning with the ID it last held first, for stickiness across a restart, then ascending from a random offset, and taking the first ID a skip-if-busy acquisition grants on nodeid:{pool}:{id}. The holder renews explicitly — a renewal the backend refuses or a lease that runs out steps it back to candidate at once — and an assignment token bounds what it does with a belief that outlives the lease. The guarantee is stated as narrowly as it is kept: at most one holder per ID while clocks and the lock backend behave, with a window on expiry bounded by NodeLeasing:Lease plus clock skew, and no fencing. HostLoom.NodeLeasing.DependencyInjection registers pools keyed by name with options validation and hosting, and HostLoom.NodeLeasing.Testing ships the scripted INodeLeasing double for consumers that only need to know which ID they were handed.
MappingBuilder.TryAdd<TMapper>, its explicit-pair overload, and the TryAddUpdate counterparts close the composition gap in mapping registration: when a host composes several builders and one shared registration is reached more than once, a pair already registered by the same map class — with its lifetime — is left in place, while a different implementation for that pair is still rejected as a duplicate. The factory and instance overloads keep no TryAdd counterpart, because a registration built from a runtime object cannot be recognized as the same map on a second call.
HostLoom.Redis bounds the queue every invalidation subscription fans into. That queue was previously unbounded: a server publishing faster than the local reader could apply messages — a chatty keyspace, a stalled event loop — grew memory without limit and without a signal. The queue now holds Caching:Invalidation:MaxPending messages and drops the newest undelivered one past that bound, reporting each drop on the new DroppedMessages property and the hostloom.redis.invalidation.dropped counter. The in-process entries a dropped message would have removed stay until they expire, the same trade the cache's own pending queue already makes.
HostLoom no longer references the Microsoft.AspNetCore.App shared framework. The kernel never called an ASP.NET Core API — it consumes only hosting, dependency-injection, logging, options, and health-check abstractions — and it now references exactly those five Microsoft.Extensions.* packages, so a worker or console consumer no longer resolves the ASP.NET Core runtime. Applications that host on ASP.NET Core already reference the framework themselves and are unaffected. The core package still makes no Native AOT claim.
Upgrading from 0.15.1 changes no signature an application compiles against. The one dependency-level change is HostLoom's framework reference, and an application that hosts on ASP.NET Core references the framework itself, so it is unaffected; a worker or console consumer simply stops resolving a runtime it never used. HostLoom.NodeLeasing, HostLoom.NodeLeasing.DependencyInjection, and HostLoom.NodeLeasing.Testing are published for the first time; the remaining packages carry the changes above and nothing else. @hostloom/websocket-client is not part of this NuGet set and has no changes since its 0.3.0 release.
Added
HostLoom.NodeLeasing, lease-based node-ID leasing over the distributed lock: a pool of small integer IDs (Snowflake-style worker IDs), one elector per pool with a sticky scan and explicit renewal, an assignment token, and the honest guarantee statement (at most one holder per ID bounded by the lease plus clock skew, no fencing);HostLoom.NodeLeasing.DependencyInjection, pools keyed by name with options validation and hosting; andHostLoom.NodeLeasing.Testing, the scriptedINodeLeasingdouble.HostLoom.Redisreports invalidation messages its subscription fan-in drops under load: aDroppedMessagesproperty onRedisCacheInvalidationChanneland ahostloom.redis.invalidation.droppedcounter tagged by namespace.MappingBuilder.TryAdd<TMapper>andTryAdd<TSource, TDestination, TMapper>, withTryAddUpdatecounterparts, for a registration a host may reach twice: a pair already registered by the same map class is left in place — with its lifetime — while a different implementation for the same pair is still rejected as a duplicate. The factory and instance overloads keep noTryAddcounterpart, because a registration built from a runtime object cannot be recognized as the same map on a second call.
Changed
- The
HostLoom.Redisinvalidation fan-in queue is bounded byCaching:Invalidation:MaxPendinginstead of growing without limit. A Redis instance publishing faster than the local reader can apply now drops the newest undelivered message and reports each drop, and the entries it would have removed stay in the in-process tier until they expire — the same trade the cache's own pending queue already makes. HostLoomreferences the fiveMicrosoft.Extensions.*packages it actually uses instead of theMicrosoft.AspNetCore.Appshared framework, so worker and console consumers no longer pull in the ASP.NET Core runtime. Applications that host on ASP.NET Core are unaffected.
HostLoom 0.15.1
The sixteenth release of HostLoom. Nothing in it changes a signature an application compiles against: it changes what the packages let a reader see, how the Redis adapters resolve their connection when an application registers a multiplexer beside them, and which versions the whole set builds on.
HostLoom.Caching no longer discards an explicit write. A SetAsync that overlapped an invalidation — routine while a Redis or Valkey subscription is being established, because the first acknowledged subscription raises a flush — was a silent no-op: the value reached neither tier, no error surfaced, and the next read missed without reporting degradation. An explicit set now always reaches the distributed tier, as SetIfAbsentAsync and WarmupAsync already did; only the in-process commit stays generation-guarded, and a factory's result is still discarded when an invalidation overlaps the fill.
Every framework package except HostLoom.Analyzers now ships an XML documentation file. Generation is enabled for every project beneath src/ except that package and the composition generator compiler, whose assemblies run inside a build rather than in an application, and the API comments are completed where parameters went undocumented, so IntelliSense and documentation tooling surface the same remarks the sources already carried. Nothing is enforced by it: there is no documentation-coverage gate, CS1591 stays suppressed for the members that remain undocumented, and no member gained or lost a signature.
UseRedis composes RedisCacheStore and RedisLockProvider over the RedisConnection the caching and locking builders share. Both types also offer a constructor for an application-supplied IConnectionMultiplexer, so registering a multiplexer beside HostLoom left two resolvable constructors for each; the registration now names the shared connection, both adapters retain the configured Redis options, and an application no longer has to replace the lock-provider registration by hand. A store or provider that an application registered itself is still used as it is.
The pinned dependencies move forward, with one hold: Confluent.Kafka stays at 2.15.1 because 2.16.0 fails Kafka topic administration against Apache Kafka 3.9, which the transport integration suite reproduces on every run. HostLoom.Valkey advertises the Native AOT compatibility it already declared through the aot package tag.
No package is published for the first time; the whole set publishes at 0.15.1. @hostloom/websocket-client is not part of this NuGet set and has no changes since its 0.3.0 release.
Added
- Every shipped framework package now carries its XML documentation file. Documentation generation is enabled for all source projects except the analyzers and the composition generator compiler, and the existing API comments are completed where parameters went undocumented, so IntelliSense and documentation tooling surface the same remarks the sources already carried.
Changed
- Update
MessagePackfrom 3.1.10 to 3.1.11, including its upstream fix for an LZ4 decompression integer overflow (GHSA-7rj2-5w9w-r9jx),StackExchange.Redisfrom 3.3.1 to 3.4.0, andxunit.v3from 4.0.1 to 4.0.2.Confluent.Kafkastays at 2.15.1. HostLoom.Valkeyadvertises its Native AOT compatibility through theaotpackage tag, matching itsIsAotCompatibledeclaration.
Fixed
TieredCachewrites an explicitSetAsyncto the distributed tier even when an invalidation moves the key's generation between the write's capture and its commit. The write was previously discarded entirely — the value reached neither tier, nothing was reported, and a later read missed without degrading. The flush an invalidation channel raises as a subscription is established is enough to hit it, which is how the container-built Valkey cache exposed it. A factory result is still discarded when an invalidation overlaps the fill, and the in-process commit stays generation-guarded.HostLoom.Redisexplicitly constructsRedisLockProviderandRedisCacheStoreover the sharedRedisConnectionwhen selected throughUseRedis. Registering anIConnectionMultiplexeralongside HostLoom no longer causes ambiguous-constructor failures. Both adapters retain the configured Redis options, and applications no longer need to replace the lock-provider registration manually. Existing caller-provided instances and factories remain in use.
HostLoom 0.15.0
The fifteenth release of HostLoom. One thread runs through it, and it is a new package: secure generators for the values a service hands to people, such as confirmation codes, coupon codes, invoice identifiers, order IDs, tokens, and nonces.
HostLoom.Generators is dependency-free (pure BCL) and trimming- and Native AOT-compatible, and it collects the string and code generators a service otherwise hand-rolls, where the usual defects are subtle: modulo bias in character picking, off-by-one bounds, formatting that depends on the current culture, and codes that do not survive their own normalisation. It provides exact-length random strings over versioned preset alphabets (Digits, HexLower, HexUpper, AlphaNumeric, UrlSafe, and a thirty-one-character HumanReadable that drops the visually ambiguous glyphs); fixed-width numeric codes that keep their leading zeros, with a legacy maxExclusive adapter and a NumericIdProfile for identifiers that never start with zero; human-readable coupon codes with position-aware check digits and a Normalize/TryValidate pair where a generated code always survives validation of its normalized form; opaque Base64Url tokens and hex nonces; exact-length prefixed identifiers that reject an unacceptable prefix rather than sanitize it; dashed and compact GUID string identifiers; DistinctBatch for batch-local de-duplication after canonicalization; and SecureChoice for unbiased choice. Every generator draws from a cryptographic random number generator behind the explicit IRandomSource entropy seam, so a test can substitute deterministic entropy instead of reaching into production randomness.
The guarantees are stated as narrowly as the implementation. No generator promises uniqueness: two calls can return the same value, durable uniqueness stays an atomic constraint in the owning service, and DistinctBatch de-duplicates only within one batch. Generated secrets must never be logged, and the package's own Native AOT sample prints lengths rather than values. Output formatting uses the invariant culture only. And this is not a drop-in Powell.CouponCode replacement: the pinned 1.0.3 artifact's alphabet, checksums, and grouping behavior are unverified, and old codes are never regenerated. The companion HostLoom.Generators.Testing package ships the deterministic fakes of the seam — SeededRandomSource for repeatable shapes, ScriptedRandomSource, which forces exact draws and fails loudly when under-scripted, and AsyncNumericSequence — with no dependency on a test framework.
Upgrading from 0.14.0 changes nothing an application already references: no existing package changes.
HostLoom.Generators and HostLoom.Generators.Testing are published for the first time; the rest of the set is unchanged. @hostloom/websocket-client is not part of this NuGet set; that package carries its own version and ships when a websocket-client-vX.Y.Z tag is published, and it has no changes since its 0.3.0 release.
Added
HostLoom.Generators, secure random strings, numeric codes, coupon codes, opaque tokens, and constrained identifier generators, andHostLoom.Generators.Testing, the deterministicIRandomSourcefakes for tests.
HostLoom 0.14.0
The fourteenth release of HostLoom. Two threads run through it, plus a new sample: what HostLoom.Logging captures and protects, and six lifecycle defects outside logging that could stop work silently.
The first thread is logging fidelity. A dictionary key written through a destructured hole was rendered by calling ToString() on the key, so a key of a type whose members are masked or excluded appeared in full while the same type used as a value was protected; complex keys now go through the same masking plan as values and use their protected JSON rendering as key text, subject to the destructuring caps and cycle detection, while scalar keys keep their invariant text. A member protected by more than one rule — a Mask<T> rule and a masking attribute, or a native and a legacy attribute — kept only one of them, and which one survived depended on registration order; the rules now combine, each end reveals the smallest count any rule allows, every rule's short-value guard remains effective, different replacement texts resolve to the ordinal minimum, and exclusion still wins. Int128, UInt128, Half, nint/nuint, and BigInteger are written as JSON numbers on every capture path, like the other numeric scalars; in a {@...} hole one used to become {} or an object of its flag properties, and as a dictionary key it lost its digit spelling. A quoted CLEF @r rendering escapes the reverse solidus and control characters as well as the quotation mark, so a rendering is a string literal a JSON reader can hand back. A character-counted cap (MaxStringLength and the exception cap) no longer splits a surrogate pair at its cut: an emoji straddling the boundary loses its lead unit instead of producing half a pair. A template larger than MaxMessageLength no longer captures or retains formatted-token renderings that CLEF cannot emit after the template is discarded. And a finite logging enqueue, flush, shutdown, or process-exit flush timeout of about 24.8 days or longer no longer becomes an infinite wait; long waits use finite intervals and recalculate their remaining budget, while a null EnqueueTimeout still waits without limit.
The second thread is lifecycle defects that stopped work without a word. HostLoom.Redis invoked cache-invalidation handlers inside the channel gate, so a handler that subscribed, detached its own subscription, or disposed the channel deadlocked the non-reentrant gate on the channel's single reader thread and silently stopped all invalidation for the process; handlers now run outside the gate, as the Valkey channel already did. Redis lock acquire and extend truncated a positive sub-millisecond lease to PEXPIRE 0, deleting the lock while reporting success; leases now round up to whole milliseconds, as the Valkey adapter already did. LeaderElector.ResignAsync threw ObjectDisposedException from the term's disposed cancellation source when leadership ended concurrently with the call; it now completes gracefully. A schedule's timeout timer that fired after its run scope had ended threw ObjectDisposedException on a timer thread, which terminated the process; the late timer is now ignored. An outbox PollInterval beyond the Task.Delay ceiling of about 49.7 days passed validation, and the first poll wait then faulted the relay loop, leaving messages pending until a restart; the interval is clamped to the ceiling. TieredCache subscribed to its invalidation channel after starting the invalidation loop, so a Subscribe that threw left the constructor to propagate while the loop task parked forever, rooting the whole cache graph for the process lifetime; it now subscribes before the loop starts.
The repository also gains examples/HostLoom.Examples.MappingAot, a self-checking Native AOT sample that publishes the mapping packages natively and verifies closed-map injection, the scoped IMapper dispatcher, MapMany and MapOrNull, and an update map in the produced binary.
Upgrading from 0.13.0 changes behaviour an application can observe; each change is stated under Fixed. MessagePack moves from 3.1.9 to 3.1.10 for its upstream security fix against catastrophic regular-expression backtracking.
No package is published for the first time; the set is the same as 0.13.0. @hostloom/websocket-client is not part of this NuGet set; that package carries its own version and ships when a websocket-client-vX.Y.Z tag is published, and it has no changes since its 0.3.0 release.
Added
examples/HostLoom.Examples.MappingAot, a self-checking Native AOT sample that publishes the mapping packages natively and verifies closed-map injection, the scopedIMapperdispatcher,MapManyandMapOrNull, and an update map in the produced binary.
Changed
- Update MessagePack from 3.1.9 to 3.1.10, including its upstream security fix for catastrophic regular-expression backtracking.
Fixed
HostLoom.Loggingprotects complex dictionary keys in{@...}holes through the same masking plan as values, instead of callingToString()and exposing excluded or masked members. These keys now use their protected JSON representation as key text, subject to destructuring caps and cycle detection; scalar keys keep their invariant text.- Applicable
Mask<T>rules and native or legacy masking attributes combine independently of registration order. Each end reveals the smallest allowed count, every rule's short-value guard remains effective, and different replacement texts resolve to the ordinal minimum. Exclusion always wins, including when a mask is registered later for the same type and member. - Templates larger than
MaxMessageLengthno longer capture or retain formatted-token renderings that CLEF cannot emit after the template is discarded. Safe message rendering and captured fields remain available within their configured caps. - Finite logging enqueue, flush, shutdown, and process-exit flush timeouts of about 24.8 days or longer no longer become infinite waits. Long waits use finite intervals and recalculate their remaining budget; a null
EnqueueTimeoutstill waits without limit. HostLoom.LoggingwritesInt128,UInt128,Half,nint/nuint, andBigIntegeras JSON numbers on every capture path, like the other numeric scalars. A{@...}hole of one used to become{}or an object of its flag properties, and a dictionary key of one lost its digit spelling.- A quoted CLEF
@rrendering escapes the reverse solidus and control characters, not only the quotation mark, so a rendering is a string literal a JSON reader can hand back. - Character-counted logging caps (
MaxStringLengthand the exception cap) no longer split a surrogate pair at the cut: an emoji straddling the boundary loses its lead unit instead of producing half a pair. HostLoom.Redisinvokes invalidation handlers outside the channel gate, as the Valkey channel already did. A handler that subscribed, detached its own subscription, or disposed the channel deadlocked the non-reentrant gate on the channel's single reader thread and silently stopped all invalidation for the process.- Redis lock acquire and extend round a lease up to whole milliseconds. A positive sub-millisecond lease used to truncate to
PEXPIRE 0, deleting the lock while reporting success; the Valkey adapter already rounded up. LeaderElector.ResignAsynccompletes gracefully when leadership ends concurrently with the call, instead of throwingObjectDisposedExceptionfrom the term's disposed cancellation source.- A schedule's timeout timer that fires after its run scope has ended no longer throws
ObjectDisposedExceptionon a timer thread, which terminated the process. - An outbox
PollIntervalbeyond theTask.Delayceiling of about 49.7 days is clamped to it. Validation accepted the value, and the first poll wait then faulted the relay loop, leaving messages pending until a restart. TieredCachesubscribes to its invalidation channel before starting the invalidation loop. ASubscribethat threw left the constructor to propagate while the loop task parked forever, rooting the whole cache graph for the process lifetime.
HostLoom 0.13.0
The thirteenth release of HostLoom. Two threads run through it, both in HostLoom.Logging. The first is choosing the output format in configuration. A service moving from Serilog configured its console sink and CompactJsonFormatter in appsettings, but HostLoom.Logging took its formatter only in code, so the format could not differ between environments without a rebuild and a shared logging wrapper had to hard-code it. HostLoomLoggerOptions.Formatter now names the format, "Json" or "Clef", matched without regard to case and available as LogFormatterNames constants, and MaxExceptionLength sets the exception cap of the formatter created from the name. A new AddHostLoomLogging(IConfiguration, Action<HostLoomLoggerOptions>?) overload takes everything from configuration and writes to standard output, so "Formatter": "Clef" under HostLoom:Logging and one registration line replace a Serilog WriteTo console sink with CompactJsonFormatter. A formatter passed in code still takes precedence over the name, and the bootstrap logger honours the name too. Wrappers had no way to set defaults that configuration could override, because the configuration overloads apply their callback after binding, so they bound the options themselves and bypassed HostLoom's strict binding; HostLoomLoggerOptions.Bind(IConfiguration) now applies configuration with the same strict rules over whatever the wrapper set first. The Native AOT logging sample chooses its format from configuration, and its native binary checks the result.
The second thread is configuration and registration that were ignored without a word. The strict binder counted Enrichers and TimeProvider as known keys but could bind neither, so an enricher list under HostLoom:Logging:Enrichers passed startup and did nothing, the same silent skip that hides a Serilog enricher whose package is missing; any key under either name now fails startup and says to set it in code. A second AddHostLoomLogging call on the same service collection was deduplicated away together with its sink, formatter, and options, so a test host that registered its own sink over the application's received nothing; it now throws, and ClearProviders() before it replaces the first registration. Options are also validated when AddHostLoomLogging runs, so an out-of-range value or an unknown formatter name fails at the line that registered it instead of when logging is first resolved.
Upgrading from 0.12.0 changes behaviour an application can observe; each change is stated under Changed or Fixed. A second AddHostLoomLogging call on the same service collection throws instead of being ignored along with its sink, formatter, and options; call ClearProviders() first to replace a registration. Options are validated when AddHostLoomLogging runs, so an out-of-range value fails at that call rather than when logging is first resolved. Configuration that sets Enrichers or TimeProvider fails startup. An application that names no formatter, in configuration or in code, keeps writing JSON from the provider and CLEF from the bootstrap logger.
No package is published for the first time; the set is the same as 0.12.0. @hostloom/websocket-client is not part of this NuGet set; that package carries its own version and ships when a websocket-client-vX.Y.Z tag is published, and it has no changes since its 0.3.0 release.
Added
HostLoomLoggerOptions.Formatterchooses the output format by name, so configuration can set it:"Json"or"Clef", matched without regard to case, with the names also available asLogFormatterNamesconstants. Unset, the provider writes JSON and the bootstrap logger CLEF, as before. A formatter passed toAddHostLoomLoggingin code takes precedence over the name.MaxExceptionLengthsets the exception cap of the formatter created from the name.- An
AddHostLoomLogging(IConfiguration, Action<HostLoomLoggerOptions>?)overload that takes everything from configuration, the format included, and writes to standard output. HostLoomLoggerOptions.Bind(IConfiguration)applies configuration with the strict rules of the configuration overloads. A wrapper that sets defaults before calling it lets configuration override them, which the configuration overloads, applying their callback afterwards, cannot.
Changed
- A second
AddHostLoomLoggingcall on the same service collection throws. It used to be ignored along with its sink, formatter, and options; callClearProviders()first to replace a registration. AddHostLoomLoggingvalidates the options when it runs, so an out-of-range value fails at the registering call instead of when logging is first resolved.
Fixed
- Configuration that sets
EnrichersorTimeProviderunder the options section fails startup. The strict binder accepted both names but could bind neither, so a configured enricher list was ignored without a word.
HostLoom 0.12.0
The twelfth release of HostLoom. Four threads run through it, three of them in HostLoom.Logging. The first is output that a service moving from Serilog can keep. Its log queries were written against the records of Serilog's CompactJsonFormatter, and the provider differed in ways such a query notices: a collection in a plain hole such as {Ids} was written as its type name, a byte array was Base64 when destructured and its type name otherwise, the JSON writer trimmed trailing zeros from @t so a fixed-pattern timestamp parser rejected it, standard ILogger calls had no @r, destructured objects carried no "$type", and a caller's hole named SourceContext, ThreadId, or EventId lost to the formatter's own. Collections now keep their structure, byte arrays are uppercase hex with Serilog's summary beyond 1,024 bytes, @t carries all seven fractional digits, @r holds one invariant rendering per formatted token, the opt-in DestructuringOptions.TypeTags adds "$type", IncludeFields can leave public fields out as Serilog does, the caller's holes win, and string-keyed dictionaries and (string, value) tuples passed to BeginScope become fields. The same work closed paths that wrote protected values: destructuring read a property with a private getter such as public string Password { private get; set; }, walked a delegate's closure through Target, and rendered @r for {@Order:j} from the value's own ToString(), which printed members that [NotLogged] and [LogMasked] hide. It now reads public getters only, writes delegates and reflection objects as their names, and renders @r from the masked JSON, and a legacy LogReplaced attribute masks its member whole instead of being ignored.
The second thread is records that were lost and log calls that failed. A ToString() that threw in a plain hole, MEL's own formatter throwing, or a template with fewer arguments than holes escaped the log call, and LoggerFactory rethrew it to the caller; the value now reads "[DestructuringFailed]", what was captured before the failure is kept, and the failure is counted. With every thread-pool thread busy, a queued record never reached the sink and neither EnqueueTimeout nor ShutdownTimeout fired, measured with 100 ms bounds over 10 s; producers now wake the writer directly, a blocked caller waits on a monitor the writer signals, and the shutdown deadlines run on a thread of their own. One transient IOException from a sink used to fault the pipeline and drop every later record; it now costs the batch being written, counted as sink_failed. Environment.Exit, Main returning, and an unhandled exception ended the process around the background writer and lost up to a full queue, the last lines before a crash among them; the provider now flushes from AppDomain.ProcessExit and AppDomain.UnhandledException within ShutdownTimeout, and a crash handler registered after it can call the new HostLoomLoggerProvider.Flush. Since .NET 10 a SIGTERM to an application without the Generic Host raises no event at all, which the documentation now states. A stdout that nobody read stalled every thread that logged a warning for as long as it stalled, so EnqueueTimeout now defaults to one second. Records with thousands of fields, oversized field names, and LogFast values that refuse every buffer are bounded before they cost the caller or the writer; a nested destructuring failure replaces only the failing value; the built-in formatters are safe to share between providers; returned log entries no longer keep exception graphs alive; shutdown counts every abandoned record exactly once; concurrent disposals await one shutdown; and StreamLogSink releases an owned stream even when its final flush throws.
The third thread is new surface and Native AOT. AddHostLoomLogging accepts a Func<IServiceProvider, ILogSink>: each container built from the service collection calls it when it first resolves logging and gets a sink of its own, where a sink instance is shared by all of them, and a call site that creates its sink there no longer needs a CA2000 suppression. HostLoom.Logging is now annotated for trimming and Native AOT. Under Native AOT the configuration overloads failed at startup, because the reflection binder found no options and counted every key as unknown; the binding is now compiled. Destructuring wrote every type a native application had not preserved as an empty object, with no signal; DestructuringOptions.Preserve<T>() keeps a type's members, NotLogged<T> and Mask<T> now keep theirs, and a type still left without members is counted once under component=destructurer. A legacy LogMasked attribute whose options a native application cannot read masks its member whole instead of failing the whole object. examples/HostLoom.Examples.LoggingAot publishes natively, checks its own output, and runs in the release workflow next to the Valkey sample.
The fourth thread is metrics across the framework. Every histogram now advises its bucket boundaries: operation durations from 0.1 ms to 30 s, spans and lags from 1 ms to a day, and WebSocket queue bytes from 256 B to 16 MiB. An exporter's defaults, which end at 10 000, put every sub-millisecond cache hit into one bucket.
Upgrading from 0.11.0 changes behaviour an application can observe; each change is stated under Changed or Fixed. EnqueueTimeout defaults to one second, so a record that waits longer for room on a full queue is dropped and counted as enqueue_timeout; set it to null to wait without limit. A sink failure drops the batch being written as sink_failed and logging goes on, and writer_fault now means a defect in the writer itself. Log stores see new field shapes: a collection or dictionary in a plain hole keeps its structure, byte arrays and byte memory are uppercase hex, LogFast through an injected logger keeps numeric and boolean types instead of strings, and string-keyed dictionaries and (string, value) tuples passed to BeginScope become fields. Destructuring reads only public getters and writes delegates and reflection objects as their names. A custom ILogFormatter must allow Format to run concurrently, because the formatter passed to AddHostLoomLogging is shared by the provider of every container. A Native AOT application preserves the types it writes with {@...} through DestructuringOptions.Preserve<T>(). Exported histograms get new bucket boundaries. No package is published for the first time.
No package is published for the first time; the set is the same as 0.11.0. @hostloom/websocket-client is not part of this NuGet set; that package carries its own version and ships when a websocket-client-vX.Y.Z tag is published, and it has no changes since its 0.3.0 release.
Added
- Every histogram advises its bucket boundaries: operation durations from 0.1 ms to 30 s, spans and lags from 1 ms to a day, and WebSocket queue bytes from 256 B to 16 MiB. An exporter's defaults, which end at 10 000, put every sub-millisecond cache hit into one bucket.
DestructuringOptions.TypeTagsadds Serilog's"$type"member, the type's short name, to every destructured object except anonymous and other compiler-generated types. Off by default.DestructuringOptions.IncludeFields, on by default, can leave public fields out of destructured objects, as Serilog does; value tuples are written as sequences of their items either way.ClefLogFormatterwrites@rfor standardILoggercalls: one rendering per template token that has a format, in template order, rendered in the invariant culture the way Serilog'sCompactJsonFormatterrenders it.LogRecord.TemplateRenderingsexposes them to other formatters.AddHostLoomLoggingoverloads that take aFunc<IServiceProvider, ILogSink>. Each container built from the service collection calls the factory when it first resolves logging and so gets a sink of its own, where a sink instance is shared by all of them. A call site that creates its sink in the factory no longer needs a CA2000 suppression.HostLoomLoggerProvider.Flush(TimeSpan)waits until every record logged before the call is written and the sink flushed, for a crash handler registered after the provider: handlers run in registration order, so such a handler logs after the provider's own exit flush.HostLoom.Loggingis annotated for trimming and Native AOT, andexamples/HostLoom.Examples.LoggingAotpublishes natively and checks its own output.DestructuringOptions.Preserve<T>()keeps a type's members for{@...}in such an app, asNotLogged<T>andMask<T>now do for theirs. Under Native AOT a destructured type without readable members is counted once undercomponent=destructurerinstead of passing unnoticed.
Changed
- A collection or dictionary in an event hole without an operator (
{Ids}) is captured with its structure, as Serilog captures it, instead of as its type name. An element that is neither a scalar nor a collection is written as its invariantToString(), and the destructuring caps and record budget apply. - A byte array is written as Serilog's uppercase hex in every hole, summarized as its first 16 bytes and its length beyond 1024 bytes. A destructured byte array was Base64, and one in a plain hole was its type name.
EnqueueTimeoutdefaults to one second instead of no limit. A log call that waits for room on a full queue, underBlockor for Warning and above underDropBelowWarning, drops its record asenqueue_timeoutafter a second instead of waiting for as long as the sink stalls; a stdout nobody read stalled every thread that logged a warning. Set it to null to wait without limit.- A sink failure costs the batch being written instead of stopping logging for the rest of the process. The batch's records are counted as dropped with the new reason
sink_failed, and the writer goes on with the next batch, so a sink that rec...
HostLoom 0.11.0
The eleventh release of HostLoom. Five threads run through it. The first is delivery that could quietly go wrong. The idempotent inbox recorded a delivery's key before its handlers ran and never released it, so a handler failure, a cancellation during a rolling deploy, or an inbox placed inside UseRetry turned the broker's redelivery into an acknowledged duplicate and lost the event; a failed or cancelled run now releases its key through the new IInboxStore.ReleaseAsync, and on a real Kafka broker 500 events with 148 failing first attempts were all handled exactly once, where 0.10.0 lost exactly those 148. An envelope whose messageType was JSON null passed validation and failed the handler lookup as an argument error, which a Kafka event subscription retried forever and so stalled its partition; missing message and response types and incomplete faults are now rejected as malformed and skipped as poison. Kafka replicas configured with one ClientId shared a reply group and split the response partitions, so most of their requests timed out; the reply group is now named per instance. The outbox relay used to charge every pending message an attempt on every drain during a broker outage, so a 70-second RabbitMQ outage dead-lettered 607 of 1,000 events; it now stops a drain at the first failed publish, backs off as a whole, probes with one message, and republishes what it holds as soon as a publish succeeds, which took the same outage to no dead letters and 12 failed attempts. A failed mark after a successful publish is no longer counted as a failed attempt, a second inbox or outbox registration throws instead of silently doubling or dropping, a request that a receive filter completes without its handler is answered with a HandlerNotRun fault, failed event handlers are counted as faults, and host shutdown waits no longer than the host allows.
The second thread is the RabbitMQ and Kafka transports. Stopping a RabbitMQ listener or subscription waited for the broker to confirm each channel close, which against a stalled broker could hold host shutdown for twenty seconds per consumer; a stop now stops the broker's deliveries, waits up to five seconds for the handlers it cancelled with the channel still open, so a handler that finishes anyway is acknowledged rather than reported as a rejection, and closes the channel in the background. Disposing the transport against an unresponsive broker returns after about seven seconds instead of about thirty. A consumer the broker cancels, for example because its queue was deleted, used to stop consuming silently; it is now logged, counted on hostloom.rabbitmq.consumers, and restored by declaring its queue again on a new channel. Foreign clients that use RabbitMQ direct reply-to are answered, which the documentation promised but the exact-name check prevented; a Kafka publication still waiting for its delivery report when the transport is disposed ends with ObjectDisposedException instead of hanging, as it did on a paused broker in 0.10.0; and every line either transport logs now carries a stable event id.
The third thread is the WebSocket gateway. A close the server started, for session expiry, an administrative disconnect, rate limiting, a protocol violation, or shutdown, cancelled the pending receive, which aborts the runtime's socket, so clients saw an abnormal closure instead of the status and reason, and the TypeScript client reconnected after a logout. The writer now sends the frames already queued and the close frame and waits for the client's answer, bounded by the new CloseTimeout, and sessions close with 1001 when the host starts stopping rather than after Kestrel has drained its connections, which took a host stop with connected clients from thirty seconds to milliseconds. The gateway endpoint also accepts the HTTP/2 extended CONNECT handshake, which browsers use on an existing HTTP/2 connection and which previously got 405, and a Protocol Buffers frame the reader cannot follow closes with 1007 while any other unexpected failure closes with 1011 instead of a normal closure.
The fourth thread is correctness elsewhere in the framework. A cron schedule in a zone with daylight saving time ran back to back during the fall-back hour, because an ambiguous local time resolved to an instant before the scheduler's input; each local time now occurs once. A registered pipeline's WithRetry re-ran in the failed attempt's DI scope with the same filter instances, so scoped state such as a unit of work leaked into the retry; every attempt now gets its own scope. A composition rule's Skip, Throw, and Replace collided with the rule's own entries, so ExpectMany().Throw() over two implementations always threw; policies now compare against registrations that existed before the rule. A Redis invalidation channel whose subscription failed left an unread queue attached for every attempt, each buffering every invalidation for the life of the process after recovery; a failed attempt now detaches exactly what it attached.
The fifth thread is logging and release security. AddHostLoomLogging registered its provider as an instance the container never disposed, so records still queued at shutdown were lost and a buffered sink was never flushed; the container now owns and drains it. An exception whose Message getter throws, and any other formatter failure, used to stop the log writer for the rest of the process; a formatter failure now drops only that record. [LogMasked] no longer reveals a value too short to keep at least half of it hidden, and dictionary keys and the per-record byte budget are capped like string values. The NuGet release workflow now grants its publishing identity only to a job that runs no restore, build, or test code, and the browser-client workflow reads no npm token.
Upgrading from 0.10.0 changes behaviour an application can observe; each change is stated under Changed. The inbox releases a delivery's key when its handlers fail or are cancelled, so a custom IInboxStore should implement the new ReleaseAsync, and registering the inbox or the outbox a second time throws. An envelope without a message type, a request without a response type, and an incomplete fault are rejected as malformed, and a request that a receive filter completes without its handler is answered with the new HandlerNotRun fault. A registered pipeline's retry runs every attempt in a new DI scope. A composition rule's policy no longer collides with the rule's own entries. The Kafka reply group is named per instance, so replicas may share a ClientId. A WebSocket close the server starts now completes the close handshake, bounded by CloseTimeout; a RabbitMQ consumer the broker cancels is restored by declaring its queue again; and the bare amq.rabbitmq.reply-to reply address is rejected. Stopping a RabbitMQ listener or subscription waits up to five seconds for its handlers, and a delivery its closing channel cannot settle is logged as a warning and redelivered rather than logged as a rejection. The outbox relay stops a drain at its first failed publish and backs off as a whole, so an outage costs one attempt per failed drain instead of one per pending message. [LogMasked] writes only the mask for a value too short to keep at least half of it hidden, which includes a sixteen-digit number shown as its first six and last four digits, and a formatter failure drops only that record. No package is published for the first time.
No package is published for the first time; the set is the same as 0.10.0. @hostloom/websocket-client is not part of this NuGet set; that package carries its own version and ships when a websocket-client-vX.Y.Z tag is published. Its unreleased changes pace credit and acknowledgements below the gateway's control-frame budget and cancel a connect queued behind a second close; the published 0.2.0 client is closed with rate_limited at ordinary event rates, so upgrade it once 0.3.0 ships.
Added
HostLoomWebSocketOptions.CloseTimeout(5 seconds) bounds how long a session this side is closing waits for the client's close frame before aborting; the abort is logged asWebSocketCloseTimedOut(4111).IInboxStore.ReleaseAsync(string, CancellationToken), a default member that does nothing, andInboxStore.FromClaim(tryRecord, release).InMemoryInboxStoreimplements the release. A release the store cannot complete is logged asInboxReleaseFailed(3312).InMemoryOutboxStore.PublishedCapacity(1,000 by default; zero keeps none) bounds the published messages the in-memory store keeps; it used to keep every frame for the life of the process.- The
hostloom.message.kindtag (requestorevent) onhostloom.request.duration,.active,.faults, and.retries, and theHandlerNotRunfault type. - Log events
OutboxMarkPublishedFailed(3305),InMemoryHandlersAbandoned(1404), andWebSocketSessionFailed(4112); the WebSocket close reasoninternal_error; and the logging drop reasonformat_failed. - The
hostloom.rabbitmq.consumerscounter and log eventsRabbitMqConsumerCancelled(1411),RabbitMqConsumerRestored(1412), andRabbitMqConsumerRestoreFailed(1413). - Log events
RabbitMqDeliveryUnsettled(1405),RabbitMqHandlersAbandoned(1406),RabbitMqConnectionStillClosing(1407), andRabbitMqChannelCloseFailed(1408); stable event ids 1421 to 1430 for every Kafka log line, which used to log event id 0; and the outbox relay'sOutboxRelayBackingOff(3306) andOutboxRelayRecovered(3307), each logged once when a relay backoff begins and ends.
Changed
- The inbox releases a delivery's key when its handlers throw or are cancelled, so the transport's redelivery runs them again: a Kafka event that failed, a RabbitMQ delivery requeued by a shutdown, and an inbox inside
UseRetryno longer have their next attempt acknowledged as a duplicate. The release runs on its own five-second...
WebSocket client 0.3.0
The third release of the HostLoom WebSocket client. A busy subscription could make the client disconnect itself: with a credit of two, it sent a replenishment frame for every event, and automatic credit and acknowledgements could exceed the gateway's control-frame budget and close the connection with 1008 rate_limited. The client now coalesces those frames per subscription and paces them against a connection-wide budget. A second fix closes a lifecycle race: calling close(), then connect(), then close() again used to open a replacement socket after the caller had decided to stop; the queued connect now rejects instead.
Upgrading from 0.2.0 changes behaviour an application can observe. Credit and acknowledgements are sent after the current task and may wait for budget. acknowledge() skips progress already covered, and a failed send faults the subscription instead of throwing from the call. The new maximumControlFramesPerSecond option defaults to 50, matching the gateway; set it to the gateway's configured limit if that differs. Size subscription credit for the event rate and number of subscriptions: when credit runs out, the gateway drops live events, and acknowledgements do not provide replay.
This release publishes @hostloom/websocket-client 0.3.0 through its independent npm release stream. It keeps the JSON-v1 protocol and dependency-free ESM output with TypeScript declarations; no NuGet packages are published by this release.
Added
- The
maximumControlFramesPerSecondconnection option, 50 by default, configures the gateway control-frame limit used to pace automatic credit and acknowledgements.
Changed
- Credit and acknowledgement frames are coalesced per subscription and sent after the current task. Every outgoing control frame draws on the shared budget; automatic credit and acknowledgements wait while it is spent. With the default setting, the pacing allows a burst of five frames and refills at twenty per second.
acknowledge(sequence)keeps the highest pending sequence and skips sequences already acknowledged. A failed acknowledgement send ends the subscription with an error instead of throwing synchronously fromacknowledge().- Applications on busy topics should allow enough credit for the time between replenishments. With the default low watermark, ten subscriptions receiving twenty events per second each and using credit 32 need about twelve credit frames per second; acknowledgements consume additional budget.
Fixed
- Automatic credit and acknowledgements no longer send a frame for every event on a credit-two subscription and exhaust the gateway's default control-frame budget at ordinary event rates.
- A
close()during an earlier close rejects aconnect()queued behind it withHostLoomConnectionClosedError, preventing a replacement socket from opening after the caller closes again.
Security
- Publication uses the package's npm trusted publisher through GitHub OIDC, without an npm write token. The release workflow publishes the exact tarball produced by its verification job and refuses a publishing dispatch from a branch whose name resembles a release tag.
HostLoom 0.10.0
The tenth release of HostLoom. Five threads run through it. The first is transport recovery. RabbitMQ publications now borrow confirmed channels from a bounded pool instead of sharing the reply channel behind a single gate, so one stalled confirmation no longer serializes every publication, and RabbitMqOptions.PublishTimeout bounds an event publication end to end, from waiting for a channel through the broker's confirmation. A channel whose publication was not confirmed is never reused: it closes in the background, capped at MaxConcurrentPublishes and reported by hostloom.rabbitmq.channels.closing, so a timed-out publication returns at its deadline rather than after the broker's close reply, which a real-broker test measured at twenty seconds before the change. RequestDispatchConcurrency and EventDispatchConcurrency let a request listener handle up to sixteen deliveries at once while an event subscription keeps its queue order by default, and a rejected delivery is logged with the exception that caused it. Kafka's reply consumer is now its own component that recovers when its first watermark query fails, rather than latching the failure until the host is recreated; commits run from the consumer loop after the handler returns, so a failed commit no longer re-runs a handler whose reply was already produced; and an event handler that keeps failing holds its partition and retries instead of being dropped after five attempts. The in-memory transport now behaves like a broker where tests depend on it: handlers receive the listener's token rather than the caller's, a subscriber failure is logged instead of failing the publisher and making an outbox retry every subscriber, an unbound request waits for its timeout, and disposal ends every pending request.
The second thread is a transport error contract. IRequestBroker.RequestAsync and IEventBroker.PublishAsync now throw MessagingTransportException for every transport failure other than those with their own documented type, carrying the address and the client library's exception as its inner exception, so an application no longer catches Kafka or RabbitMQ types to handle an ordinary broker failure. The broker contracts document what a caller observes: a timeout ends with RequestTimeoutException and an unbound or unroutable address times out, caller cancellation carries the caller's own token, disposal throws ObjectDisposedException, a handler's token belongs to its listener, a completed publication means acceptance rather than handling, and a health probe states whether it reports local state or checks the broker. A shared conformance suite runs the same fifteen scenarios on the in-memory transport, RabbitMQ, and Kafka, and it found two gaps that this release closes: a cancelled RabbitMQ caller received the transport's linked token instead of its own, and disposing the in-memory transport left a pending request waiting.
The third thread is cache invalidation. Redis tracking now flushes the in-process tier after tracking is re-registered on a restored command connection, because CLIENT TRACKING is per connection and an interactive reconnect alone lost invalidations. A tagged conditional write sends its value and tag memberships in one script, a Redis Cluster connection without hash tags on database zero is refused before any cache command, and the Valkey invalidation channel probes its subscriber over a private channel with its own queue, keeps its reconnect backoff while a socket flaps, and flushes on its first acknowledged subscription. Invalidation generations are striped by tag as well as by key, so a tag invalidation discards only the in-flight fills that declare a tag sharing its stripe, and a key with a slow factory is cached again while unrelated tags are being invalidated. That race logic now lives in its own component with synchronous unit tests, which took the tiered cache from 1,740 to 1,434 lines without changing its public surface. The caching reference now states how a write becomes visible on other instances in each invalidation mode, and cache conformance gains cross-instance removal, overwrite, own-echo, delivery-gap, and tag-removal scenarios on the in-process store, Redis, and Valkey.
The fourth thread is coordination under uncertainty. A lock acquisition can end without a certain outcome: the caller cancels, MaxWait expires with the call in flight, the reply arrives after the usable lease, or the provider times out after the server applied the write. The lock kernel now gives each acquisition its own provider token and follows every such ending with exactly one bounded, owner-checked release that the caller never waits for, counted on hostloom.lock.orphan_releases, so an abandoned grant no longer holds the key for a full lease; providers follow standard cancellation. A leader whose renewal fails while its lease is still held now stays leader and retries until an extension succeeds or the lease ends, instead of handing leadership over on one transient provider failure, and a hosted elector whose lease exceeds Locking:MaxLease fails at startup rather than being capped silently.
The fifth thread is evidence. HostLoom.Transport.RabbitMq and HostLoom.Transport.Kafka meters count publications, deliveries, rejections, requeues, commits, rewinds, skips, loop faults, reply-consumer starts, and pending requests, and the Kafka meters are asserted against a real broker. Opt-in experiments time out a RabbitMQ publication and drop and restore its connection through a private TCP proxy, and pause a Kafka container the test owns, including during the reply consumer's watermark query, which gives the Kafka recovery its first real-broker proof.
Upgrading from 0.9.0 changes behaviour an application can observe, each stated under Changed below. Code that caught a Kafka or RabbitMQ client-library exception from RequestAsync or PublishAsync must catch MessagingTransportException and read its inner exception. RabbitMQ request handlers on one endpoint run up to sixteen at a time, so a handler that is not safe to overlap needs RabbitMqOptions.RequestDispatchConcurrency = 1 or a receive-pipeline concurrency limit, and a deployment with a low channel_max should budget the pooled publisher channels. A Kafka event handler that keeps failing now holds its partition until it succeeds. A Redis Cluster deployment must set UseHashTags = true and DatabaseIndex = 0, which renames its keys, so expect a cold cache and roll every instance together where locks must stay exclusive. A Valkey ACL that names the invalidation channel must also allow its :probe:* suffix. Cancelling a lock acquisition no longer stops a command already sent, and a hosted elector whose lease exceeds Locking:MaxLease now fails at startup.
No package is published for the first time; the set is the same as 0.9.0. @hostloom/websocket-client is not part of this NuGet set; that package carries its own version and ships when a websocket-client-vX.Y.Z tag is published.
Added
RabbitMqOptions.PublishTimeout(30 seconds) bounds an event publication end to end: waiting for a publisher channel, declaring the exchange, and the broker confirmation. When it elapses the publisher receives aTimeoutException; a request publication stays bounded by the request timeout, which now also covers client initialization.RabbitMqOptions.MaxConcurrentPublishes(16) caps the confirmed publisher channels the broker keeps in its pool; each outstanding publication owns one exclusively. Both are validated when the broker is constructed. A channel whose publication was not confirmed is never reused and closes in the background, so the caller returns at its deadline rather than after the broker's close reply. At mostMaxConcurrentPublisheschannels close at once, and a publication that needs a new channel meanwhile waits within its own deadline. Thehostloom.rabbitmq.channels.closinggauge reports them, and broker disposal waits at most five seconds for them before loggingRabbitMqChannelsStillClosing(1403) and closing the connection.RabbitMqRequestBroker(IOptions<RabbitMqOptions>, ILogger<RabbitMqRequestBroker>?). A delivery whose handler throws and whose requeue also fails is logged asRabbitMqDeliveryRejected(1401) at Error with the delivery tag, the original exception, and whetherDeadLetterExchangeis configured, before it is rejected without requeue. Previously the failure was swallowed. Logging does not retain the message; configureDeadLetterExchangefor that.KafkaRequestBrokerimplementsIBrokerHealthProbe, so the readiness check no longer treats Kafka as always reachable. It reports unhealthy after disposal, after a failed reply-consumer initialization ("the next request retries initialization"), and while the reply consumer is still awaiting its partition assignment; otherwise healthy, stating that broker reachability is not probed. It reflects local consumer state only.InMemoryRequestBroker(ILogger<InMemoryRequestBroker>?). A subscriber that throws is logged asInMemoryEventFailed(1402) at Error with the subscription name.- The transports reference documents the two RabbitMQ options, the probe support of each transport, and the in-memory delivery rules below.
RabbitMqOptions.RequestDispatchConcurrency(16) andRabbitMqOptions.EventDispatchConcurrency(1), the deliveries a request listener and an event subscription handle concurrently on their channel, each validated between 1 andPrefetchCount. Previously both were the client library's default of 1, soPrefetchCountonly buffered deliveries and every handler on an endpoint ran in turn.InMemoryRequestBroker(ILogger<InMemoryRequestBroker>?, TimeProvider?). The request timeout and the unbound-address wait run on the supplied clock, and a host that registers aTimeProviderbeforeUseInMemory()drives them from a test clock without waiting i...
HostLoom 0.9.0
The ninth release of HostLoom. Five threads run through it. The first is scheduled work. HostLoom.Scheduling is a scheduled-job kernel in the shape of the caching and locking kernels: IScheduledJob is the unit of work, ScheduleTrigger.Cron accepts five or six fields with names, steps, ?, Sunday as 0 or 7, either-day matching, and a time zone with daylight-saving handling, FixedRate counts from the previous due time and FixedDelay from the previous completion, and a Scheduler runs each schedule as one sequential loop over TimeProvider with a per-run timeout, typed outcomes, state, an execution-free probe, and the HostLoom.Scheduling meter and activity source. A late run starts at once and missed occurrences are not replayed; a failed run is logged and the schedule continues. An Exclusive schedule runs on one instance at a time through IScheduleGuard: a claim is granted or refused at once, a refused claim skips the run, a guard failure skips it as GuardFailed rather than running unguarded, and a claim is held past the run until the lease ends or the next occurrence is due, so a slower instance reaching the same occurrence is refused. HostLoom.Scheduling.DependencyInjection registers schedules with a fresh scope per run and validates at startup that an exclusive schedule has exactly one guard, HostLoom.Scheduling.Locking supplies DistributedLockScheduleGuard over IDistributedLock with one skip-if-busy acquisition of schedule:{name} per run, and HostLoom.Scheduling.Testing supplies ManualScheduleGuard, scripted with Hold, Release, Lose, and FailNext.
The second thread is leadership. HostLoom.Leadership is lease-based leader election over the distributed lock: ILeadership exposes Role, IsLeader, Term, LeadershipToken, WaitForLeadershipAsync, and OnChange, and LeaderElector runs a candidate loop on a jittered retry cadence, renews explicitly without being capped by Locking:MaxHold, steps down at once on a refused renewal or an expired lease, resigns on request, and releases on stop. The guarantee is stated as the lock's: at most one leader while clocks and the backend behave, a window bounded by the lease on expiry, and no fencing; Term counts this instance's acquisitions. HostLoom.Leadership.DependencyInjection registers electors keyed by role, HostLoom.Scheduling.Leadership supplies LeaderScheduleGuard and UseLeader(role), which run exclusive schedules on the elected leader with no lock round trip per occurrence, and HostLoom.Leadership.Testing supplies ManualLeadership. LeaderChannel<T> is a bounded Channel<T> gated on a role: every instance writes to it, a leader's items reach the reader, and a follower's are accepted and discarded, so a warm standby keeps its state current without acting; drops are counted by channel and reason and summarised in the log at most once per interval. A leader elector whose lock is disabled no longer leads on every instance: LeadershipOptions.WhenUncoordinated defaults to Follow, and a single-instance or development host opts into Lead. Two-elector tests over the in-process lock and a fake clock, a real-Redis test, and an opt-in outage experiment measure the hand-over window against the lease.
The third thread is reliable messaging at the edges of a unit of work. A transactional outbox routes every IPublishEndpoint.PublishAsync through an IOutboxStore from the caller's scope, and OutboxRelay drains it when woken and every Outbox:PollInterval, publishing each frame unchanged through the transport; a failed message is left pending with its error, retried with exponential backoff, and dead-lettered after OutboxOptions.MaxAttempts. Delivery is at-least-once. An idempotent inbox records {topic}:{subscription}:{messageId} before the handlers run, skips a delivery seen inside the window with an InboxDuplicate payload, runs anyway with an InboxSkipped payload when the store cannot answer, and passes requests through untouched; InboxStore.FromClaim builds a cache-backed store in one line. HostLoomBuilder.ConfigureReceivePipeline accepts a filter that needs a service from the container, and the pipeline is still composed once, from the root provider. RabbitMQ event publication now waits for publisher confirmation and sets persistent delivery when DurableTopics is enabled, so an unconfirmed outbox message remains retryable.
The fourth thread is a security hardening of the whole solution. Remote faults no longer carry the handler's exception type and message by default; a handler that wants the caller to see a message throws RemoteFaultException, and HostLoomOptions.IncludeFaultDetails restores the previous behaviour. The WebSocket gateway refuses a keyless subscribe to a keyed topic unless the registration passes allowTopicWideSubscription: true, drops an oversized event instead of aborting every subscriber, bounds how long a subscriber can hold a snapshot provider open, budgets request frames per second, resolves every configured authorization policy name at startup, and ends a subscription before it answers an invalid credit or ack frame so a fault after subscribed is terminal on both peers. Kafka validates the reply-to header before the handler runs and gains SecurityProtocol, SaslMechanism, and TLS settings; RabbitMQ accepts only server-named reply queues by default, declares a dead-letter exchange when configured, and moves to versioned, role-qualified queue names. Envelope decoding rejects empty ids, the messaging.message.type tag is bounded, logging caps message and field lengths and honours [NotLogged] and [LogMasked] on overridden virtual properties, the Redis and Valkey invalidation channels drop malformed messages and reject tag members outside the namespace's data prefix, the single-flight guard map is bounded, CacheKey.FromSensitive and LockKey.FromSensitive hash low-entropy secrets with HMAC-SHA256, and every coordinated contract now reports IsCoordinated.
The fifth thread is a production review of the cache and lock kernels. Lock acquisition rejects a successful backend reply that arrives after the usable lease has expired, concurrent renewals serialize backend calls with local deadline updates so an older renewal cannot overwrite a newer deadline, a heartbeat that fails re-arms itself and keeps retrying until an extension succeeds or the lease runs out, and a release that throws is logged only, because the backend keeps the owner's lease until it expires. RedisConnection reports the client name of a multiplexer supplied by Redis:ConnectionFactory, so tracking finds that connection's subscriber; a tagged Redis write pipelines the tag memberships before the value; the Valkey invalidation channel flushes after a first subscription that follows failed attempts and probes itself so a dead subscriber socket is replaced within one CommandTimeout; and the cache tracks invalidation generations in 1024 key stripes and recognises the echo of its own publish, so an invalidation of one key no longer suppresses every in-flight fill on the instance and the refill that follows a removal is cached rather than dropped. CacheFilter runs the rest of the pipe on a remembered null, the IDistributedCache adapter applies Caching:MaxPayloadBytes, Kafka reply consumers retain progress across reassignments, and RabbitMQ subscriptions close channels even when cancellation callbacks throw.
Upgrading from 0.8.0 is source-breaking for custom implementations: IsCoordinated is added to IDistributedLock, ILockHandle, ILeadership, and IScheduleGuard, IOutboxStore.MarkFailedAsync takes the next attempt time and MarkDeadLetteredAsync is new, and OutboxMessage, WebSocketTopicDescription, LeadershipDescription, and SchedulingDescription gain positional record parameters; every in-repo implementation and the Testing packages are updated. Several defaults change: remote fault details are opt-in through HostLoomOptions.IncludeFaultDetails; RabbitMQ deployments must follow the queue migration guide or set RabbitMqOptions.QueueNaming to RabbitMqQueueNaming.Legacy during rollout, and a client-named reply queue needs RabbitMqOptions.AllowNamedReplyQueues; a keyed WebSocket topic refuses a topic-wide subscription unless the registration allows it; a leader elector with the lock disabled follows unless LeadershipOptions.WhenUncoordinated is Lead; the Kafka reply consumer starts from the latest offset; the inbox key is length-prefixed, so keys recorded before the upgrade are not recognised for the remainder of their window; and a restricted Valkey cache ACL now also needs SREM. Each is stated under Changed, Fixed, and Security below.
Eight packages are published for the first time: HostLoom.Scheduling, HostLoom.Scheduling.DependencyInjection, HostLoom.Scheduling.Locking, HostLoom.Scheduling.Leadership, HostLoom.Scheduling.Testing, HostLoom.Leadership, HostLoom.Leadership.DependencyInjection, and HostLoom.Leadership.Testing. HostLoom.Scheduling and HostLoom.Leadership depend only on the logging abstractions, with HostLoom.Leadership and HostLoom.Scheduling.Locking building on HostLoom.Locking; the DependencyInjection packages add the hosting and options abstractions, and the Testing packages depend on their kernel alone. @hostloom/websocket-client is not part of this NuGet set; that package carries its own version and ships when a websocket-client-vX.Y.Z tag is published.
Added
HostLoom.Scheduling, a scheduled-job kernel in the shape of the caching and locking kernels:IScheduledJob,ScheduleTrigger.Cron(five or six fields, names, steps,?, Sunday as 0 or 7, either-day matching, time zones with daylight-saving handling),FixedRatecounted from the previous due tim...