Skip to content

Commit 102c35e

Browse files
authored
Merge pull request #242 from simplify9/hamza/fix/retry-total-attempts-cap
Enforce MaxAttemptsTotal across messages instead of per message
2 parents ae5bf12 + ca08886 commit 102c35e

36 files changed

Lines changed: 13092 additions & 233 deletions

SW.Bitween.Api/Data/BitweenDbContext.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
236236
b.HasKey(p => p.Id);
237237
b.Property(p => p.Id).IsUnicode(false).HasMaxLength(50);
238238
b.Property(p => p.On);
239-
b.Property(p => p.GroupAttemptCounts).StoreAsJson();
240239
b.HasIndex(p => p.On);
241240
});
242241

242+
modelBuilder.Entity<RetryGroupUsage>(b =>
243+
{
244+
b.ToTable("RetryGroupUsages");
245+
b.HasKey(p => new { p.SubscriptionId, p.GroupId });
246+
b.Property(p => p.AttemptsUsed);
247+
b.Property(p => p.LastAttemptOn);
248+
});
249+
243250
modelBuilder.Entity<Xchange>(b =>
244251
{
245252
b.ToTable("Xchanges");
@@ -252,7 +259,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
252259
b.Property(p => p.HandlerId).HasMaxLength(200).IsUnicode(false);
253260
b.Property(p => p.HandlerProperties).StoreAsJson();
254261
b.Property(p => p.MapperProperties).StoreAsJson();
255-
b.Property(p => p.GroupAttemptCounts).StoreAsJson();
256262
b.Property(p => p.InputContentType).IsUnicode(false).HasMaxLength(200);
257263
b.Property(p => p.ResponseMessageTypeName).IsUnicode(false).HasMaxLength(500);
258264

@@ -283,6 +289,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
283289
b.Property(p => p.ResponseName).HasMaxLength(200);
284290
b.Property(p => p.ResponseContentType).IsUnicode(false).HasMaxLength(200);
285291
b.Property(p => p.OutputContentType).IsUnicode(false).HasMaxLength(200);
292+
b.Property(p => p.RetryBlockedReason).HasMaxLength(500);
286293

287294

288295
b.HasOne<Xchange>().WithOne().HasForeignKey<XchangeResult>(p => p.Id).OnDelete(DeleteBehavior.Cascade);
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
using System;
2-
using System.Collections.Generic;
32
using SW.PrimitiveTypes;
43

54
namespace SW.Bitween.Domain;
65
// Id should be the same for xchangeId when retry happens the record is deleted
76
public class DelayedRetry : BaseEntity<string>
87
{
98
public DateTime On { get; set; }
10-
public Dictionary<string, int> GroupAttemptCounts { get; set; } = new();
119
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System;
2+
3+
namespace SW.Bitween.Domain;
4+
5+
/// <summary>
6+
/// Running total of the retries one retry group has spent for one integration, backing
7+
/// <c>RetryBudget.MaxAttemptsTotal</c>. That cap is shared by every message hitting the
8+
/// group, so it cannot be tracked on an individual xchange.
9+
/// </summary>
10+
/// <remarks>
11+
/// The total never resets on its own: once <see cref="AttemptsUsed"/> reaches the group's
12+
/// <c>MaxAttemptsTotal</c> the group stops retrying for that integration until this row is
13+
/// cleared.
14+
/// </remarks>
15+
public class RetryGroupUsage
16+
{
17+
/// <summary>The integration whose budget this is. A shared policy gives each one its own total.</summary>
18+
public int SubscriptionId { get; set; }
19+
20+
/// <summary><c>RetryGroup.Id</c>, which survives policy edits, so the total does too.</summary>
21+
public Guid GroupId { get; set; }
22+
23+
public int AttemptsUsed { get; set; }
24+
25+
/// <summary>When the last attempt was claimed — the only clue left once a group is exhausted.</summary>
26+
public DateTime LastAttemptOn { get; set; }
27+
}

SW.Bitween.Api/Domain/Xchange/Xchange.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public Xchange(Subscription subscription, XchangeFile file, string[] references
6060
}
6161

6262
//retry xchange
63-
public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup, IReadOnlyDictionary<string, int> groupAttemptCounts = null) :
63+
public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup) :
6464
this(xchange.DocumentId, workGroup, file, xchange.References)
6565
{
6666
SubscriptionId = xchange.SubscriptionId;
@@ -72,11 +72,10 @@ public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup, IReadOnl
7272
ResponseSubscriptionId = xchange.ResponseSubscriptionId;
7373
RetryFor = xchange.Id;
7474
CorrelationId = xchange.CorrelationId;
75-
GroupAttemptCounts = groupAttemptCounts == null ? null : new Dictionary<string, int>(groupAttemptCounts);
7675
}
7776

7877
//retry with reset subscription properties
79-
public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, IReadOnlyDictionary<string, int> groupAttemptCounts = null) :
78+
public Xchange(Subscription subscription, Xchange xchange, XchangeFile file) :
8079
this(xchange.DocumentId, subscription.WorkGroup, file, xchange.References)
8180
{
8281
SubscriptionId = xchange.SubscriptionId;
@@ -88,7 +87,6 @@ public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, IRe
8887
ResponseSubscriptionId = subscription.ResponseSubscriptionId;
8988
RetryFor = xchange.Id;
9089
CorrelationId = xchange.CorrelationId;
91-
GroupAttemptCounts = groupAttemptCounts == null ? null : new Dictionary<string, int>(groupAttemptCounts);
9290
}
9391

9492
public int? SubscriptionId { get; private set; }
@@ -109,6 +107,5 @@ public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, IRe
109107

110108
public string RetryFor { get; private set; }
111109
public string CorrelationId { get; set; }
112-
public IReadOnlyDictionary<string, int> GroupAttemptCounts { get; private set; }
113110
}
114111
}

SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ public XchangeResult(string xchangeId,WorkGroup workGroup, XchangeFile outputFil
6262
public bool ResponseBad { get; private set; }
6363
public string ResponseContentType { get; private set; }
6464

65+
/// <summary>
66+
/// Why the retry policy declined to schedule another attempt for this failure, or
67+
/// <c>null</c> when a retry was scheduled or no policy applied. Without it a group that
68+
/// has exhausted its budget looks identical to one that never matched.
69+
/// </summary>
70+
public string RetryBlockedReason { get; private set; }
71+
72+
/// <summary>Records the policy's refusal so it can be shown alongside the failure.</summary>
73+
public void SetRetryBlocked(string reason) => RetryBlockedReason = reason;
74+
6575

6676

6777
}

SW.Bitween.Api/Resources/RetryPolicies/Delete.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,18 @@ public async Task<object> Handle(int key)
2828
if (inUse)
2929
throw new SWException("Cannot delete a retry policy that is assigned to one or more subscriptions.");
3030

31+
// Same reason as Update: the policy's groups are about to stop existing, so clear their
32+
// usage rows rather than strand them.
33+
var policy = await _dbContext.FindAsync<RetryPolicy>(key);
34+
var groupIds = policy.Groups.Select(g => g.Id).ToList();
35+
3136
await _dbContext.DeleteByKeyAsync<RetryPolicy>(key);
37+
38+
if (groupIds.Count > 0)
39+
await _dbContext.Set<RetryGroupUsage>()
40+
.Where(u => groupIds.Contains(u.GroupId))
41+
.ExecuteDeleteAsync();
42+
3243
return null;
3344
}
3445
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Linq;
2+
using System.Threading.Tasks;
3+
using Microsoft.EntityFrameworkCore;
4+
using SW.Bitween.Domain;
5+
using SW.Bitween.Domain.Accounts;
6+
using SW.Bitween.Model;
7+
using SW.PrimitiveTypes;
8+
9+
namespace SW.Bitween.Resources.RetryPolicies;
10+
11+
/// <summary>
12+
/// Clears spent group budget, letting an exhausted group retry again. The total never resets on
13+
/// its own, so this is the only way back for an integration that has hit its ceiling.
14+
/// </summary>
15+
[HandlerName("resetusage")]
16+
public class ResetUsage : ICommandHandler<int, RetryPolicyResetUsage, object>
17+
{
18+
private readonly BitweenDbContext _dbContext;
19+
private readonly RequestContext _requestContext;
20+
21+
public ResetUsage(BitweenDbContext dbContext, RequestContext requestContext)
22+
{
23+
_dbContext = dbContext;
24+
_requestContext = requestContext;
25+
}
26+
27+
public async Task<object> Handle(int key, RetryPolicyResetUsage request)
28+
{
29+
_requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member);
30+
31+
var policy = await _dbContext.Set<RetryPolicy>().AsNoTracking()
32+
.FirstOrDefaultAsync(p => p.Id == key);
33+
if (policy == null) throw new SWNotFoundException(key.ToString());
34+
35+
// Scope the reset to this policy's own integrations and groups, so a policy id in the
36+
// route can never clear a counter belonging to a different policy.
37+
var subscriptionIds = await _dbContext.Set<Subscription>()
38+
.Where(s => s.RetryPolicyId == key)
39+
.Select(s => s.Id)
40+
.ToListAsync();
41+
42+
var groupIds = policy.Groups.Select(g => g.Id).ToList();
43+
44+
var query = _dbContext.Set<RetryGroupUsage>()
45+
.Where(u => subscriptionIds.Contains(u.SubscriptionId) && groupIds.Contains(u.GroupId));
46+
47+
if (request.SubscriptionId.HasValue)
48+
query = query.Where(u => u.SubscriptionId == request.SubscriptionId.Value);
49+
50+
if (request.GroupId.HasValue)
51+
query = query.Where(u => u.GroupId == request.GroupId.Value);
52+
53+
await query.ExecuteDeleteAsync();
54+
return null;
55+
}
56+
}

SW.Bitween.Api/Resources/RetryPolicies/Test.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public Test(RequestContext requestContext)
2121
_requestContext = requestContext;
2222
}
2323

24-
public Task<object> Handle(TestRetryPolicyRequest request)
24+
public async Task<object> Handle(TestRetryPolicyRequest request)
2525
{
2626
_requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member);
2727

@@ -30,13 +30,14 @@ public Task<object> Handle(TestRetryPolicyRequest request)
3030
"Choose Error or Bad result — a successful result is never retried.");
3131

3232
var policy = new CustomRetryPolicy { Groups = request.Groups ?? [] };
33-
var evaluator = new RetryPolicyEvaluator(policy);
33+
// In-memory budget: a dry-run must not spend any real integration's total.
34+
var evaluator = new RetryPolicyEvaluator(policy, new InMemoryRetryGroupBudget());
3435
var attemptsToSimulate = Math.Clamp(request.AttemptsToSimulate, 1, 20);
3536

3637
var attempts = new List<TestRetryAttemptResult>();
3738
for (var attemptIndex = 0; attemptIndex < attemptsToSimulate; attemptIndex++)
3839
{
39-
var decision = evaluator.Evaluate(request.ResultType, request.Content, attemptIndex);
40+
var decision = await evaluator.Evaluate(request.ResultType, request.Content, attemptIndex);
4041

4142
attempts.Add(new TestRetryAttemptResult
4243
{
@@ -53,6 +54,6 @@ public Task<object> Handle(TestRetryPolicyRequest request)
5354
if (!decision.ShouldRetry) break;
5455
}
5556

56-
return Task.FromResult<object>(new TestRetryPolicyResponse { Attempts = attempts });
57+
return new TestRetryPolicyResponse { Attempts = attempts };
5758
}
5859
}

SW.Bitween.Api/Resources/RetryPolicies/Update.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Linq;
12
using System.Threading.Tasks;
3+
using Microsoft.EntityFrameworkCore;
24
using SW.Bitween.Domain;
35
using SW.Bitween.Domain.Accounts;
46
using SW.Bitween.Model;
@@ -23,9 +25,23 @@ public async Task<object> Handle(int key, RetryPolicyUpdate model)
2325
RetryGroupValidation.EnsureCanFire(model.Groups);
2426

2527
var entity = await _dbContext.FindAsync<RetryPolicy>(key);
28+
29+
// Spent budget is keyed by group id, so a group removed here would leave usage rows
30+
// that no policy claims — invisible to the usage report and beyond the reach of reset.
31+
var removedGroupIds = entity.Groups
32+
.Select(g => g.Id)
33+
.Except((model.Groups ?? []).Select(g => g.Id))
34+
.ToList();
35+
2636
entity.Name = model.Name;
2737
entity.Groups = model.Groups ?? [];
2838
await _dbContext.SaveChangesAsync();
39+
40+
if (removedGroupIds.Count > 0)
41+
await _dbContext.Set<RetryGroupUsage>()
42+
.Where(u => removedGroupIds.Contains(u.GroupId))
43+
.ExecuteDeleteAsync();
44+
2945
return null;
3046
}
3147
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using Microsoft.EntityFrameworkCore;
5+
using SW.Bitween.Domain;
6+
using SW.Bitween.Domain.Accounts;
7+
using SW.Bitween.Model;
8+
using SW.PrimitiveTypes;
9+
10+
namespace SW.Bitween.Resources.RetryPolicies;
11+
12+
/// <summary>
13+
/// Reports how much of each group's total budget the integrations using this policy have spent,
14+
/// so an exhausted group is visible instead of just silently declining to retry.
15+
/// </summary>
16+
[HandlerName("usage")]
17+
public class Usage : ICommandHandler<int, RetryPolicyUsageRequest, object>
18+
{
19+
private readonly BitweenDbContext _dbContext;
20+
private readonly RequestContext _requestContext;
21+
22+
public Usage(BitweenDbContext dbContext, RequestContext requestContext)
23+
{
24+
_dbContext = dbContext;
25+
_requestContext = requestContext;
26+
}
27+
28+
public async Task<object> Handle(int key, RetryPolicyUsageRequest request)
29+
{
30+
_requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member);
31+
32+
var policy = await _dbContext.Set<RetryPolicy>().AsNoTracking()
33+
.FirstOrDefaultAsync(p => p.Id == key);
34+
if (policy == null) throw new SWNotFoundException(key.ToString());
35+
36+
var subscriptions = await _dbContext.Set<Subscription>().AsNoTracking()
37+
.Where(s => s.RetryPolicyId == key)
38+
.Select(s => new { s.Id, s.Name })
39+
.ToListAsync();
40+
41+
var subscriptionIds = subscriptions.Select(s => s.Id).ToList();
42+
43+
var usages = await _dbContext.Set<RetryGroupUsage>().AsNoTracking()
44+
.Where(u => subscriptionIds.Contains(u.SubscriptionId))
45+
.ToListAsync();
46+
47+
// Only groups that allow retries have a budget to spend.
48+
var budgets = policy.Groups
49+
.Where(g => g.Budget != null)
50+
.ToDictionary(g => g.Id, g => new { g.Name, g.Budget.MaxAttemptsTotal });
51+
52+
var names = subscriptions.ToDictionary(s => s.Id, s => s.Name);
53+
54+
var rows = usages
55+
.Where(u => budgets.ContainsKey(u.GroupId))
56+
.Select(u => new RetryGroupUsageRow
57+
{
58+
SubscriptionId = u.SubscriptionId,
59+
SubscriptionName = names.GetValueOrDefault(u.SubscriptionId),
60+
GroupId = u.GroupId,
61+
GroupName = budgets[u.GroupId].Name,
62+
AttemptsUsed = u.AttemptsUsed,
63+
MaxAttemptsTotal = budgets[u.GroupId].MaxAttemptsTotal,
64+
Exhausted = u.AttemptsUsed >= budgets[u.GroupId].MaxAttemptsTotal,
65+
LastAttemptOn = u.LastAttemptOn
66+
})
67+
// Exhausted integrations first — those are the ones no longer being retried.
68+
.OrderByDescending(r => r.Exhausted)
69+
.ThenByDescending(r => r.AttemptsUsed)
70+
.ToList();
71+
72+
return new List<RetryGroupUsageRow>(rows);
73+
}
74+
}

0 commit comments

Comments
 (0)