Release v0.0.92
Overview
This release makes it possible to answer "why did this instance fault?" Incidents move out of the Instances.Incidents jsonb column into their own table with unbounded history, a denormalized HasActiveIncident flag with a partial index, and two new read surfaces — GET .../instances/{id}/incidents/active and GET .../instances/{id}/incidents — reached through a link-only incident block that appears identically on the state body and on metadata.incident for both a single GET and every list item (response shape v9), all while taking incident reads on the three hottest paths to zero (#972). Instance status changes move to a Postgres compare-and-set, replacing distributed locks and redundant transactions around the status flip (#975). Discovery registry reads get a read-through L1/L2 cache under the default http provider, plus a hosted bulk-read window and a now-synchronous POST utilities/discovery/refresh — measured p50 0.182 ms on a hit against 100.7 ms on a registry miss (#976). And sub-state changes relay post-commit instead of waiting on the outbox → pub/sub → inbox path, which on preprod once took 6 min 30 s, with the receiver finally taking the same per-sub-item lock the three terminal paths take (#978). This release runs on component schema 0.0.53.
Features
Incidents in their own table, reachable by link (#972)
Incidents lived in the Instances.Incidents jsonb column, pruned to the newest five. A client had no way to read incident history, and nothing on the instance explained why it faulted.
Storage. InstanceIncident is now an entity with its own InstanceIncidents table: one row per incident, unbounded history, cascade-deleted with the instance, indexed on (InstanceId, CreatedAt DESC). Instances.HasActiveIncident becomes a real denormalized column with a partial index on true, maintained by the aggregate. Guards read the flag; only code that needs the rows calls LoadActiveIncidentsAsync, which issues no query when the flag is false. Incidents are never Included. History, paging and batch reads go through a new IInstanceIncidentRepository.
Read surfaces. The state body and metadata.incident — on the single GET and on every list item — carry the same block, and it carries links, not content:
{
"incident": {
"hasActiveIncident": true,
"active": { "href": ".../instances/{id}/incidents/active" },
"history": { "href": ".../instances/{id}/incidents" }
}
}
activeis present only while the flag is true, so a client follows it exactly when there is something to fetch.historyis always present. One shape across both surfaces, so a client learns it once.GET .../instances/{instance}/incidents/activereturns the newest unresolved incident;GET .../instances/{instance}/incidentspages the full history, newest first. Both sit behind the samequeryRolesgate as the state function, and neither returns a stack trace.404(Instance:100037) from the active endpoint is a normal answer, not a failure. The link is advertised while the flag is set, but a successful retry resolves the incident and a client may follow the link just after that. A caller failing the role gate gets 403 instead, so "no incident" and "not allowed to know" stay distinguishable.- Lifted from an active subflow,
active.hrefaddresses the subflow that owns the incident, whilehistory.hrefstays on the polled instance — that link answers "what has gone wrong with the thing I asked about". ResponseShapeVersionis v9, andHasActiveIncidentjoins the fingerprint ETag, so raising or resolving an incident without a state change still invalidates a parked long-poller.
Why links rather than an embedded summary. The first pass embedded the active incident, plus a newest-five array and a total count in metadata. That cost reads on the two hottest paths in the runtime, duplicated what the history endpoint already returns, and left a staleness hole: resolving incident A and raising incident B inside one parked state moved no fingerprint member, so a client validating with If-None-Match kept its 304 and went on showing A. Switching to links removed all three at once:
| Incident reads | Before | After |
|---|---|---|
| State function | 1 | 0 |
| Instance GET | up to 3 | 0 |
| List view | 1 batch per page | 0 |
vnext-meta declares these entries as since: 0.0.89, but they ship in 0.0.92 — the metadata rows were written before the release was cut.
Instance status via Postgres compare-and-set (#975)
Instance status changes were protected by a distributed lock plus a surrounding transaction. Both are replaced by a database compare-and-set: the status write itself carries the expected prior value, so the database decides the winner of a race and a losing writer learns it from the update's row count rather than from lock contention.
InstanceBusyManagerandInstanceCancellationServicemove onto CAS;TransitionAdmissionServicereads the CAS outcome instead of holding a lock across the check.TransitionSettlementand the standard task persistence strategy simplify to set-based writes, relying on per-instance Busy/CAS serialization instead of their own transactions.- Settlement now handles the "already applied" and "skipped" outcomes explicitly, rather than treating them as failures.
Reference: PR #975 — see also Transition pipeline.
Discovery registry read-through cache (#976)
Under ServiceDiscovery:Provider = http, every cross-domain hop paid a registry GET at roughly 80 ms. A read-through cache now sits in front of IDiscoveryRegistryClient — in-process L1, shared distributed L2, live registry on a miss — plus a hosted service that bulk-reads every registration once per window under a distributed lock.
{
"ServiceDiscovery": {
"Cache": {
"Enabled": true,
"L1Enabled": true,
"L1TtlSeconds": 60,
"TickIntervalSeconds": 60,
"RefreshIntervalSeconds": 3600,
"L2TtlSeconds": 7200,
"WarmupLockLeaseSeconds": 30,
"BulkPageSize": 100,
"MaxPages": 20
}
}
}
Scoped to the default http provider only. Under Provider=dapr the plain registry client is registered and nothing on that path changes.
A cache of this shape was built and removed once before, on the grounds that routing to a moved or dead endpoint for up to five minutes was not worth the latency saving. That verdict was correct about that implementation, which had four defects — each of which this design exists to avoid:
| Old defect | Consequence | Avoided by |
|---|---|---|
| Revalidated over HTTP on every hit, against an endpoint with no conditional-request support | a "hit" cost a cache read plus the full 80 ms — the saving was negative | a hit performs no network I/O |
| Rewrote the whole blob with a fresh TTL on any miss | the TTL extended indefinitely under traffic | per-domain entries, each stamped with its own fetch time |
Followed links.next, which carries the remote's gateway base path | page 2 404'd, was swallowed, and one page stayed cached forever | explicit paging, loud at the cap |
| Cached the endpoint under a domain-only key | its Kind depends on the caller's preferredKind, so callers overwrote each other | caches the caller-independent DomainRegistration |
Two design decisions are worth stating explicitly. The entry carries FetchedAtUtc, validated on read, because the distributed cache is Dapr-backed and an absolute expiration becomes the state store's ttlInSeconds — which a component without TTL support ignores silently, reproducing exactly the unbounded staleness the earlier removal feared. Correctness must not depend on which component is configured. And the window is sized to how often a domain's address actually moves (1 h refresh, 2 h entry age); the moving case is covered by POST utilities/discovery/refresh, which is un-deprecated, now synchronous, and reports its outcome ({"outcome":"Refreshed","refreshed":true}). Anyone changing a domain's baseUrl should call it.
Cache:Enabled=false restores the previous behaviour literally — the plain client is registered, so not even a pre-flip entry stays readable. The bulk read gets its own named HttpClient, so a failing refresh cannot trip the circuit breaker guarding the fallback path a miss takes.
Measured in a cross-domain lab (79 resolutions):
| Resolution | n | p50 | p95 |
|---|---|---|---|
cache | 78 | 0.182 ms | 1.234 ms |
registry | 1 | 100.705 ms | — |
One pod performed the single bulk read cluster-wide; a second pod saw its marker, skipped, and still served every one of its resolutions from what the first wrote.
AcceptedStatuses defaults to ["A"], but metadata.status is the status of the registration workflow instance, not a health signal. A deployment whose registration flow runs to a Finish state leaves those instances at C. Check this against your registry before enabling the cache.
Reference: PR #976 — see also Service discovery configuration.
Sub-state change post-commit relay, receiver lock and rest-point emission (#978)
InstanceSubStateChangedEvent now also travels as an immediate post-commit command, not only through outbox → Dapr pub/sub → Inbox. On preprod that outbox-only path once took 6 min 30 s, when the Dapr Redis pub/sub pool was exhausted.
- The per-event
switchin the terminal relay is replaced byIPostCommitEventRelay<TEvent>, resolved from DI by the event's runtime type. The registration is the opt-in — one class plus oneAddScopedline, no marker interface, no central switch — and removing the line is the kill switch. - The fast path walks the whole ancestor chain, not one level.
SubflowStateServiceis a second dispatcher call site, because the runner never sees an event raised inside that service's own unit of work. SubflowStateServicenow takes the same per-sub-item lock the three terminal paths take. It was the only parent-mutation path with no lock, which made itsSubFlowStateChangedAtread-check-write a real time-of-check-to-time-of-use race.- Its loader narrows to the tracked parent plus only this child's open correlation, with no data list.
Propagation is hop-by-hop, and the value is the leaf's state at every level:
W (deepest subflow) reaches its rest point
└─ event { ParentInstanceId = Z, NewState = "w-state" }
└─ Z: correlation updated, Z.EffectiveState = "w-state"
└─ new event { ParentInstanceId = Y, NewState = "w-state" }
└─ Y: same → { ParentInstanceId = X, NewState = "w-state" }
└─ X (root): EffectiveState = "w-state"; no parent, chain ends
There is no broadcast — each level re-publishes to its own parent — so an ancestor's effectiveState answers "where is the deepest active link", not "where is my direct child". A duplicate delivery does not cascade: propagation returns early when the value, type and subtype are unchanged, which is what keeps dual delivery (relay plus Inbox backup) from walking the chain twice.
sub:state-changed is now emitted once per activation episode, at its rest point. Instance.ChangeState only arms it; TransitionSettlement publishes inside the pipeline's unit of work, so the event and the state still commit together. The rule is "the chain stopped", not "the transition was manual": in A→B→C→D, B and C are not reported and D is, because D is where the hop loop ended — automatic transitions are not exempt as a class, and the chain's final landing is published even when that landing is an automatic transition's target. Measured on the same workload: 53 → 47 receiver applications, 22 → 19 relays.
Two mechanics keep this correct and are easy to break. The two publish sites have different guards: TransitionSettlement publishes an instance's own state change and is suppressed while it holds an open SubFlow correlation (the child's own notification supersedes it), whereas the propagation path relays a descendant's state and publishes regardless — applying the first guard to the second would break the chain at every intermediate level, since every one of them has an open correlation. And a completing subflow's finish state reaches the parent through the terminal channel: the finish state is published, but by then the terminal path has usually closed the correlation and reset the parent's EffectiveState itself, so the state event resolves as correlation_not_found. That is pre-existing behaviour, and the net effect is unchanged.
Reference: PR #978 — see also Event-driven workflows.
Behavior Changes
The incident block is a response-shape change on three surfaces at once — the state body, the single instance GET and every list item — advancing ResponseShapeVersion to v9 and adding HasActiveIncident to the fingerprint ETag; clients that read incidents inline must follow the active / history links instead, and must treat a 404 Instance:100037 from the active endpoint as a normal answer rather than an error. Emission of sub:state-changed also narrows to once per activation episode at its rest point, so intermediate states in an automatic chain are no longer reported upward. Each item, its impact direction and the migration steps are documented in the v0.0.92 breaking changes announcement.
Fixes
Five incident and retry defects, each found by running the change against a real runtime (#972):
- The migration's inner foreign key named the wrong schema — the multi-schema SQL generator does not rewrite the inner foreign keys of a
CreateTableOperation, so every flow schema pointed atpublic."Instances"and the backfill failed with23503in 13 schemas. Fixed by usingprincipalSchema: null, the convention this repository has used since the initial migration. - Incidents were re-inserted by another context —
LoadActiveIncidentsAsyncput no-tracking rows on the EF navigation, and another context tracking the same aggregate saw them as new children and re-INSERTed them, killing the retry request with23505and a half-written response body. Loaded rows now sit in a detached list EF cannot see. - An abort wrote two incidents — the boundary's verdict plus a bare
ErrorBoundaryAbortpipeline row, because the task steps saved before recording, so the fault path's reload still readHasActiveIncident = falseand added its fallback. As a resultincident.activeon a faulted instance carried no boundary verdict. The three task steps now record before their own save, committing row and flag together. - A re-faulting retry settled
Active— the response said"status":"F", but the aggregate was loaded tracked in the ambient request unit of work, and the ambient commit overwrote theFaultedan innerRequiresNewscope had persisted. The instance looked healthy, had not finished its work, and could never be retried again (Instance:100027). Retry now reads read-only and unfaults with a compare-and-set. - A recovered instance still reported an active incident —
Unfault()resolved only the newest row, and because the flag is fingerprint material, long-polling clients saw the stale signal too. The whole open set is now closed and the flag recomputed.
Also in this release:
- A completed parent no longer propagates Busy to its subflows (#975).
- The DbMigrator exits non-zero when any schema fails, instead of reporting success (#972).
Configuration Updates
Configuration for v0.0.92:
{
"runtimeVersion": "0.0.92",
"schemaVersion": "0.0.53"
}
Note: Schema version is unchanged at 0.0.53.
New settings:
ServiceDiscovery:Cache:*—Enabled,L1Enabled,L1TtlSeconds(60),TickIntervalSeconds(60),RefreshIntervalSeconds(3600),L2TtlSeconds(7200),WarmupLockLeaseSeconds(30),BulkPageSize(100),MaxPages(20). Off by default in code — so domain teams consuming the runtime as a package inherit the previous behaviour — and on in the orchestration host'sappsettings.json.SchemaMigration:CommandTimeoutSeconds(default 600) andSchemaMigration:LockExpirySeconds(default 900, validated to exceed the timeout) on the DbMigrator, because a platform migration over a large table can outrun the default command timeout. Both are optional in Helm; add them under the db-migrator environment configuration only if a deployment needs longer than the defaults.
Migrations. MoveInstanceIncidentsToTable creates the InstanceIncidents table and the HasActiveIncident column; BackfillInstanceIncidents copies existing jsonb rows idempotently (jsonb_array_elements + ON CONFLICT DO NOTHING) and sets the flag. The legacy Instances.Incidents jsonb column stays in the database, unmapped, in this release — dropping it is a later release's migration. There is no retroactive repair: instances that already carry duplicate rows, or a stale flag, stay that way until their next unfault.
Container images: published at tag 0.0.92 under ghcr.io/burgan-tech/vnext/*, Cosign-signed (keyless OIDC) with SBOM + provenance. Immutable digests are listed in the GitHub release.
Issues Referenced
- vnext #972 — Move incidents to their own table and fix incident/retry defects.
- vnext #975 — Replace status locks with Postgres compare-and-set.
- vnext #976 — Cache discovery registry reads behind the default provider.
- vnext #978 — Relay sub-state changes post-commit and lock the receiver.
Summary
- Incidents get their own table with unbounded history, a denormalized
HasActiveIncidentflag with a partial index, andGET .../incidents/.../incidents/active; theincidentblock carries links only, identical on the state body andmetadata.incident. Response shape v9; incident reads on the three hottest paths go to zero. - 404
Instance:100037from the active endpoint is a normal answer; 403 is the role-gate denial, so the two stay distinguishable. - Instance status changes use a Postgres compare-and-set, replacing distributed locks and redundant transactions; a completed parent no longer propagates Busy.
- Discovery registry reads are cached (L1 + L2, hourly bulk window,
FetchedAtUtcvalidated on read) under thehttpprovider only — hit p50 0.182 ms vs registry 100.7 ms;POST utilities/discovery/refreshis synchronous and reports its outcome. - Sub-state changes relay post-commit up the whole ancestor chain, with the receiver taking the same per-sub-item lock as the terminal paths, and
sub:state-changedemitted once per activation episode at its rest point. - Migrations
MoveInstanceIncidentsToTableandBackfillInstanceIncidents; the legacy jsonb column is retained unmapped. DbMigrator gainsSchemaMigration:CommandTimeoutSeconds/LockExpirySecondsand now exits non-zero on failure. - Schema stays at 0.0.53.
vNext Runtime Platform Team Released September 9, 2026
