Skip to content

Commit 7cd8421

Browse files
authored
SEK-G30: distinguish unspecified tag reservations (#1120)
* dcb: distinguish unspecified tag reservations * test: kill nonempty reservation collapse
1 parent 5b41fb8 commit 7cd8421

28 files changed

Lines changed: 469 additions & 73 deletions

dcb/src/Sekiban.Dcb.Core/Actors/CoreGeneralSekibanExecutor.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ public async Task<ResultBox<ExecutionResult>> ExecuteAsync<TCommand>(
153153
continue; // skip non-consistency tags (no reservation)
154154
}
155155

156-
var lastSortableUniqueId = "";
156+
string? lastSortableUniqueId = null;
157157

158158
if (tag is ConsistencyTag ctWithVersion && ctWithVersion.SortableUniqueId.HasValue)
159159
{
@@ -381,15 +381,23 @@ public async Task<ResultBox<SerializableTagState>> GetSerializableTagStateAsync(
381381
}
382382
}
383383

384-
public async Task<ResultBox<SerializedCommitResult>> CommitSerializableEventsAsync(
385-
SerializedCommitRequest request,
386-
CancellationToken cancellationToken)
384+
public async Task<ResultBox<SerializedCommitResult>> CommitSerializableEventsAsync(
385+
SerializedCommitRequest request,
386+
CancellationToken cancellationToken)
387387
{
388388
var stopwatch = Stopwatch.StartNew();
389389

390-
try
391-
{
392-
if (request.EventCandidates.Count == 0)
390+
try
391+
{
392+
if (request.ConsistencyTags.Any(entry => entry.LastSortableUniqueId is null))
393+
{
394+
return ResultBox.Error<SerializedCommitResult>(
395+
new ArgumentException(
396+
"Serialized consistency-tag reservations require a non-null lastSortableUniqueId. " +
397+
"Use an empty string to assert that the tag is empty."));
398+
}
399+
400+
if (request.EventCandidates.Count == 0)
393401
{
394402
return ResultBox.FromValue(
395403
new SerializedCommitResult(

dcb/src/Sekiban.Dcb.Core/Actors/GeneralTagConsistentActor.cs

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public async Task<ResultBox<string>> GetLatestSortableUniqueIdAsync()
6666
}
6767
}
6868

69-
public async Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string lastSortableUniqueId)
69+
public async Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string? lastSortableUniqueId)
7070
{
7171
// Ensure catch-up is completed before acquiring lock
7272
await EnsureCatchUpCompletedAsync();
@@ -85,9 +85,16 @@ public async Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string la
8585
new Exception($"Tag {await GetTagActorIdAsync()} is currently reserved"));
8686
}
8787

88-
// SEK-G19: EXACT-MATCH optimistic-concurrency check after null/empty NORMALIZATION, in-lock and post-catch-up. An
88+
// SEK-G30: null means the command never observed this tag. Keep the complete reservation lifecycle, but do
89+
// not compare, refresh, or adopt a version. Empty remains G19 AssertEmpty and non-empty remains ExactMatch.
90+
if (lastSortableUniqueId is null)
91+
{
92+
return ResultBox.FromValue(await CreateReservationAsync());
93+
}
94+
95+
// SEK-G19: EXACT-MATCH optimistic-concurrency check after the SEK-G30 null branch, in-lock and post-catch-up. An
8996
// empty caller version means "I expect this tag to be EMPTY" (a first write) — NOT "skip the check". Comparing
90-
// the normalized expected against the normalized current (both null/empty collapse to "") covers all five
97+
// empty expected/current values normalized to "" covers all five
9198
// classes: empty/empty pass (first write on an empty tag); empty/non-empty CONFLICT (a second first-write against
9299
// a tag that already has committed state — the #1085 hole); non-empty/empty CONFLICT (an update expecting a
93100
// version the tag never had — the secondary hole); non-empty mismatch CONFLICT; non-empty match pass. The
@@ -131,24 +138,26 @@ public async Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string la
131138
_latestSortableUniqueId = lastSortableUniqueId;
132139
}
133140

134-
// Create new reservation
135-
var reservationCode = Guid.NewGuid().ToString();
136-
var expiredUtc = DateTime.UtcNow.AddSeconds(_options.CancellationWindowSeconds);
137-
var reservation = new TagWriteReservation(
138-
reservationCode,
139-
expiredUtc.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'"),
140-
await GetTagActorIdAsync());
141-
142-
_activeReservations[reservationCode] = reservation;
143-
144-
return ResultBox.FromValue(reservation);
141+
return ResultBox.FromValue(await CreateReservationAsync());
145142
}
146143
finally
147144
{
148145
_reservationLock.Release();
149146
}
150147
}
151148

