NEO-127: rollback duplicate grants when TryRecord loses race
Mirror EncounterCompletionOperations compensating rollback: concurrent losers undo item and skill XP applies before returning the winner event.pull/166/head
parent
c764f5af6b
commit
71fbf3d628
|
|
@ -23,7 +23,7 @@
|
||||||
- **Skill XP path** — call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`MissionRewardSkillXpConstants.MissionRewardSourceKind`** (not the fire-and-forget **`MissionRewardSkillXpGrant`** wrapper) so deny envelopes map to router reason codes.
|
- **Skill XP path** — call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`MissionRewardSkillXpConstants.MissionRewardSourceKind`** (not the fire-and-forget **`MissionRewardSkillXpGrant`** wrapper) so deny envelopes map to router reason codes.
|
||||||
- **Idempotent replay** — **`IRewardDeliveryStore.TryGet`** before apply; return success with existing **`RewardDeliveryEvent`** without re-granting (backlog “idempotent replay via store”).
|
- **Idempotent replay** — **`IRewardDeliveryStore.TryGet`** before apply; return success with existing **`RewardDeliveryEvent`** without re-granting (backlog “idempotent replay via store”).
|
||||||
- **Empty bundle** — null or both-empty grant lists → success with empty snapshots and **`TryRecord`** (NEO-126 allows empty arrays; CI guarantees all four prototype quests have bundles).
|
- **Empty bundle** — null or both-empty grant lists → success with empty snapshots and **`TryRecord`** (NEO-126 allows empty arrays; CI guarantees all four prototype quests have bundles).
|
||||||
- **Store race** — if grants succeed but **`TryRecord`** returns `false`, retain grants and treat as idempotent success (mirror [NEO-107 encounter event note](../../../server/README.md#encounter-complete-event-record-neo-107)).
|
- **Store race** — if grants succeed but **`TryRecord`** returns `false`, **rollback this call's item + skill XP grants** and return the winner's store event (mirror **`EncounterCompletionOperations`** when **`TryMarkCompleted`** loses); do not retain duplicate grants on the loser.
|
||||||
|
|
||||||
## Goal, scope, and out-of-scope
|
## Goal, scope, and out-of-scope
|
||||||
|
|
||||||
|
|
@ -52,9 +52,9 @@
|
||||||
|
|
||||||
## Implementation reconciliation (shipped)
|
## Implementation reconciliation (shipped)
|
||||||
|
|
||||||
- **Operations:** `RewardRouterOperations.TryDeliverQuestCompletion` — item pre-flight + apply, skill XP via `TryApplyGrant` (`mission_reward`), compensating item rollback, `IRewardDeliveryStore.TryRecord`.
|
- **Operations:** `RewardRouterOperations.TryDeliverQuestCompletion` — item pre-flight + apply, skill XP via `TryApplyGrant` (`mission_reward`), compensating item + skill XP rollback on apply/skill deny and on **`TryRecord`** race loss, `IRewardDeliveryStore.TryRecord`.
|
||||||
- **Types:** `RewardDeliveryResult`, `RewardDeliveryReasonCodes`.
|
- **Types:** `RewardDeliveryResult`, `RewardDeliveryReasonCodes`.
|
||||||
- **Tests:** 10 AAA tests in `RewardRouterOperationsTests` (skill-only + item+skill happy paths, inventory deny, skill deny rollback, store replay, mission_reward apply, unknown skill deny, null bundle empty record, invalid id deny).
|
- **Tests:** 12 AAA tests in `RewardRouterOperationsTests` (skill-only + item+skill happy paths, inventory deny, skill deny rollback, store replay, mission_reward apply, unknown skill deny, null bundle empty record, invalid id deny, TryRecord race rollback, concurrent race no double-grant).
|
||||||
- **Docs:** `server/README.md` reward router section; E7.M2 module anchor + alignment register updated.
|
- **Docs:** `server/README.md` reward router section; E7.M2 module anchor + alignment register updated.
|
||||||
|
|
||||||
## Technical approach
|
## Technical approach
|
||||||
|
|
@ -113,7 +113,7 @@ public static RewardDeliveryResult TryDeliverQuestCompletion(
|
||||||
- On **`ProgressionStoreMissing`**, compensating item rollback + deny.
|
- On **`ProgressionStoreMissing`**, compensating item rollback + deny.
|
||||||
- On **`Granted`**, append **`RewardSkillXpGrantApplied`**.
|
- On **`Granted`**, append **`RewardSkillXpGrantApplied`**.
|
||||||
7. **Record** — build **`RewardDeliveryEvent`** with **`DeliveredAt = timeProvider.GetUtcNow()`**, **`IdempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(...)`**, grant snapshots.
|
7. **Record** — build **`RewardDeliveryEvent`** with **`DeliveredAt = timeProvider.GetUtcNow()`**, **`IdempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(...)`**, grant snapshots.
|
||||||
8. **`deliveryStore.TryRecord`** — if `true`, return fresh success; if `false` (race), return success with **`TryGet`** event (grants already applied — idempotent success).
|
8. **`deliveryStore.TryRecord`** — if `true`, return fresh success; if `false` (race), **compensating rollback** of this call's item + skill XP grants, then return **`TryGet`** winner event with **`already_delivered`** (or deny if store miss).
|
||||||
|
|
||||||
**Compensating item rollback** — private helper mirroring **`EncounterCompletionOperations.CompensatingRemoveGrants`**: reverse-order **`TryRemoveStack`** for applied item rows only (no skill XP undo).
|
**Compensating item rollback** — private helper mirroring **`EncounterCompletionOperations.CompensatingRemoveGrants`**: reverse-order **`TryRemoveStack`** for applied item rows only (no skill XP undo).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,88 @@ public sealed class RewardRouterOperationsTests
|
||||||
Assert.False(deps.DeliveryStore.TryGet(PlayerId, " ", out _));
|
Assert.False(deps.DeliveryStore.TryGet(PlayerId, " ", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryDeliverQuestCompletion_ShouldRollbackGrants_WhenTryRecordLosesRace()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var winnerEvent = new RewardDeliveryEvent(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
DeliveredAt,
|
||||||
|
[],
|
||||||
|
[new RewardSkillXpGrantApplied("salvage", 25)],
|
||||||
|
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
|
||||||
|
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
|
||||||
|
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
||||||
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||||
|
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
||||||
|
PlayerId,
|
||||||
|
GatherQuestId,
|
||||||
|
bundle,
|
||||||
|
deps.ItemRegistry,
|
||||||
|
deps.InventoryStore,
|
||||||
|
deps.SkillRegistry,
|
||||||
|
deps.SkillProgressionStore,
|
||||||
|
deps.LevelCurve,
|
||||||
|
deps.PerkUnlockEngine,
|
||||||
|
store,
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
|
||||||
|
Assert.Equal(winnerEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey);
|
||||||
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||||
|
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
|
||||||
|
Assert.Equal(salvageBefore, salvageAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryDeliverQuestCompletion_ShouldNotDoubleGrant_WhenConcurrentRecordRaces()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var bundle = new QuestRewardBundleRow(
|
||||||
|
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||||
|
[new QuestSkillXpGrantRow("salvage", 25)]);
|
||||||
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
|
||||||
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||||
|
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
||||||
|
var kitBefore = CountBagItem(inventoryBefore!, "survey_drone_kit");
|
||||||
|
var barrier = new Barrier(2);
|
||||||
|
RewardDeliveryResult result1 = default;
|
||||||
|
RewardDeliveryResult result2 = default;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await Task.WhenAll(
|
||||||
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
barrier.SignalAndWait();
|
||||||
|
result1 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||||
|
}),
|
||||||
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
barrier.SignalAndWait();
|
||||||
|
result2 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result1.Success);
|
||||||
|
Assert.True(result2.Success);
|
||||||
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
|
||||||
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||||
|
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
|
||||||
|
Assert.Equal(salvageBefore + 25, salvageAfter);
|
||||||
|
Assert.Equal(kitBefore + 1, CountBagItem(inventoryAfter!, "survey_drone_kit"));
|
||||||
|
Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
||||||
|
}
|
||||||
|
|
||||||
private static RewardDeliveryResult Deliver(
|
private static RewardDeliveryResult Deliver(
|
||||||
RewardRouterTestDependencies deps,
|
RewardRouterTestDependencies deps,
|
||||||
string questId,
|
string questId,
|
||||||
|
|
@ -365,4 +447,28 @@ public sealed class RewardRouterOperationsTests
|
||||||
ISkillLevelCurve LevelCurve,
|
ISkillLevelCurve LevelCurve,
|
||||||
PerkUnlockEngine PerkUnlockEngine,
|
PerkUnlockEngine PerkUnlockEngine,
|
||||||
IRewardDeliveryStore DeliveryStore);
|
IRewardDeliveryStore DeliveryStore);
|
||||||
|
|
||||||
|
/// <summary>TryGet misses until after a failed <see cref="IRewardDeliveryStore.TryRecord"/> — simulates concurrent race loser.</summary>
|
||||||
|
private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore
|
||||||
|
{
|
||||||
|
private bool recordAttempted;
|
||||||
|
|
||||||
|
public bool TryRecord(RewardDeliveryEvent deliveryEvent)
|
||||||
|
{
|
||||||
|
recordAttempted = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent)
|
||||||
|
{
|
||||||
|
if (!recordAttempted)
|
||||||
|
{
|
||||||
|
deliveryEvent = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveryEvent = winnerEvent;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,12 +141,17 @@ public static class RewardRouterOperations
|
||||||
return SuccessFromEvent(deliveryEvent, reasonCode: null);
|
return SuccessFromEvent(deliveryEvent, reasonCode: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lost TryRecord race after apply — rollback this call's grants (mirror
|
||||||
|
// EncounterCompletionOperations when TryMarkCompleted returns false).
|
||||||
|
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
|
||||||
|
CompensatingRemoveSkillXp(playerId, appliedSkillXp, skillProgressionStore);
|
||||||
|
|
||||||
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent))
|
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent))
|
||||||
{
|
{
|
||||||
return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
|
return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SuccessFromEvent(deliveryEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
|
return Deny(RewardDeliveryReasonCodes.AlreadyDelivered);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string MapInventoryReason(string? reasonCode) =>
|
private static string MapInventoryReason(string? reasonCode) =>
|
||||||
|
|
@ -177,6 +182,23 @@ public static class RewardRouterOperations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void CompensatingRemoveSkillXp(
|
||||||
|
string playerId,
|
||||||
|
IReadOnlyList<RewardSkillXpGrantApplied> grants,
|
||||||
|
IPlayerSkillProgressionStore skillProgressionStore)
|
||||||
|
{
|
||||||
|
for (var i = grants.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var grant = grants[i];
|
||||||
|
_ = skillProgressionStore.TryApplyXpDelta(
|
||||||
|
playerId,
|
||||||
|
grant.SkillId,
|
||||||
|
-grant.Amount,
|
||||||
|
out _,
|
||||||
|
out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static RewardDeliveryResult Deny(string reasonCode) =>
|
private static RewardDeliveryResult Deny(string reasonCode) =>
|
||||||
new(
|
new(
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp
|
||||||
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
|
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
|
||||||
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
|
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
|
||||||
|
|
||||||
Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (race), grants are retained and the existing store event is returned. Quest-state wiring lands in NEO-128. Plan: [NEO-127 implementation plan](../../docs/plans/NEO-127-implementation-plan.md).
|
Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item + skill XP grants and returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). Quest-state wiring lands in NEO-128. Plan: [NEO-127 implementation plan](../../docs/plans/NEO-127-implementation-plan.md).
|
||||||
|
|
||||||
## Encounter definitions (NEO-103)
|
## Encounter definitions (NEO-103)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue