From f4ee5c6401c3f5193aabcea9b15008213f7ea596 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Thu, 30 Jul 2026 09:10:07 -0500 Subject: [PATCH 1/2] fix(#5091): fence the allocation history on allocated, not reserved, sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #4953/#5057 allocation fence was built from the sequence's RESERVED ceiling (mt_events_sequence.last_value), and Postgres reports last_value = 1 for a sequence nothing has drawn from yet. No poll could ever report a value at or below a stuck mark of 0, so findAllocationFence(0) always returned null: a leading gap — the mark pinned at 0 under a hole at the very start of the sequence — could never be fenced, and any permanently idle open transaction held it forever. That is the shape reported in #5090 ("holding before the sequence gap above 0"), where the quiescent-session exclusion was inert on 9.21.0. The statistics reading now also carries is_called, which distinguishes "1 has been handed out" from "1 is the next value to hand out", giving the highest ALLOCATED sequence number — 0 for a pristine sequence. The fence compares against that instead, which makes mark 0 fenceable without weakening the proof: everything above the allocated high was handed out strictly after the poll that observed it. The reserved ceiling still bounds how far a proven-dead skip may advance (StuckGapObservation.ReservedCeiling is unchanged), and per-tenant partitioned stores report no allocation reading at all, so they keep today's conservative no-fence behaviour. Also documents that a process starting more than one daemon-hosting IHost over its lifetime should prefer Events.UseAdvisoryLockTransaction = false, since a session-scoped leadership lock holds no open transaction that gap detection has to treat as a possible in-flight append. Tests cover the dead leading gap skipping past an idle advisory-lock session, a live reserver of the leading gap still holding the mark, and a detector that never saw the pristine sequence still holding conservatively. The first two are verified RED before the fix. Co-Authored-By: Claude Opus 5 (1M context) --- docs/events/projections/async-daemon.md | 17 ++ .../Bug_5091_allocation_fence_at_mark_zero.cs | 235 ++++++++++++++++++ .../Daemon/HighWater/HighWaterDetector.cs | 56 +++-- .../HighWater/HighWaterStatisticsDetector.cs | 30 ++- 4 files changed, 316 insertions(+), 22 deletions(-) create mode 100644 src/DaemonTests/Bugs/Bug_5091_allocation_fence_at_mark_zero.cs diff --git a/docs/events/projections/async-daemon.md b/docs/events/projections/async-daemon.md index 9cc7fdc8c5..9261fe3a0c 100644 --- a/docs/events/projections/async-daemon.md +++ b/docs/events/projections/async-daemon.md @@ -99,6 +99,23 @@ Some monitoring tools erroneously report this query as "load", however this quer If this monitoring is undesirable for your scenario, you can opt-out by setting `options.Events.UseMonitoredAdvisoryLock` to false when configuring Marten. ::: +By default the `HotCold` leadership lock is transaction-scoped (`pg_try_advisory_xact_lock`), which +means the session holding it keeps a transaction open for as long as it is the leader. Set +`options.Events.UseAdvisoryLockTransaction` to false to use a session-scoped lock instead, which holds +no open transaction. + +::: warning +Prefer `UseAdvisoryLockTransaction = false` when a **single process starts more than one +daemon-hosting `IHost` over its lifetime** — the usual shape of an xUnit integration suite that boots +and tears down a host per test class. Should any leadership lock session outlive its host, a +transaction-scoped lock leaves that session `idle in transaction` for the rest of the process, and the +daemon's high-water gap detection has to treat any transaction older than a sequence gap as a +potential in-flight append it must not skip past. One such session is enough to pin the high water +mark for every later daemon in that process, which surfaces as `WaitForNonStaleProjectionDataAsync` +timing out and a repeating `Daemon high water detection is holding before the sequence gap` log. A +session-scoped lock cannot cause that, because it holds no transaction to be seen. +::: + ## Projection Distribution If your Marten store is only using a single database, Marten will distribute projections by projection type. If your store is using diff --git a/src/DaemonTests/Bugs/Bug_5091_allocation_fence_at_mark_zero.cs b/src/DaemonTests/Bugs/Bug_5091_allocation_fence_at_mark_zero.cs new file mode 100644 index 0000000000..137ec64fa5 --- /dev/null +++ b/src/DaemonTests/Bugs/Bug_5091_allocation_fence_at_mark_zero.cs @@ -0,0 +1,235 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DaemonTests.TestingSupport; +using JasperFx.Core; +using JasperFx.Events; +using Marten; +using Marten.Events.Daemon.HighWater; +using Marten.Storage; +using Marten.Testing; +using Marten.Testing.Harness; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Shouldly; +using Weasel.Postgresql; +using Xunit; + +namespace DaemonTests.Bugs; + +/// +/// #5091, follow-up to #4953/#5057. The allocation fence used to be built from the sequence's +/// RESERVED ceiling (last_value), and Postgres reports last_value = 1 for a sequence +/// nothing has drawn from yet. No poll could ever report a value at or below a stuck mark of 0, so a +/// leading gap — the mark pinned at 0 under a hole at the very start of the sequence — could never be +/// fenced, and any permanently idle open transaction held it forever. That is the shape in #5090: +/// "Daemon high water detection is holding before the sequence gap above 0". Reading is_called +/// as well gives the highest ALLOCATED value, which is 0 for a pristine sequence, and mark 0 becomes +/// fenceable without weakening the proof. +/// +public class Bug_5091_allocation_fence_at_mark_zero: DaemonContext +{ + private readonly ITestOutputHelper _output; + + public Bug_5091_allocation_fence_at_mark_zero(ITestOutputHelper output): base(output) + { + _output = output; + } + + private string Schema => theStore.Events.DatabaseSchemaName; + + private async Task openConnection() + { + var conn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await conn.OpenAsync(); + return conn; + } + + // The #5090 zombie: a session that took a transaction-scoped advisory lock and will never run + // another statement. Its xact_start predates every gap, so the unfenced liveness probe counts it + // as a possible reserver forever. + private async Task startIdleAdvisoryLockSession(long lockId) + { + var conn = await openConnection(); + await conn.BeginTransactionAsync(); + await conn.CreateCommand($"select pg_advisory_xact_lock({lockId})").ExecuteNonQueryAsync(); + return conn; + } + + private async Task appendEvents(int count) + { + await using var session = theStore.LightweightSession(); + for (var i = 0; i < count; i++) + { + session.Events.StartStream(Guid.NewGuid(), new Bug5091GapEvent(Guid.NewGuid(), i + 1)); + } + + await session.SaveChangesAsync(); + } + + private async Task scalar(string sql) + { + await using var conn = await openConnection(); + var raw = await conn.CreateCommand(sql).ExecuteScalarAsync(); + return raw is long l ? l : Convert.ToInt64(raw ?? 0L); + } + + private HighWaterDetector buildDetector() + { + return new HighWaterDetector((MartenDatabase)theStore.Tenancy.Default.Database, theStore.Events, + NullLogger.Instance); + } + + [Fact] + public async Task a_dead_leading_gap_skips_despite_an_idle_advisory_lock_session() + { + StoreOptions(opts => + { + opts.Projections.StaleSequenceThreshold = 500.Milliseconds(); + }); + theStore.EnsureStorageExists(typeof(IEvent)); + + // The zombie is already parked before anything is appended — exactly the #5090 ordering, + // where the leaked session came from an earlier host in the same process. + var listener = await startIdleAdvisoryLockSession(5091001); + try + { + var detector = buildDetector(); + + // The fence-enabling reading: a poll over a pristine sequence. last_value is 1 here, but + // is_called is false, so nothing has been handed out and the allocated high is 0. + var baseline = await detector.Detect(CancellationToken.None); + baseline.CurrentMark.ShouldBe(0); + + // seq 1 is reserved and rolled back: a permanently dead hole at the very start. + await using (var conn = await openConnection()) + { + var tx = await conn.BeginTransactionAsync(); + var seq = (long)(await conn.CreateCommand($"select nextval('{Schema}.mt_events_sequence')") + .ExecuteScalarAsync())!; + seq.ShouldBe(1); + await tx.RollbackAsync(TestContext.Current.CancellationToken); + } + + await appendEvents(3); // 2..4 committed + + // First sighting — the stale threshold is measured from here, so this one holds. + var first = await detector.DetectInSafeZone(CancellationToken.None); + first.CurrentMark.ShouldBe(0); + + await Task.Delay(700, TestContext.Current.CancellationToken); + + // Past the threshold. The only open transaction older than the gap is the idle advisory + // lock session, and it has provably executed nothing since before seq 1 was allocated, so + // it cannot be the reserver. The gap is dead: skip. + var second = await detector.DetectInSafeZone(CancellationToken.None); + _output.WriteLine($"Leading gap past threshold with idle listener: CurrentMark={second.CurrentMark}"); + second.CurrentMark.ShouldBe(4); + second.IncludesSkipping.ShouldBeTrue(); + + var persisted = await scalar( + $"select coalesce(max(last_seq_id), 0) from {Schema}.mt_event_progression where name = 'HighWaterMark'"); + persisted.ShouldBe(4); + } + finally + { + await listener.DisposeAsync(); + } + } + + [Fact] + public async Task a_live_reserver_of_the_leading_gap_still_holds_the_mark() + { + StoreOptions(opts => + { + opts.Projections.StaleSequenceThreshold = 500.Milliseconds(); + }); + theStore.EnsureStorageExists(typeof(IEvent)); + + var listener = await startIdleAdvisoryLockSession(5091002); + try + { + var detector = buildDetector(); + (await detector.Detect(CancellationToken.None)).CurrentMark.ShouldBe(0); + + // seq 1 reserved by a transaction that is still alive. It called nextval AFTER the fence, + // which bumped its state_change, so the fence must keep it even though the idle listener + // is ruled out. Fencing mark 0 must not become a licence to skip live appends. + var conn = await openConnection(); + var tx = await conn.BeginTransactionAsync(); + try + { + var seq = (long)(await conn.CreateCommand($"select nextval('{Schema}.mt_events_sequence')") + .ExecuteScalarAsync())!; + seq.ShouldBe(1); + + await appendEvents(3); // 2..4 committed + + (await detector.DetectInSafeZone(CancellationToken.None)).CurrentMark.ShouldBe(0); + + await Task.Delay(700, TestContext.Current.CancellationToken); + var held = await detector.DetectInSafeZone(CancellationToken.None); + _output.WriteLine($"Leading gap past threshold with LIVE reserver: CurrentMark={held.CurrentMark}"); + held.CurrentMark.ShouldBe(0); + + await tx.RollbackAsync(TestContext.Current.CancellationToken); + } + finally + { + await conn.DisposeAsync(); + } + + // The reserver died, so now the gap is provably dead. + await Task.Delay(200, TestContext.Current.CancellationToken); + var after = await detector.DetectInSafeZone(CancellationToken.None); + _output.WriteLine($"After reserver death: CurrentMark={after.CurrentMark}"); + after.CurrentMark.ShouldBe(4); + after.IncludesSkipping.ShouldBeTrue(); + } + finally + { + await listener.DisposeAsync(); + } + } + + [Fact] + public async Task a_detector_that_never_saw_the_pristine_sequence_still_holds_conservatively() + { + StoreOptions(opts => + { + opts.Projections.StaleSequenceThreshold = 500.Milliseconds(); + }); + theStore.EnsureStorageExists(typeof(IEvent)); + + var listener = await startIdleAdvisoryLockSession(5091003); + try + { + // The gap forms before this detector ever polls — the fresh-host-over-an-existing-database + // case. There is no proof of when seq 1 was allocated, so the idle session stays a + // candidate reserver and the documented conservative hold remains (#5090's actual shape; + // its real fix is the Weasel handle strand, not this fence). + await using (var conn = await openConnection()) + { + var tx = await conn.BeginTransactionAsync(); + await conn.CreateCommand($"select nextval('{Schema}.mt_events_sequence')").ExecuteScalarAsync(); + await tx.RollbackAsync(TestContext.Current.CancellationToken); + } + + await appendEvents(3); // 2..4 committed + + var detector = buildDetector(); + (await detector.DetectInSafeZone(CancellationToken.None)).CurrentMark.ShouldBe(0); + + await Task.Delay(700, TestContext.Current.CancellationToken); + var second = await detector.DetectInSafeZone(CancellationToken.None); + _output.WriteLine($"Fenceless leading gap past threshold: CurrentMark={second.CurrentMark}"); + second.CurrentMark.ShouldBe(0); + } + finally + { + await listener.DisposeAsync(); + } + } +} + +public record Bug5091GapEvent(Guid Id, int Number); diff --git a/src/Marten/Events/Daemon/HighWater/HighWaterDetector.cs b/src/Marten/Events/Daemon/HighWater/HighWaterDetector.cs index e708182995..d3769c476b 100644 --- a/src/Marten/Events/Daemon/HighWater/HighWaterDetector.cs +++ b/src/Marten/Events/Daemon/HighWater/HighWaterDetector.cs @@ -55,8 +55,9 @@ private sealed record TenantStaleObservation(DateTimeOffset Since, long Xmax); // store), Xmax fences the liveness probe to transactions that could have reserved the gap, and // ReservedCeiling bounds how far a proven-dead skip may advance — sequence numbers reserved AFTER // the observation belong to newer transactions whose fate is not proven. AllocationFence is the - // latest server time at which the reserved last_value was still at or below Mark (null when no - // such poll is in memory — e.g. the detector restarted while the gap already existed), letting + // latest server time at which the highest ALLOCATED sequence number was still at or below Mark + // (#5091; null when no such poll is in memory — e.g. the detector restarted while the gap already + // existed, or the store is tenant-partitioned and has no store-global allocation reading), letting // the liveness probe rule out sessions that have provably executed nothing since before the // gap's sequence numbers were even allocated. Detector-scoped state: resets on restart, which // only means a stuck gap waits one fresh threshold before skipping. @@ -65,18 +66,26 @@ private sealed record StuckGapObservation(long Mark, DateTimeOffset Since, long private StuckGapObservation? _stuckGap; - // #4953 follow-up: (server timestamp, reserved last_value) pairs from every statistics poll, so - // a stuck gap's AllocationFence can be looked up when it is first observed. Permanently-idle open - // transactions (Wolverine's advisory-lock listener sessions) otherwise satisfy the liveness - // probe's open-transaction clause forever and a genuinely dead gap never skips. Bounded and - // compacted: consecutive polls at the same last_value collapse to one entry keeping the LATEST - // timestamp (the fence wants the last moment the value was still that low). Guarded by its own - // lock — Detect (poll loop) and DetectInSafeZone (rebuild/catch-up) can run concurrently. + // #4953 follow-up: (server timestamp, highest ALLOCATED sequence number) pairs from every + // statistics poll, so a stuck gap's AllocationFence can be looked up when it is first observed. + // Permanently-idle open transactions (Wolverine's advisory-lock listener sessions, a stranded + // coordinator lock) otherwise satisfy the liveness probe's open-transaction clause forever and a + // genuinely dead gap never skips. Bounded and compacted: consecutive polls at the same value + // collapse to one entry keeping the LATEST timestamp (the fence wants the last moment the value + // was still that low). Guarded by its own lock — Detect (poll loop) and DetectInSafeZone + // (rebuild/catch-up) can run concurrently. + // + // #5091: this tracks the highest ALLOCATED value, not the reserved ceiling (last_value). Postgres + // reports last_value = 1 / is_called = false for a sequence nothing has drawn from, so a history + // of reserved ceilings never contains a reading at or below a mark of 0 and a gap stuck there + // could never be fenced — the case in #5090. The allocated high reads 0 for a pristine sequence, + // which is the reading that makes mark 0 fenceable, and it is still a sound fence: everything + // above it was handed out strictly after the poll that observed it. private readonly object _allocationHistoryLock = new(); private readonly List _allocationHistory = new(); private const int AllocationHistoryCapacity = 128; - private sealed record AllocationObservation(DateTimeOffset Timestamp, long HighestSequence); + private sealed record AllocationObservation(DateTimeOffset Timestamp, long AllocatedHigh); public HighWaterDetector(MartenDatabase runner, EventGraph graph, ILogger logger) { @@ -871,10 +880,13 @@ private async Task loadCurrentStatistics(CancellationToken // #4953 follow-up: feed the allocation history from every statistics poll — see _allocationHistory private void recordAllocationObservation(HighWaterStatistics statistics) { - // Under per-tenant event partitioning HighestSequence is the committed max(seq_id), NOT the - // reserved last_value (#4712) — "committed height <= mark at time T" says nothing about what - // was ALLOCATED by then, so no sound fence can be built from it. - if (_graph.UseTenantPartitionedEvents || statistics.Timestamp == default) + // #5091: the reading has to be the highest ALLOCATED sequence number. Under per-tenant event + // partitioning there is none to be had — tenants draw from their own sequences, and + // HighestSequence is the committed max(seq_id) (#4712), where "committed height <= mark at + // time T" says nothing about what was ALLOCATED by then. AllocatedSequenceHigh is null in + // exactly those cases, so no sound fence is built from them. + if (statistics is not MartenHighWaterStatistics marten || marten.AllocatedSequenceHigh is not { } allocatedHigh + || statistics.Timestamp == default) { return; } @@ -885,7 +897,7 @@ private void recordAllocationObservation(HighWaterStatistics statistics) if (count > 0) { var last = _allocationHistory[count - 1]; - if (statistics.HighestSequence == last.HighestSequence) + if (allocatedHigh == last.AllocatedHigh) { if (statistics.Timestamp > last.Timestamp) { @@ -897,13 +909,13 @@ private void recordAllocationObservation(HighWaterStatistics statistics) // Readings can complete out of order across concurrent callers; only ever append a // strictly newer, strictly higher reading so the list stays monotone in both fields - if (statistics.HighestSequence < last.HighestSequence || statistics.Timestamp <= last.Timestamp) + if (allocatedHigh < last.AllocatedHigh || statistics.Timestamp <= last.Timestamp) { return; } } - _allocationHistory.Add(new AllocationObservation(statistics.Timestamp, statistics.HighestSequence)); + _allocationHistory.Add(new AllocationObservation(statistics.Timestamp, allocatedHigh)); if (_allocationHistory.Count > AllocationHistoryCapacity) { _allocationHistory.RemoveAt(0); @@ -911,16 +923,18 @@ private void recordAllocationObservation(HighWaterStatistics statistics) } } - // The latest server time at which the reserved last_value was still at or below the stuck mark — - // every sequence number above the mark was allocated strictly after this moment. Null when no - // qualifying poll is in memory (detector started while the gap already existed). + // The latest server time at which the highest ALLOCATED sequence number was still at or below the + // stuck mark — every sequence number above the mark was therefore handed out strictly after this + // moment. Null when no qualifying poll is in memory (detector started while the gap already + // existed). #5091: reading allocation rather than the reserved ceiling is what lets a gap stuck at + // mark 0 be fenced at all, since a pristine sequence reserves 1 while having allocated nothing. private DateTimeOffset? findAllocationFence(long mark) { lock (_allocationHistoryLock) { for (var i = _allocationHistory.Count - 1; i >= 0; i--) { - if (_allocationHistory[i].HighestSequence <= mark) + if (_allocationHistory[i].AllocatedHigh <= mark) { return _allocationHistory[i].Timestamp; } diff --git a/src/Marten/Events/Daemon/HighWater/HighWaterStatisticsDetector.cs b/src/Marten/Events/Daemon/HighWater/HighWaterStatisticsDetector.cs index b6dabcb008..d0b2c545ed 100644 --- a/src/Marten/Events/Daemon/HighWater/HighWaterStatisticsDetector.cs +++ b/src/Marten/Events/Daemon/HighWater/HighWaterStatisticsDetector.cs @@ -18,6 +18,16 @@ namespace Marten.Events.Daemon.HighWater; internal class MartenHighWaterStatistics: HighWaterStatistics { public long CurrentXmax { get; set; } + + /// + /// #5091: the highest sequence number the event sequence has actually HANDED OUT, as opposed to + /// , which is the reserved ceiling. Postgres + /// reports last_value = 1, is_called = false for a sequence nothing has drawn from yet, so + /// the reserved ceiling never reads below 1 and a gap stuck at mark 0 could never be fenced. Null + /// when no sound reading exists — under per-tenant event partitioning the store-global sequence is + /// not the one being drawn from, so nothing can be concluded about allocation from it. + /// + public long? AllocatedSequenceHigh { get; set; } } internal class HighWaterStatisticsDetector: ISingleQueryHandler @@ -35,6 +45,18 @@ public HighWaterStatisticsDetector(EventGraph graph) ? $"(select coalesce(max(seq_id), 0) from {graph.DatabaseSchemaName}.mt_events)" : $"(select last_value from {graph.DatabaseSchemaName}.mt_events_sequence)"; + // #5091: the highest ALLOCATED sequence number, for the #4953 allocation fence. `last_value` + // is the reserved ceiling and reads 1 on a sequence nothing has drawn from yet (is_called = + // false), so a fence built from it could never be established for a gap stuck at mark 0 — + // exactly the shape in #5090. `is_called` distinguishes "1 has been handed out" from "1 is + // the next value to hand out", which makes a pristine sequence report an allocated high of 0. + // Under per-tenant partitioning the store-global sequence is not the one tenants draw from, + // so there is no sound allocation reading at all — NULL, and no fence (same posture the + // detector already takes for that case). + var allocatedHighSql = graph.UseTenantPartitionedEvents + ? "null::bigint" + : $"(select case when is_called then last_value else last_value - 1 end from {graph.DatabaseSchemaName}.mt_events_sequence)"; + // #4953: a single statement so every reading comes from ONE snapshot (see GapDetector for the // multi-statement snapshot-skew hazard this rules out). The LEFT JOIN from a one-row VALUES // clause preserves the #4712 guarantee that exactly one row always comes back with a real @@ -49,7 +71,8 @@ public HighWaterStatisticsDetector(EventGraph graph) transaction_timestamp() as ""timestamp"", pg_snapshot_xmax(pg_current_snapshot())::text::bigint as current_xmax, p.last_seq_id, - p.last_updated + p.last_updated, + {allocatedHighSql} as allocated_high from (values (1)) as one(x) left join {graph.DatabaseSchemaName}.mt_event_progression p on p.name = '{HighWaterShardIdentity.StoreGlobal}' @@ -81,6 +104,11 @@ public async Task HandleAsync(DbDataReader reader, Cancella statistics.LastUpdated = await reader.GetFieldValueAsync(4, token).ConfigureAwait(false); } + if (!await reader.IsDBNullAsync(5, token).ConfigureAwait(false)) + { + statistics.AllocatedSequenceHigh = await reader.GetFieldValueAsync(5, token).ConfigureAwait(false); + } + return statistics; } } From a0e16c578465056012166c926431800692b8a3a1 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Thu, 30 Jul 2026 10:02:39 -0500 Subject: [PATCH 2/2] Adopt JasperFx 2.36.3 and Weasel 9.20.2 (the #5090 chain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JasperFx 2.36.3 (jasperfx#592/#593): ProjectionCoordinatorBase.StartAsync cancels AND drains any existing leadership loop instead of disposing the old CancellationTokenSource without cancelling it, so a bare ResumeAsync can no longer orphan a live loop that outlives StopAsync. Weasel 9.20.2 (weasel#396/#397): AdvisoryLock can no longer strand a handle acquired after DisposeAsync drained — the actual cause of the permanently 'idle in transaction' advisory-lock session in #5090. Weasel 9.20.2 also carries weasel#399/#400, found while making this bump: 9.18.0's computed-column delta detection made TableColumn.MatchesForDelta compare through the non-virtual Equals(TableColumn), bypassing subclass overrides of Equals(object). That is the seam RevisionColumn uses to tolerate an existing bigint mt_version (#4614/#4742), so every Weasel from 9.18.0 through 9.20.1 fails Bug_4614's assert-check test with an empty change set. 9.20.2 is the first version this repo can move to. Verified on the published packages: CoreTests 494, DaemonTests 260, EventSourcingTests 1478 — all green on net10.0. Co-Authored-By: Claude Opus 5 (1M context) --- Directory.Packages.props | 42 +++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e3791bd56d..a8d6175a1c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -157,14 +157,21 @@ disposed daemon, ProjectionCoordinatorBase.PauseAsync logs ObjectDisposedException at Debug instead of Error, and ProjectionCoordinatorBase.StopAsync calls the new abstract ClearResolvedDaemons() seam after disposing daemons so subclass caches drop the disposed - instances (implemented here on ProjectionCoordinator + ExplicitProjectionCoordinator). --> - - - + instances (implemented here on ProjectionCoordinator + ExplicitProjectionCoordinator). + JasperFx 2.36.3: jasperfx#592/#593 (marten#5090) — ProjectionCoordinatorBase.StartAsync now + cancels AND drains any existing leadership loop instead of disposing the old + CancellationTokenSource without cancelling it. Disposing a CTS does not cancel it and _runner + was overwritten, so a ResumeAsync not preceded by PauseAsync orphaned a live executeAsync for + the rest of the process — one that re-attained the leadership lock right after StopAsync + released it, and that won an advisory-lock handle after the lock had been disposed. Pairs + with Weasel 9.20.1 (weasel#396), which is what makes that stranded handle impossible. --> + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -215,7 +222,7 @@ - + - - + Marten — bumped to stay current on the Weasel line (compile-verified). + 9.20.1 (weasel#396/#397, marten#5090): AdvisoryLock can no longer strand a handle acquired after + DisposeAsync drained. TryAttainLockAsync checked _disposed only at method entry and then stored the + winning handle unconditionally, so an acquire in flight during disposal put its handle where nothing + would ever dispose it — with the default transaction-scoped leadership lock that is a backend left + 'idle in transaction' on pg_try_advisory_xact_lock until the process exits, which the #4953 gap + detection then reads as a live pre-gap reserver and never advances past. The store now happens under + the same lock the drain latches under, a displaced handle (lock lost in monitored mode, re-attained) + is disposed rather than overwritten, and HasLock/ReleaseLockAsync move under that lock too. + 9.20.2 (weasel#399/#400, found here): REQUIRED to move off 9.17.0. Weasel 9.18.0's computed-column + delta detection (weasel#373) routed every column through TableColumn.MatchesForDelta, where a bare + Equals(actual) binds to the protected NON-virtual Equals(TableColumn) overload and silently bypasses + subclass overrides of Equals(object). That override is exactly how RevisionColumn declares an + existing bigint mt_version acceptable for an integer-desired column instead of emitting a lossy + narrowing cast (#4614/#4742), so on 9.18.0–9.20.1 the column landed in Columns.Different, the table + was classified Update, and AssertDatabaseMatchesConfigurationAsync threw with an EMPTY change set — + Bug_4614_revision_column_int_for_IRevisioned's assert-check test fails on every version in that + range. 9.18/9.19/9.20 are otherwise additive for Marten and ride along (compile-verified). --> + +