149+
private async Task<TagWriteReservation> CreateReservationAsync()
150+
{
151+
var reservationCode = Guid.NewGuid().ToString();
152+
var expiredUtc = DateTime.UtcNow.AddSeconds(_options.CancellationWindowSeconds);
153+
var reservation = new TagWriteReservation(
154+
reservationCode,
155+
expiredUtc.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'"),
156+
await GetTagActorIdAsync());
157+
_activeReservations[reservationCode] = reservation;
158+
return reservation;
159+
}
160+
152161
/// <summary>
153162
/// Performs the bounded SEK-G22 authoritative re-check while <see cref="_reservationLock" /> is already held.
154163
/// This method deliberately does not call the catch-up path or acquire the reservation lock.

dcb/src/Sekiban.Dcb.Core/Actors/ITagConsistentActorCommon.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ public interface ITagConsistentActorCommon
1818
/// <returns>ResultBox containing the latest sortable unique ID (empty string if none) or error if something went wrong</returns>
1919
Task<ResultBox<string>> GetLatestSortableUniqueIdAsync();
2020

21-
Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string lastSortableUniqueId);
21+
/// <param name="lastSortableUniqueId">
22+
/// null for an unobserved tag (reserve without comparison), empty to assert empty, or a non-empty exact version.
23+
/// </param>
24+
Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string? lastSortableUniqueId);
2225
Task<bool> ConfirmReservationAsync(TagWriteReservation reservation);
2326
Task<bool> CancelReservationAsync(TagWriteReservation reservation);
2427

dcb/src/Sekiban.Dcb.Core/Actors/TagReservationHelper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public static class TagReservationHelper
1212
public static async Task<ResultBox<TagWriteReservation>> RequestReservationAsync(
1313
IActorObjectAccessor actorAccessor,
1414
ITag tag,
15-
string lastSortableUniqueId)
15+
string? lastSortableUniqueId)
1616
{
1717
try
1818
{

dcb/src/Sekiban.Dcb.Core/Commands/ConsistencyTagEntry.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
namespace Sekiban.Dcb.Commands;
22

33
/// <summary>
4-
/// Consistency check DTO: tag string + last SortableUniqueId for reservation.
4+
/// Consistency check DTO: tag string + last SortableUniqueId for reservation. Empty asserts that the tag is empty;
5+
/// null is invalid on the legacy/V1 serialized boundary.
56
/// Used by WASM clients to specify which tags require consistency checks.
67
/// </summary>
78
public record ConsistencyTagEntry(

dcb/src/Sekiban.Dcb.Core/Commands/CoreGeneralCommandContext.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,12 @@ public async Task<ResultBox<bool>> TagExistsAsync(ITag tag)
215215
// a first write still reserves expect-empty and a second first-write conflicts (the #1085 fix is preserved).
216216
if (!_accessedTagStates.ContainsKey(tag))
217217
{
218-
var tagContent = tag.GetTag().Substring(tag.GetTagGroup().Length + 1);
219-
_accessedTagStates[tag] = new TagState(
220-
new EmptyTagStatePayload(), 0, latestSortableUniqueId, tag.GetTagGroup(), tagContent, string.Empty, string.Empty);
218+
TrackObservedVersion(tag, latestSortableUniqueId);
221219
}
222220
return ResultBox.FromValue(true);
223221
}
224222

223+
TrackObservedVersion(tag, string.Empty);
225224
return ResultBox.FromValue(false);
226225
}
227226
catch (Exception ex)
@@ -334,4 +333,22 @@ public void ClearResults()
334333
/// </summary>
335334
[Obsolete("Use ClearResults() instead")]
336335
public void ClearAppendedEvents() => _appendedEvents.Clear();
336+
337+
private void TrackObservedVersion(ITag tag, string lastSortableUniqueId)
338+
{
339+
if (_accessedTagStates.ContainsKey(tag))
340+
{
341+
return;
342+
}
343+
344+
var tagContent = tag.GetTag().Substring(tag.GetTagGroup().Length + 1);
345+
_accessedTagStates[tag] = new TagState(
346+
new EmptyTagStatePayload(),
347+
0,
348+
lastSortableUniqueId,
349+
tag.GetTagGroup(),
350+
tagContent,
351+
string.Empty,
352+
string.Empty);
353+
}
337354
}

