Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: TraxSharp/Trax.Effect
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v1.54.0
Choose a base ref
...
head repository: TraxSharp/Trax.Effect
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v1.54.2
Choose a head ref
  • 9 commits
  • 102 files changed
  • 2 contributors

Commits on Sep 16, 2026

  1. chore: refresh trax pins (#144)

    * chore(deps): refresh cross-repo Trax pins to the latest published versions
    
    Trax.Core 1.6.0 -> 1.7.0.
    
    Pins are deliberate edits under adr/0002, so they do not move by themselves,
    and these had drifted far enough that a downstream repo could no longer build
    against what its own code needed. Regenerated every lockfile with the local
    feed hidden and 1.99.99 purged from the NuGet cache, so the resolved versions
    are the ones CI will see rather than the locally packed build.
    
    * chore(deps): make the Trax pins readable so Dependabot can bump them
    
    The pins were written as
    $([MSBuild]::ValueOrDefault('$(TraxLocalVersion)', '1.54.0')). That is not a
    version any tool can parse, so Dependabot skipped every one of them while
    bumping the plain-versioned packages beside them in the same file: across three
    repos, ten Dependabot commits touched Directory.Packages.props and none of them
    moved a Trax pin. The mechanism that makes local development work was blinding
    the automation that would have kept the pins fresh, which is why some had
    drifted thirteen releases behind.
    
    The pins are now plain literal versions. The local override moves to a
    PackageVersion Update group that trax-local.props switches on, so
    pack-local.sh still redirects everything to the packed 1.99.99 and CI, which
    never sees that file, still restores the exact pins under --locked-mode. No
    resolved version changes: the lockfiles are untouched.
    
    Dependabot also gains an ignore for Trax majors. A major is a deliberate
    migration, and it keeps the bot away from the unlisted 2.0.0 packages still
    sitting on nuget.org for trax.core and trax.scheduler, which stay resolvable
    and would otherwise be a legitimate-looking upgrade target.
    
    * ci: regenerate lockfiles on Dependabot pull requests
    
    Dependabot edits Directory.Packages.props but does not reliably regenerate the
    packages.lock.json files beside it. The pull-request build restores with
    --locked-mode, so a lockfile that disagrees with the manifest fails the PR with
    NU1004 and the bump stalls until somebody regenerates by hand. Reproduced
    deliberately: bumping one pin without touching the lockfiles gives exactly that
    error, and restore --use-lock-file --force-evaluate clears it. This matters more
    now that the Trax pins are readable, because those bumps will start arriving.
    
    Two jobs, per GITHUB_ACTIONS_SECURITY.md triage item 2. `dotnet restore`
    resolves and downloads third-party packages, and that must not share a job with
    a write token, so the resolve job holds contents: read and hands the result to
    the commit job as a patch. The commit job holds the only elevated token in the
    workflow and runs nothing but git.
    
    The patch comes from a job that ran dependency code, so it is treated as
    untrusted on arrival: git apply is restricted with --include to lockfile paths,
    and the working tree is re-checked afterwards in case that restriction ever
    slips. Both were verified to hold, including that --include refuses a patch
    touching Directory.Packages.props.
    
    The guard is the pull request author rather than github.actor, which is what
    GitHub documents: a human re-running the workflow changes the actor but not the
    author. persist-credentials is true in the commit job alone, against the
    convention everywhere else here, because that is the job that pushes.
    Theauxm authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    3a8e05e View commit details
    Browse the repository at this point in the history
  2. test: bound the SignalR handshake under the hub wait (#147)

    The hub tests wait on StartAsync with a 10 second ceiling while HubConnection
    keeps its 15 second handshake default, so a stalled handshake outlived the
    assertion and reported as a bare cancellation. The shared fixture and the one
    test that builds its own connection now set HandshakeTimeout to 5s.
    
    ServerTimeout is left alone deliberately: it detects a dead server rather than
    delaying a wait, and shortening it below the server's keep-alive interval would
    drop healthy connections mid-test.
    
    Trax.Docs/adr/0014.
    Theauxm authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    59fb794 View commit details
    Browse the repository at this point in the history
  3. chore(deps-dev): bump js-yaml from 4.3.1 to 4.3.2 (#141)

    Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.1 to 4.3.2.
    - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md)
    - [Commits](nodeca/js-yaml@4.3.1...4.3.2)
    
    ---
    updated-dependencies:
    - dependency-name: js-yaml
      dependency-version: 4.3.2
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    39faa84 View commit details
    Browse the repository at this point in the history
  4. ci: harden the Dependabot lockfile workflow (#148)

    Two fixes to the commit job, the one holding the write token.
    
    The patch is now downloaded before the checkout and into RUNNER_TEMP rather
    than the workspace. Previously actions/download-artifact ran after the
    checkout, so a third-party action executed while the push credentials were
    already in .git/config; now nothing third-party runs in that window. Keeping
    the patch outside the working tree also means it can never be swept into the
    commit, so the explicit delete is gone.
    
    The pinned SHAs for upload-artifact and download-artifact were correct but
    their version comments were not, reading v5.0.0 and v6.0.0 against SHAs that
    are v7.0.1 and v8.0.1 everywhere else here. The SHA is what pins, so this
    changed nothing at runtime, but a wrong label is worse than none in a pinned-
    action audit.
    Theauxm authored Sep 16, 2026
    Configuration menu
    Copy the full SHA
    e9e24d9 View commit details
    Browse the repository at this point in the history

Commits on Sep 17, 2026

  1. ci: make the Dependabot heal workflow actually finish the job (#151)

    Same change proven on Trax.Mediator, where it healed #84 end to end.
    
    The old workflow could never land a commit: it used `git push`, and the org
    ruleset requires verified signatures, which a runner has no key for. It now
    writes through the createCommitOnBranch GraphQL mutation, which GitHub signs
    itself, so the heal lands Verified with no signing key in CI.
    
    It also only ever fixed lockfiles. Dependabot rewrites project files on a
    grouped bump: when the transitive graph resolves one package at different
    versions across projects, it adds <PackageReference ... VersionOverride="...">
    to projects that never referenced it, several pinned to the version being
    bumped away from. Nothing reverted those, so lockfiles regenerated on top of
    the injection. The revert is re-derived with git in the committing job rather
    than trusted from the patch, so the job that runs dependency code still hands
    over nothing but lockfiles.
    
    The regenerating restore now passes -p:NuGetAudit=false. Directory.Build.props
    promotes NU1901-NU1904 to errors, so an advisory anywhere in the graph aborted
    the regeneration, which is precisely the state a security bump arrives in. The
    PR build still audits.
    
    It keeps authenticating with GITHUB_TOKEN rather than an App token or a PAT.
    Either would avoid the "Approve and run" press on the run that follows the
    write, but both mean a long-lived credential in Dependabot secrets, and an App
    private key never expires and can mint write tokens for every repo it is
    installed on. GITHUB_TOKEN stores nothing.
    
    On the config side, csharpier and PublicApiGenerator are ignored. Each has to
    move in one commit with the artefact it invalidates -- the repo-wide format and
    the PublicApi baselines -- which a bot PR cannot do, so it just leaves the check
    failing every time. Roslyn moves to its own group so that when its bump does
    need a human it does not take the rest of the week's updates with it, and the
    PR limit drops to three.
    Theauxm authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    96893b8 View commit details
    Browse the repository at this point in the history
  2. Bump FluentAssertions and 13 others (#152)

    Bumps FluentAssertions from 8.10.0 to 8.11.0
    Bumps Microsoft.AspNetCore.SignalR.Client from 10.0.11 to 10.0.12
    Bumps Microsoft.AspNetCore.TestHost from 10.0.11 to 10.0.12
    Bumps Microsoft.Extensions.Configuration from 10.0.11 to 10.0.12
    Bumps Microsoft.Extensions.Configuration.Json from 10.0.11 to 10.0.12
    Bumps Microsoft.Extensions.DependencyInjection from 10.0.11 to 10.0.12
    Bumps Microsoft.Extensions.Hosting.Abstractions from 10.0.11 to 10.0.12
    Bumps Microsoft.Extensions.Logging from 10.0.11 to 10.0.12
    Bumps Microsoft.Extensions.Logging.Console from 10.0.11 to 10.0.12
    Bumps Microsoft.Extensions.TimeProvider.Testing from 10.9.0 to 10.10.0
    Bumps Microsoft.NET.Test.Sdk from 18.9.0 to 18.10.1
    Bumps NUnit.Analyzers from 4.14.0 to 4.15.0
    Bumps Trax.Core from 1.7.0 to 1.7.1
    Bumps Trax.Core.Testing from 1.7.0 to 1.7.1
    
    ---
    updated-dependencies:
    - dependency-name: FluentAssertions
      dependency-version: 8.11.0
      dependency-type: direct:production
      update-type: version-update:semver-minor
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.AspNetCore.SignalR.Client
      dependency-version: 10.0.12
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.AspNetCore.TestHost
      dependency-version: 10.0.12
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.Extensions.Configuration
      dependency-version: 10.0.12
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.Extensions.Configuration.Json
      dependency-version: 10.0.12
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.Extensions.DependencyInjection
      dependency-version: 10.0.12
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.Extensions.Hosting.Abstractions
      dependency-version: 10.0.12
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.Extensions.Logging
      dependency-version: 10.0.12
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.Extensions.Logging.Console
      dependency-version: 10.0.12
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.Extensions.TimeProvider.Testing
      dependency-version: 10.10.0
      dependency-type: direct:production
      update-type: version-update:semver-minor
      dependency-group: nuget-minor-patch
    - dependency-name: Microsoft.NET.Test.Sdk
      dependency-version: 18.10.1
      dependency-type: direct:production
      update-type: version-update:semver-minor
      dependency-group: nuget-minor-patch
    - dependency-name: NUnit.Analyzers
      dependency-version: 4.15.0
      dependency-type: direct:production
      update-type: version-update:semver-minor
      dependency-group: nuget-minor-patch
    - dependency-name: Trax.Core
      dependency-version: 1.7.1
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    - dependency-name: Trax.Core.Testing
      dependency-version: 1.7.1
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: nuget-minor-patch
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    75bc65e View commit details
    Browse the repository at this point in the history
  3. chore(deps): bump codecov/codecov-action (#149)

    Bumps the actions group with 1 update in the / directory: [codecov/codecov-action](https://github.com/codecov/codecov-action).
    
    
    Updates `codecov/codecov-action` from 7.0.0 to 7.1.0
    - [Release notes](https://github.com/codecov/codecov-action/releases)
    - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
    - [Commits](codecov/codecov-action@fb8b358...0b35c9e)
    
    ---
    updated-dependencies:
    - dependency-name: codecov/codecov-action
      dependency-version: 7.1.0
      dependency-type: direct:production
      update-type: version-update:semver-minor
      dependency-group: actions
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    e1ad605 View commit details
    Browse the repository at this point in the history
  4. fix(deps): reference SQLitePCLRaw directly instead of pinning it tran…

    …sitively (#153)
    
    Trax.Effect.Data.Sqlite pinned SQLitePCLRaw.bundle_e_sqlite3 through a bare
    PackageVersion, to force the patched native library that fixes CVE-2025-6965.
    Nothing referenced the package, and Dependabot only updates a PackageVersion it
    can tie to a PackageReference, so the pin was invisible to it and had sat at
    3.0.3 while Trax.Samples, which references it directly, was carried to 3.0.5.
    A pin that exists to raise a security floor had quietly stopped rising.
    
    The reference now lives in Trax.Effect.Data.Sqlite, the project that pulls it,
    so Dependabot maintains it from here. That also means the floor reaches
    consumers through the package's own dependencies rather than applying only to
    builds inside this repo, which is the intended effect for a CVE.
    
    NoOrphanPackageVersionTests fails on any future PackageVersion with no matching
    PackageReference. Records Trax.Docs/adr/0015.
    Theauxm authored Sep 17, 2026
    Configuration menu
    Copy the full SHA
    f6b81bf View commit details
    Browse the repository at this point in the history

Commits on Sep 24, 2026

  1. fix: train outcome recording (#154)

    * fix: record a train's outcome on a token the caller cannot cancel
    
    ServiceTrain set the terminal state through FinishServiceTrain and then persisted it with
    EffectRunner.SaveChanges(CancellationToken). That token is cancelled in exactly the case the
    write exists to record, so the write never landed. The row stayed InProgress with no EndTime
    until a scheduler's stale-in-progress reaper rewrote it to Failed an hour later, whatever the
    train had actually done, and a train whose downstream call takes no token finishes its work
    after the caller gives up only to be recorded as Failed.
    
    The three terminal writes now go through SaveOutcome, which persists on CancellationToken.None.
    ADR 0005 records why that one write does not take the token, because the obvious edit undoes it.
    
    * feat: refuse a junction chain that reads the value being processed
    
    TrainInput and TrainOutput now throw while a chain is being declared, so a chain cannot vary by
    the input and the chain read at startup is the chain that runs. Nothing had to change to adopt
    this: every Junctions() override in the repo was already pure.
    
    ServiceTrain no longer overrides RunInternal. Once monad construction moved behind the
    overridable NewMonad() upstream, the override was identical to the base, and removing it leaves
    Train.RunInternal with no framework override at all.
    
    * test: move zero-junction test trains onto Junctions()
    
    A train that returns its input, or Unit, declares a chain of no junctions and ends it with
    Resolve(). These no longer need RunInternal.
    
    * test: move the remaining chainable test trains onto Junctions()
    
    Work that a train used to do inline moves into a junction, which is where per-execution
    decisions belong. Where the output is a property of the input, Extract states that directly
    rather than needing a junction at all.
    
    * test: finish moving test trains onto Junctions()
    
    Work moves into junctions, which is where per-execution decisions belong. Three trains keep
    RunInternal: two return the train instance itself, and one captures the monad to assert on
    Memory, so both need seeding a chain declaration cannot express.
    
    * feat: drop ServiceTrain.Activate
    
    Redundant once monad construction moved behind NewMonad(), and it was the last public route to a
    seeded monad outside a chain declaration. ServiceTrain.Run now goes through RunEither, which is
    the public entry point RunInternal sits behind.
    
    * feat: let a train hold its queue entry unconfirmed until OnQueue commits
    
    An OnQueue hook and the work queue row are two writes, and a crash between
    them used to leave the hook's side-effect with no queued work to consume it.
    Two ways to close that, depending on where the hook writes.
    
    IEnqueueContextAccessor exposes the context the enqueue is about to commit
    on, so a hook writing Trax's own entities is committed and rolled back with
    the entry. That does not help a hook writing through its own DbContext: EF
    can only share a transaction between contexts that share a connection.
    
    For that case a train overrides DeferQueuePromotion. Its entry is committed
    with a null confirmed_at and is not dispatchable, the hook runs, and a second
    commit promotes it. The two writes still are not atomic — nothing can make
    them atomic across two databases — but a crash now strands a findable entry
    instead of an invisible side-effect, and IWorkQueuePromotion.PromoteStaleAsync
    recovers it. Promotion rather than cancellation, because the deferred run
    re-executes the chain and the hook is already required to be idempotent.
    
    Immediate promotion stays the default, so a train that does not opt in sees
    no extra round trip. Existing rows are backfilled from created_at.
    
    * feat: let a train name the subject its queued work touches
    
    Queued entries are claimed by several workers with SKIP LOCKED ordered by
    priority and age, keyed on nothing, so two mutations for one record can run
    at the same instant. Against a system that resolves concurrent writes by
    last-write-wins that is a lost update, and Trax had no way to express that
    two pieces of work touch the same thing.
    
    QueueSubjectKey gives a train one. It is read at enqueue time from a metadata
    carrying the input, so the key varies per mutation rather than per train, and
    it returns null by default so every existing train is unaffected. Throwing
    aborts the enqueue rather than degrading to null, which would drop the
    guarantee exactly when the caller wanted it.
    
    This carries the key and the indexes the dispatch check needs; the dispatcher
    change that acts on it follows. The two partial indexes are not a tuning
    afterthought — measured on 200k entries the busy-subject check is 36ms of
    sequential scanning without them and 0.05ms with them.
    
    * feat: refuse to claim a queue entry whose subject already has a run in flight
    
    The claim now rejects an entry when another entry with the same subject key
    is dispatched and its run has not reached a terminal state — the definition
    of an active execution already used for dormant dependents.
    
    That is not sufficient on its own. Two entries for one subject are two
    different rows, so FOR UPDATE SKIP LOCKED does not make them contend, and
    while both are still queued neither can see a dispatched sibling to refuse
    itself. Two dispatchers in the claim window at the same moment would both
    succeed. LockSubject closes that: on Postgres a transaction-scoped advisory
    lock on the subject, taken before the claim. It is held only for the claim
    transaction, which commits before the job is submitted, so no remote call
    happens under it.
    
    LockSubject is default-implemented as a no-op so providers outside this repo
    keep compiling, and because a single-writer provider does not need one.
    
    * feat: record how a failed run failed
    
    Registering an IFailureClassifier makes Trax stamp a FailureClass on every
    failed run, stored on the metadata so it survives the process and can be
    queried rather than grepped.
    
    It is asked where the failure is recorded, which is the only place holding
    the consumer's own exception object: junctions enrich an exception and return
    it rather than wrapping it, so a classifier can type-check and read
    structured error data instead of parsing a message. There is a test pinning
    that, because if it ever changes every classification silently becomes
    Unclassified rather than anything failing.
    
    A classifier is optional, may return null, and may throw — a throw is logged
    and leaves the failure unclassified, since it must never be able to mask the
    failure it was asked about. Cancellation is not a failure and is not
    classified.
    
    Nothing acts on the class yet; retry remains count-based. Recording it first
    is deliberate.
    
    * fix: record a failed run once, and classify failures raised outside a junction
    
    A failed run finished the train, saved its outcome and fired OnFailed, then
    rethrew from inside the try whose catch did all three again. Every failure was
    written twice and every failure hook, global and per-train, ran twice. The
    result is now captured first and takes one path. A completed run whose outcome
    cannot be saved lets that error propagate instead of being rewritten as Failed,
    since the work did happen.
    
    The classifier's answer was only ever written onto the exception's
    TrainExceptionData, which exists only for a failure inside a junction. A train
    whose Junctions() threw, or that failed before any junction ran, recorded
    Unclassified whatever the classifier said. The class is now set on the run
    directly when nothing more specific was carried, so a class sent back by a
    remote worker still wins over the calling side's classifier.
    
    Also repairs the XML docs on the queue hooks: the DeferQueuePromotion and
    QueueSubjectKey summaries had been stacked onto QueueSubjectKey, leaving OnQueue
    and DeferQueuePromotion undocumented, and OnQueue now says that a deferring
    train's row is committed before its hook runs.
    
    * fix: keep entries the claim will refuse out of dispatch capacity
    
    The group-fair load, which is the default dispatch path, returned every queued
    manual entry, including unconfirmed ones and ones whose subject already had a
    run in flight. The claim refused them, but only after capacity limiting had
    given each a slot, so a backlog for one subject, or a handful of stranded
    staged entries, could take every slot on every cycle. Both dialects now filter
    them out the way the other load path already did.
    
    confirmed_at gets a DEFAULT of now() on Postgres. During a rolling deploy an
    instance still on the previous version inserts rows without the column, and
    without a default those rows were unconfirmed forever. The dispatchable index
    now also filters on status, so it stops covering every row ever dispatched.
    
    The subject lock moves to the two-key advisory form under a fixed class key,
    which Postgres keeps apart from the single-key space the leader lock and any
    consumer locks use.
    
    * feat: cancel stranded staged entries by default, and let enqueue contexts flow
    
    A crash can leave a deferred entry unconfirmed, and nothing recorded tells
    apart a hook that succeeded, one that never ran, and one that threw before the
    entry was removed. Promoting such an entry could run a mutation that was
    rejected, so IWorkQueuePromotion gains CancelStaleAsync as the default recovery
    and keeps PromoteStaleAsync for hosts that opt in. Promotion now only confirms
    an entry that is still queued, so one an operator cancelled while its hook ran
    stays cancelled. On the in-memory provider, which cannot translate
    ExecuteUpdate, both fall back to tracked updates instead of throwing after a
    hook has already run.
    
    The enqueue context was a plain field on a scoped service, so two enqueues in
    one scope, such as a Blazor circuit, threw or saw each other's context. It is
    an AsyncLocal now: each enqueue sees its own, and one started from inside a
    hook gets its own and hands the outer one back.
    
    * docs: route the terminal write and authorization vocabulary to their decisions
    
    AGENTS.md now points work on ServiceTrain's terminal write to effect/0005 and authorization types to effect/0004; neither was routed. The README usage example still overrode RunInternal with Activate, which no longer compiles. Also clears a nullable warning in the JSON effect provider tests.
    
    * docs: record that a closed vocabulary Trax persists is a Postgres enum
    
    Adding a value to one of these vocabularies breaks a rolling deploy, and it is
    easy to blame that on the enum column type. The failure is in mapping the value
    to a C# enum the old process lacks, which text storage would not avoid, so all
    seven stay Postgres enums. effect/0006 records that, and that a new value ships
    in a migration and a reader build before anything writes it.
    
    PostgresEnumVocabularyTests holds it up: the three places an enum is mapped
    must name the same enums, and every mapped enum's members must match the labels
    its migrations create and add.
    
    * fix: make the SQLite dispatch SQL match how SQLite stores enums
    
    The SQLite context stores enums as their integers, so status holds 0 and
    train_state holds 0 or 1, but the SQLite claim and candidate queries compared
    those columns to 'queued', 'dispatched', 'pending' and 'in_progress'. The claim
    therefore never matched a row, which meant dispatch on SQLite could not claim
    any work, and the subject check could never see a busy subject. The claim
    predates this branch; nothing caught it because the only test string-matched
    the SQL. The literals are now generated from the enums, and new tests run the
    claim and the candidate load against rows EF wrote.
    
    * fix: leave a remote failure unclassified when the worker sent no class
    
    When a worker returned a failure without a classification, the calling side asked its own classifier about the rebuilt exception and recorded the answer. That is the re-derivation docs/0020 rejects: the rebuilt exception's type is gone, so a catch-all classifier stamped Permanent on a failure the worker deliberately left alone. A failure rebuilt from a serialized record is now never classified locally; a class the worker did send still wins.
    
    * perf: index runs by failure class for the executions filter
    
    The executions query can filter on failure_class, which had no index, so a filtered page scanned the metadata table and counted it exactly. A partial index on the classified rows, ordered by id like every other filter's page, covers it; unclassified, the default and the bulk of the table, is left out.
    
    * fix: backfill confirmed_at from created_at before setting its default
    
    Migration 041 added the column with DEFAULT now(), which filled every existing
    row with the migration time and left the backfill from created_at nothing to
    do, contrary to its own comment. The default is now set after the backfill.
    The migration has not been released, so existing databases are unaffected.
    
    Also drops two tests named for a RunInternal override that no longer exists,
    fixes comments and XML docs that still referred to it, and names both
    stale-run reapers as releasing a subject.
    
    * test: cite the decisions these tests hold up
    
    The exemplar tests named by docs/0016, docs/0018, docs/0019 and docs/0020 now carry the adr property and say which decision they enforce, so someone gutting one sees that an ADR depends on it.
    
    * docs: route to the new decisions, and say how SQLite stores enums
    
    AGENTS.md now routes work queue, enqueue-context and FailureClass storage changes to their decisions, with counts that match. effect/0006 said only that SQLite stores the C# value; it stores the enum's integer, so raw SQL against those columns has to compare integers.
    
    * fix: carry the class of a failure raised outside a junction, and keep the failure if recording it fails
    
    A classifier's answer was written onto the exception only when a junction had
    attached TrainExceptionData. A failure raised elsewhere got its class on the
    local run but nowhere else, so a remote worker reported it with none and the
    caller recorded Unclassified. The data is now attached when missing, with the
    fields AddException would derive anyway.
    
    If finishing or saving a failed run threw, that exception replaced the train's
    own failure and no failure hooks ran. The recording error is now logged and
    the original failure propagates with its hooks.
    
    * fix: give SQLite's new columns and indexes integers, and pin TrainState's values
    
    SQLite stores enums as integers, but migration 009 defaulted failure_class to
    the label 'unclassified', so every row that existed before it was neither
    Unclassified to a query nor anything else, and migration 008's partial
    indexes filtered on 'dispatched', 'pending' and 'in_progress', which no row
    holds, so they were never used. Both now use the integers. TrainState's values
    are pinned explicitly, because SQLite's SQL and indexes now depend on them.
    
    * fix: share the enqueue context across instances, and make the failure-class index usable
    
    The enqueue context's AsyncLocal lived on each accessor instance, and the
    accessor is scoped, so a singleton train, or a repository resolved from
    another scope, read null inside OnQueue. It is static now: every instance
    sees the enqueue running on the current async flow.
    
    The failure-class index was partial on classified rows, but the executions
    filter sends the class as a parameter, and a generic plan cannot prove a
    parameter satisfies a partial predicate, so the index went unused. It covers
    every row now.
    
    * docs: record how a run's outcome is written when saving it fails, and SQLite's integers
    
    effect/0005 now says a completed run whose outcome cannot be saved propagates the save error instead of being rewritten as Failed, and that a failed run's recording error is logged while the original failure propagates with its hooks. effect/0006 says SQLite's failure_class is an INTEGER defaulting to 0, the subject indexes compare integers, and TrainState's values are pinned for that reason.
    
    * fix: set confirmed_at's default before backfilling it in migration 041
    
    DbUp runs the script without a transaction, so an instance still on the previous
    version could insert after the backfill UPDATE and before SET DEFAULT, leaving a
    row with a NULL confirmed_at. The dispatcher treats that as staged and skips it,
    and the stale-staged sweep then cancels it, so accepted work was silently lost
    during a rolling deploy.
    
    The default is now set before the backfill. SET DEFAULT takes an ACCESS EXCLUSIVE
    lock that waits out in-flight inserts, so every row written without the default is
    committed before it and caught by the backfill after it. The regression test
    replays the script statement by statement against a database migrated to 040 and
    inserts an old-style row between each pair; it fails on the previous ordering.
    
    * test: cover the stale-staged sweeps against confirmed backlogs, cancelled entries and SQLite
    
    The sweeps were only tested against staged entries on the in-memory provider.
    A queued entry that is confirmed but an hour old is a normal backlog, and neither
    CancelStaleAsync nor PromoteStaleAsync may touch it; an old staged entry an
    operator already cancelled must not be revived by PromoteStaleAsync.
    
    SQLite gets its own copy of the promotion tests because it runs the real
    ExecuteUpdate statements, with an integer status and a TEXT created_at, so the
    cutoff is a string comparison. That includes a row whose created_at came from
    the column's datetime('now') default rather than from EF.
    
    * test: pin what a train reports when saving its outcome fails
    
    Effect ADR 0005 says a completed run whose outcome save throws propagates the
    save error, is not rewritten as Failed and fires no OnFailed, leaving the row
    InProgress for the reaper; and that a failed run whose outcome save throws still
    propagates its own exception and fires OnFailed once. Neither half had a test.
    A failing effect provider placed ahead of the in-memory one throws once the run
    reaches the probed terminal state.
    
    * test: pin FailureClass's integers and that SQLite stores them
    
    SQLite writes FailureClass as its integer and migration 009 defaults the column
    to 0, so renumbering a member would reinterpret every stored row. The test pins
    each member's value and checks EF writes Conflict to the column as 2.
    
    * test: name the decisions in the outcome-save and FailureClass failure messages
    
    OutcomeSaveFailureTests and SqliteFailureClassStorageTests cite effect ADRs 0005 and
    0006 in their attribute and docstring; the messages now name them too, so whoever
    trips one sees the authority and the ADR guard can list them as exemplars.
    
    * docs: correct what TrainInput, TrainOutput and WorkQueue's constructor promise
    
    TrainInput and TrainOutput throw ChainDeclarationException while a chain is being
    read, and return default in QueueSubjectKey and OnQueue, which run on an instance
    with no metadata; the docs said only that they return default before a run.
    WorkQueue's parameterless constructor leaves ConfirmedAt null, and EF writes that
    null explicitly, so an entry built that way is saved as staged and never
    dispatched; the docs now say so and point to WorkQueue.Create.
    
    TrainState had two remarks blocks, of which only one renders; they are merged.
    AGENTS.md summarised effect 0004 in central 0013's words and now uses its own.
    ADR 0006's overlong lines are wrapped, and 0005 and 0006 name the tests that
    now pin them.
    
    * test: split migration 041 with DbUp's own splitter in the mid-migration writer test
    
    The test cut 041 into statements with a hand-rolled split that dropped comment lines and broke
    on every semicolon, which only matched production while the script avoided quoted or inline
    semicolons and dollar-quoted bodies. It now uses PostgresqlConnectionManager's
    SplitScriptIntoCommands, the splitter DatabaseMigrator's PostgresqlDatabase runs, so the
    statement boundaries an older writer can land between are production's by construction.
    
    * test: bound the wait for a train body in the cancelled-outcome tests
    
    Both tests awaited the body's start signal with no ceiling, so a run that faulted before its
    body started hung the suite instead of failing. The wait now races the signal against the run
    under a 15 second ceiling, rethrows the run's exception when the run ends first, and releases
    the parked body in finally so no run outlives a failed test.
    
    * chore: build against Trax.Core 1.7.2
    
    Core 1.7.2 is the release that carries FailureClass, ChainVerification and the
    Junctions()-only chain, which this branch builds on. Only Trax.Core and
    Trax.Core.Testing move; no Microsoft.Extensions floor changed.
    
    * fix: backfill confirmed_at only on rows that can still be dispatched
    
    Migration 041 rewrote every work_queue row. work_queue keeps every dispatched row
    until metadata cleanup removes it, and on 2M rows the UPDATE took 17 s, doubled
    the table and held a row lock on each one, so no other instance could dispatch
    until it finished. Startup runs migrations synchronously, so a probe that killed
    the pod mid-UPDATE would roll it back and start over.
    
    Only queued and cancelled rows are backfilled now, plus dispatched rows from the
    last day: a failed dispatch goes straight back to queued without touching
    confirmed_at, so one in flight across the migration must come out confirmed.
    Older dispatched rows keep a null nothing reads. ix_work_queue_unconfirmed is
    restricted to queued rows to match the sweep's predicate, so that history and
    the staged rows the sweep has cancelled stay out of it. SQLite's 007 does the
    same with its integer statuses; it has one writer, so it needs no recent-rows
    allowance. Neither migration has been released.
    
    * fix: run each SQLite migration in its own transaction
    
    DbUp ran the SQLite scripts without a transaction. SQLite has no ADD COLUMN IF
    NOT EXISTS, so 007-009 adding a column and then failing, or the process dying
    before DbUp journaled the script, would fail every later startup on
    "duplicate column name" and need the database repaired by hand. SQLite DDL is
    transactional, so a script now lands whole with its journal row or not at all.
    
    * feat: build a work queue entry only through WorkQueue.Create
    
    An entry built with new WorkQueue { ... } leaves ConfirmedAt null, and EF writes
    that null, so it was saved as staged, never dispatched, and cancelled by the
    stale-staged sweep with no error. The parameterless constructor is now
    protected, still reachable by EF and by System.Text.Json through
    [JsonConstructor], and PersistentWorkQueue, which only holds the EF
    configuration, gets a private one so it is not a way around Create.
    
    Create also refuses a subject key that is empty or longer than the new
    WorkQueue.MaxSubjectKeyLength (512). Until now only the mediator checked the key,
    so an entry built from CreateWorkQueue could carry one the index cannot claim.
    Theauxm authored Sep 24, 2026
    Configuration menu
    Copy the full SHA
    de45880 View commit details
    Browse the repository at this point in the history
Loading