dcb/src/Sekiban.Dcb.Core/Commands/SerializedCommitAcceptor.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ private Task<ResultBox<SerializedCommitResult>> BindLegacyThenExecuteAsync(
7575
{
7676
return Malformed(SerializedCommitShapeError.LegacyPayloadInvalid);
7777
}
78+
if (legacy.ConsistencyTags?.Any(entry => entry.LastSortableUniqueId is null) == true)
79+
{
80+
return Malformed(SerializedCommitShapeError.LegacyPayloadInvalid);
81+
}
7882

7983
// Lift losslessly to V1 (per-event tags preserved) before execution — the legacy path is not a shortcut.
8084
var envelope = LegacyUnversionedSerializedCommitAdapter.ToVersionedV1(legacy);
@@ -100,6 +104,10 @@ private Task<ResultBox<SerializedCommitResult>> BindVersionedThenExecuteAsync(
100104
{
101105
return Malformed(SerializedCommitShapeError.VersionedPayloadInvalid);
102106
}
107+
if (envelope.ConsistencyTags?.Any(entry => entry.LastSortableUniqueId is null) == true)
108+
{
109+
return Malformed(SerializedCommitShapeError.VersionedPayloadInvalid);
110+
}
103111

104112
return ExecuteAsync(envelope, cancellationToken);
105113
}

dcb/src/Sekiban.Dcb.Orleans.Core/Grains/ITagConsistentGrain.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public interface ITagConsistentGrain : IGrainWithStringKey
2121
/// <summary>
2222
/// Make a reservation for a tag write
2323
/// </summary>
24-
Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string lastSortableUniqueId);
24+
Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string? lastSortableUniqueId);
2525

2626
/// <summary>
2727
/// Confirm a reservation

dcb/src/Sekiban.Dcb.Orleans.Core/Grains/TagConsistentGrain.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public Task<ResultBox<string>> GetLatestSortableUniqueIdAsync()
4747
return _actor.GetLatestSortableUniqueIdAsync();
4848
}
4949

50-
public Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string lastSortableUniqueId)
50+
public Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string? lastSortableUniqueId)
5151
{
5252
if (_actor == null)
5353
{

dcb/src/Sekiban.Dcb.Orleans.Core/OrleansActorObjectAccessor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ private class TagConsistentGrainWrapper : ITagConsistentActorCommon
116116

117117
public Task<ResultBox<string>> GetLatestSortableUniqueIdAsync() => _grain.GetLatestSortableUniqueIdAsync();
118118

119-
public Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string lastSortableUniqueId) =>
119+
public Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string? lastSortableUniqueId) =>
120120
_grain.MakeReservationAsync(lastSortableUniqueId);
121121

122122
public Task<bool> ConfirmReservationAsync(TagWriteReservation reservation) =>

0 commit comments

Comments
 (0)