From ff8367c4696053304e50d9a3ba6d4408430cdd51 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 1 Nov 2026 15:14:45 +0300 Subject: [PATCH 01/24] feat: run bulk retry, export and bring as background requests --- .../BackgroundRequestsController.cs | 165 + .../Controllers/XchangeExportController.cs | 70 +- SW.Bitween.Api/Data/BitweenDbContext.cs | 26 +- SW.Bitween.Api/Domain/BackgroundRequest.cs | 127 + .../Resources/Xchanges/BulkRetry.cs | 243 +- .../Resources/Xchanges/BulkRetryPlanner.cs | 58 +- .../Resources/Xchanges/BulkRetryPreview.cs | 3 +- .../Resources/Xchanges/XchangeSelection.cs | 14 +- .../Services/Background/AskedRequest.cs | 145 + .../Background/BackgroundDownloads.cs | 48 + .../Background/BackgroundRequestHub.cs | 76 + .../Background/BackgroundRequestMiddleware.cs | 136 + .../Background/BackgroundRequestScope.cs | 220 + .../Services/Background/BackgroundRequests.cs | 601 +++ .../BackgroundRequestTests.cs | 380 ++ SW.Bitween.HttpTests/HttpFixture.cs | 8 +- .../SW.Bitween.HttpTests.csproj | 1 + .../Tests/BulkRetryInBackgroundTests.cs | 179 + ...01090252_AddBackgroundRequests.Designer.cs | 3573 ++++++++++++++ .../20261101090252_AddBackgroundRequests.cs | 59 + .../BitweenDbContextModelSnapshot.cs | 78 + ...01090257_AddBackgroundRequests.Designer.cs | 3564 ++++++++++++++ .../20261101090257_AddBackgroundRequests.cs | 70 + .../BitweenDbContextModelSnapshot.cs | 78 + SW.Bitween.PgSql/BitweenDbContext.cs | 20 + ...01090246_AddBackgroundRequests.Designer.cs | 4197 +++++++++++++++++ .../20261101090246_AddBackgroundRequests.cs | 63 + .../BitweenDbContextModelSnapshot.cs | 94 + SW.Bitween.Sdk/Model/Xchange.cs | 23 +- SW.Bitween.Web/Startup.cs | 17 + docs/api-reference.md | 21 +- docs/caveats.md | 12 + docs/deployment.md | 1 + docs/retries-and-alerts.md | 5 +- 34 files changed, 14258 insertions(+), 117 deletions(-) create mode 100644 SW.Bitween.Api/Controllers/BackgroundRequestsController.cs create mode 100644 SW.Bitween.Api/Domain/BackgroundRequest.cs create mode 100644 SW.Bitween.Api/Services/Background/AskedRequest.cs create mode 100644 SW.Bitween.Api/Services/Background/BackgroundDownloads.cs create mode 100644 SW.Bitween.Api/Services/Background/BackgroundRequestHub.cs create mode 100644 SW.Bitween.Api/Services/Background/BackgroundRequestMiddleware.cs create mode 100644 SW.Bitween.Api/Services/Background/BackgroundRequestScope.cs create mode 100644 SW.Bitween.Api/Services/Background/BackgroundRequests.cs create mode 100644 SW.Bitween.HttpTests/BackgroundRequestTests.cs create mode 100644 SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs create mode 100644 SW.Bitween.MsSql/Migrations/20261101090252_AddBackgroundRequests.Designer.cs create mode 100644 SW.Bitween.MsSql/Migrations/20261101090252_AddBackgroundRequests.cs create mode 100644 SW.Bitween.MySql/Migrations/20261101090257_AddBackgroundRequests.Designer.cs create mode 100644 SW.Bitween.MySql/Migrations/20261101090257_AddBackgroundRequests.cs create mode 100644 SW.Bitween.PgSql/Migrations/20261101090246_AddBackgroundRequests.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20261101090246_AddBackgroundRequests.cs diff --git a/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs b/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs new file mode 100644 index 00000000..3e1c8b8a --- /dev/null +++ b/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs @@ -0,0 +1,165 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Services.Background; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Controllers; + +/// +/// The signed-in user's background requests (see ): which +/// there are, the answer of one, and cancelling one. Nobody sees anyone else's. +/// +/// +/// A controller rather than CqApi resources, because an answer goes back exactly as the request it +/// stands in for would have sent it: its status, its content type, its bytes. +/// +[ApiController] +[Authorize] +[Route("api/background")] +public class BackgroundRequestsController(BitweenDbContext db, BackgroundRequests requests, ICloudFilesService cloudFiles, + BackgroundDownloads downloads) : ControllerBase +{ + /// Those still running, and those that finished within the half hour answers are kept. + [HttpGet] + public async Task List() + { + var owner = Owner; + if (owner == null) return Unauthorized(); + + var unfinished = await db.Set() + .Where(r => r.Owner == owner && + (r.Status == BackgroundRequestStatus.Waiting || r.Status == BackgroundRequestStatus.Running)) + .ToListAsync(); + await BackgroundRequests.EndStoppedAsync(db, unfinished); + + var now = DateTime.UtcNow; + var since = now - BackgroundRequests.KeptFor; + var rows = await db.Set().AsNoTracking() + .Where(r => r.Owner == owner && r.StartedOn > now.AddDays(-1) && (r.FinishedOn == null || r.FinishedOn > since)) + .OrderByDescending(r => r.StartedOn) + .Take(20) + .Select(r => new + { + r.Id, r.Label, r.Page, r.Path, r.Status, r.StartedOn, r.FinishedOn, r.ResultStatus, r.Done, r.Total, + r.Summary + }) + .ToListAsync(); + + return Ok(rows.Select(r => new + { + r.Id, + r.Label, + r.Page, + r.Path, + Status = r.Status.ToString(), + r.StartedOn, + r.FinishedOn, + r.ResultStatus, + r.Done, + r.Total, + r.Summary, + })); + } + + /// The answer, as the request would have given it; or why there is none. + [HttpGet("{id}/result")] + public async Task Result(string id) + { + var owner = Owner; + if (owner == null) return Unauthorized(); + + var row = await db.Set().FirstOrDefaultAsync(r => r.Id == id && r.Owner == owner); + if (row == null) + return Refusal(StatusCodes.Status404NotFound, "BACKGROUND_EXPIRED", + "This answer is no longer kept. Open the page again to ask again."); + + if (!row.Finished) await BackgroundRequests.EndStoppedAsync(db, [row]); + + // Work cancelled part way can have an answer too: what it did before it stopped. + if (row.Answered) + { + Response.StatusCode = row.ResultStatus ?? StatusCodes.Status200OK; + if (row.ResultContentType != null) Response.ContentType = row.ResultContentType; + if (row.ResultDisposition != null) Response.Headers.ContentDisposition = row.ResultDisposition; + if (row.ResultKey == null) await Response.Body.WriteAsync(row.Result ?? []); + else + { + await using var kept = await cloudFiles.OpenReadAsync(row.ResultKey); + await kept.CopyToAsync(Response.Body); + } + + return new EmptyResult(); + } + + switch (row.Status) + { case BackgroundRequestStatus.Cancelled: + return Refusal(StatusCodes.Status410Gone, "BACKGROUND_CANCELLED", "It was cancelled."); + case BackgroundRequestStatus.TimedOut: + return Refusal(StatusCodes.Status504GatewayTimeout, "BACKGROUND_TIMED_OUT", + $"It went {BackgroundRequests.Limit.TotalMinutes:n0} minutes without getting further and was stopped."); + case BackgroundRequestStatus.Stopped: + return Refusal(StatusCodes.Status503ServiceUnavailable, "BACKGROUND_STOPPED", + "The server running it stopped before it finished, as a deploy or restart does. Ask again."); + default: + return Refusal(StatusCodes.Status409Conflict, "BACKGROUND_RUNNING", "It is still running."); + } + } + + /// + /// A link to the file one of the caller's answered with - an export's zip - for the browser to download + /// the ordinary way, straight to disk. It opens for . + /// + [HttpPost("{id}/link")] + public async Task Link(string id) + { + var owner = Owner; + if (owner == null) return Unauthorized(); + + var row = await db.Set().AsNoTracking().FirstOrDefaultAsync(r => r.Id == id && r.Owner == owner); + if (row == null) + return Refusal(StatusCodes.Status404NotFound, "BACKGROUND_EXPIRED", "This file is no longer kept. Export it again."); + if (row.ResultKey == null || row.ResultStatus is not (>= 200 and < 300)) + return Refusal(StatusCodes.Status409Conflict, "BACKGROUND_NO_FILE", "It has no file to download."); + return Ok(new { url = downloads.LinkTo(id) }); + } + + /// The file a link from opens. No sign-in: the link's seal is what lets it in. + [AllowAnonymous] + [HttpGet("{id}/file")] + public async Task Download(string id, [FromQuery] string expires, [FromQuery] string seal) + { + // The same answer for a bad seal as for a missing file, so a link can't be used to probe. + if (!downloads.Opens(id, expires, seal)) return NotFound(); + var row = await db.Set().AsNoTracking().FirstOrDefaultAsync(r => r.Id == id); + if (row?.ResultKey == null) return NotFound(); + + if (row.ResultDisposition != null) Response.Headers.ContentDisposition = row.ResultDisposition; + return File(await cloudFiles.OpenReadAsync(row.ResultKey), row.ResultContentType ?? "application/octet-stream"); + } + + /// Stops one of the caller's, wherever it runs. + [HttpDelete("{id}")] + public async Task Cancel(string id) + { + var owner = Owner; + if (owner == null) return Unauthorized(); + return await requests.CancelAsync(db, owner, id) ? NoContent() : NotFound(); + } + + string Owner => User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + /// A refusal in the shape the UI reads one: the code, with what to tell the person. + static IActionResult Refusal(int status, string code, string message) => new ContentResult + { + StatusCode = status, + ContentType = "application/json", + Content = System.Text.Encoding.UTF8.GetString(BackgroundRequests.Problem(code, message)), + }; +} diff --git a/SW.Bitween.Api/Controllers/XchangeExportController.cs b/SW.Bitween.Api/Controllers/XchangeExportController.cs index 22d32355..f7b34d2d 100644 --- a/SW.Bitween.Api/Controllers/XchangeExportController.cs +++ b/SW.Bitween.Api/Controllers/XchangeExportController.cs @@ -44,11 +44,24 @@ public class XchangeExportController(BitweenDbContext dbContext, RequestContext : ControllerBase { /// - /// The most exchanges one zip takes - the bulk retry's ceiling, for the same reason: every file is - /// read from storage in sequence, inside the one request. + /// The most exchanges one zip takes when asked directly - the bulk retry's ceiling, for the same + /// reason: every file is read from storage in sequence, inside the one request. /// public const int Limit = 500; + /// + /// The most one zip takes carried on in the background, as the admin UI asks for it. The zip is built + /// on the node's temporary disk and kept in storage, and the browser downloads it from a link, straight + /// to disk; is what stops a zip of large files first. + /// + public const int MaxInBackground = 50_000; + + /// + /// The largest zip built: past it, the exchanges not yet in it are left out, and missing.txt says so. + /// Well inside the 2 GB of temporary disk the Helm chart gives a node. + /// + public const long MaxBytes = 1L << 30; + public const string MissingList = "missing.txt"; private static readonly Regex Extension = new(@"^\.[A-Za-z0-9]{1,10}$"); @@ -61,7 +74,8 @@ public class XchangeExportController(BitweenDbContext dbContext, RequestContext [HttpPost("check")] public async Task Check([FromBody] XchangeFilesExport request) { - var (ids, refusal) = await Resolve(request); + // Checked against what the admin UI's export then takes, in the background. + var (ids, refusal) = await Resolve(request, MaxInBackground); if (refusal != null) return refusal; var xchanges = await dbContext.Set().AsNoTracking().Where(x => ids.Contains(x.Id)).ToListAsync(); @@ -85,7 +99,8 @@ public async Task Check([FromBody] XchangeFilesExport request) [HttpPost] public async Task Post([FromBody] XchangeFilesExport request) { - var (ids, refusal) = await Resolve(request); + var background = Services.Background.BackgroundRequestScope.Current; + var (ids, refusal) = await Resolve(request, background != null ? MaxInBackground : Limit); if (refusal != null) return refusal; var xchanges = await dbContext.Set().AsNoTracking() @@ -118,8 +133,26 @@ await dbContext.RecordNowAsync(requestContext, OperatorAction.Export, await using (var zip = await ZipArchive.CreateAsync(body, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null, aborted)) { + // In the background, as the admin UI asks for it: says how far it has got, and a cancel + // ends it between exchanges - with what it took so far, and missing.txt saying so. + var exported = 0; foreach (var xchange in xchanges) { + background?.Progress(exported, xchanges.Count, $"{exported:n0} of {xchanges.Count:n0} exchanges"); + if (background?.CancelRequested.IsCancellationRequested == true) + { + missing.Add($"The export was cancelled after {exported} of {xchanges.Count} exchanges; the rest aren't here."); + break; + } + + if (body.Written > MaxBytes) + { + missing.Add($"The zip reached {MaxBytes / (1024 * 1024 * 1024)} GB after {exported} of {xchanges.Count} exchanges, " + + "the most one export builds; the rest aren't here. Export them separately."); + break; + } + + exported++; var mainProperty = mainProperties.GetValueOrDefault(xchange.DocumentId); var mainValue = mainProperty == null ? null : promoted.GetValueOrDefault(xchange.Id)?.Properties?.GetValueOrDefault(mainProperty); var folder = ExchangeArchive.BaseName(mainValue, xchange.Id); @@ -215,8 +248,8 @@ var type when type.StartsWith("text/") => ".txt", }; } - /// The exchanges selects, or why it can't be exported. - private async Task<(List Ids, IActionResult Refusal)> Resolve(XchangeFilesExport request) + /// The exchanges selects, up to , or why it can't be exported. + private async Task<(List Ids, IActionResult Refusal)> Resolve(XchangeFilesExport request, int limit) { try { @@ -228,13 +261,13 @@ var type when type.StartsWith("text/") => ".txt", } var selection = new XchangeSelection(dbContext); - var ids = await selection.Resolve(request, Limit); + var ids = await selection.Resolve(request, limit); if (ids.Count == 0) return (null, Refuse("NOTHING_SELECTED", "Select at least one exchange to export.")); - if (ids.Count > Limit) + if (ids.Count > limit) { - var count = string.IsNullOrWhiteSpace(request.Filter) ? ids.Count : await selection.Count(request); + var count = string.IsNullOrWhiteSpace(request.Filter) ? ids.Count : await selection.Count(request, limit); return (null, Refuse("TOO_MANY", - $"{count:n0} exchanges is more than the {Limit} one export can take. Narrow the filter and export the rest after.")); + $"{count:n0} exchanges is more than the {limit:n0} one export can take. Narrow the filter and export the rest after.")); } return (ids, null); @@ -255,6 +288,9 @@ private sealed class HeldSyncWrites(Stream response) : Stream { private readonly MemoryStream _held = new(); + /// How many bytes of zip have been written so far. + public long Written { get; private set; } + public override bool CanRead => false; public override bool CanSeek => false; public override bool CanWrite => true; @@ -266,8 +302,17 @@ public override long Position set => throw new NotSupportedException(); } - public override void Write(byte[] buffer, int offset, int count) => _held.Write(buffer, offset, count); - public override void Write(ReadOnlySpan buffer) => _held.Write(buffer); + public override void Write(byte[] buffer, int offset, int count) + { + _held.Write(buffer, offset, count); + Written += count; + } + + public override void Write(ReadOnlySpan buffer) + { + _held.Write(buffer); + Written += buffer.Length; + } /// Nothing to do until the next asynchronous write or flush. public override void Flush() @@ -278,6 +323,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella { await SendHeld(cancellationToken); await response.WriteAsync(buffer, cancellationToken); + Written += buffer.Length; } public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => diff --git a/SW.Bitween.Api/Data/BitweenDbContext.cs b/SW.Bitween.Api/Data/BitweenDbContext.cs index 1b569444..6f8c25c8 100644 --- a/SW.Bitween.Api/Data/BitweenDbContext.cs +++ b/SW.Bitween.Api/Data/BitweenDbContext.cs @@ -40,12 +40,15 @@ public class BitweenDbContext(DbContextOptions options, RequestContext requestCo /// /// Every provider's context counts its commands against the exchange stage running, so the - /// round trips an exchange costs are measured rather than estimated (see DbCommandTally). + /// round trips an exchange costs are measured rather than estimated (see DbCommandTally), + /// and lets a background request's commands run past the usual limit (see + /// BackgroundRequestScope). /// protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); - optionsBuilder.AddInterceptors(Services.Metrics.DbCommandCounter.Instance); + optionsBuilder.AddInterceptors(Services.Metrics.DbCommandCounter.Instance, + Services.Background.BackgroundRequestCommands.Instance); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -395,6 +398,25 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(u => u.KeyName).HasMaxLength(500); }); + modelBuilder.Entity(b => + { + b.ToTable("BackgroundRequests"); + b.Property(p => p.Id).HasMaxLength(32).IsUnicode(false); + b.Property(p => p.Owner).IsRequired().HasMaxLength(50); + b.Property(p => p.Path).IsRequired().HasMaxLength(4000); + b.Property(p => p.Label).HasMaxLength(200); + b.Property(p => p.Page).HasMaxLength(2000); + b.Property(p => p.Node).HasMaxLength(200).IsUnicode(false); + b.Property(p => p.ResultContentType).HasMaxLength(200).IsUnicode(false); + b.Ignore(p => p.Finished); + b.Ignore(p => p.Answered); + b.Property(p => p.Summary).HasMaxLength(500); + b.Property(p => p.ResultDisposition).HasMaxLength(500); + b.Property(p => p.ResultKey).HasMaxLength(500).IsUnicode(false); + b.HasIndex(p => new { p.Owner, p.StartedOn }); + b.HasIndex(p => p.StartedOn); + }); + modelBuilder.Entity(b => { b.ToTable("ClusterNodes"); diff --git a/SW.Bitween.Api/Domain/BackgroundRequest.cs b/SW.Bitween.Api/Domain/BackgroundRequest.cs new file mode 100644 index 00000000..4fa3d854 --- /dev/null +++ b/SW.Bitween.Api/Domain/BackgroundRequest.cs @@ -0,0 +1,127 @@ +using System; + +namespace SW.Bitween.Domain; + +/// +/// Work the admin UI asked for that took longer than it would wait for - prod's list to bring from, +/// a bulk retry, a zip of exchange files - carried on without it. The answer is kept until the UI +/// collects it, so any node can hand it over, whichever ran it. +/// +/// +/// Only what the UI asks to carry on becomes one; see +/// . +/// +public class BackgroundRequest +{ + private BackgroundRequest() + { + } + + public BackgroundRequest(string id, string owner, string path, string label, string page, string node) + { + Id = id; + Owner = owner; + Path = path; + Label = label; + Page = page; + Node = node; + Status = BackgroundRequestStatus.Waiting; + StartedOn = DateTime.UtcNow; + } + + public string Id { get; private set; } + + /// The account that asked, by its name identifier. Nobody else sees it or its answer. + public string Owner { get; private set; } + + /// What was asked: the method, path and query, as the API received them. + public string Path { get; private set; } + + /// What the UI called it, for the header. + public string Label { get; private set; } + + /// The UI address it was asked from, which is where its answer is shown. + public string Page { get; private set; } + + /// The node running it, as its heartbeat names it. + public string Node { get; private set; } + + public BackgroundRequestStatus Status { get; private set; } + public DateTime StartedOn { get; private set; } + public DateTime? FinishedOn { get; private set; } + + /// How far it has got, for work that says: done of total, and in words. + public int? Done { get; private set; } + + public int? Total { get; private set; } + public string Summary { get; private set; } + + /// The answer's HTTP status; null until there is one. + public int? ResultStatus { get; private set; } + + public string ResultContentType { get; private set; } + + /// The answer's Content-Disposition, for one that is a file to save. + public string ResultDisposition { get; private set; } + + /// A small answer, kept here. + public byte[] Result { get; private set; } + + /// A large one - a zip - kept in storage under this key instead. + public string ResultKey { get; private set; } + + public bool Finished => Status is not (BackgroundRequestStatus.Waiting or BackgroundRequestStatus.Running); + + /// Whether there is an answer to hand over, which work cancelled part way can have too. + public bool Answered => ResultStatus != null; + + public void Run() => Status = BackgroundRequestStatus.Running; + + public void Progress(int done, int total, string summary) + { + Done = done; + Total = total; + Summary = summary; + } + + /// + /// What the request answered, whatever it answered: an error is an answer too. Work its owner + /// cancelled, that stopped where it was and said how far it got, ends cancelled with its answer. + /// + public void Answer(int status, string contentType, string disposition, byte[] body, string key, + bool cancelled = false) + { + ResultStatus = status; + ResultContentType = contentType; + ResultDisposition = disposition; + Result = body; + ResultKey = key; + End(cancelled ? BackgroundRequestStatus.Cancelled : BackgroundRequestStatus.Done); + } + + public void End(BackgroundRequestStatus status) + { + Status = status; + FinishedOn = DateTime.UtcNow; + } +} + +public enum BackgroundRequestStatus +{ + /// Started, waiting for one of the node's places to run in. + Waiting = 0, + + Running = 1, + + /// Answered; the answer is kept until it expires. + Done = 2, + + /// Its owner cancelled it. + Cancelled = 3, + + /// Ran past the longest a background request may go without progress. + TimedOut = 4, + + /// The node running it stopped before it finished, as a deploy or restart does. + Stopped = 5, +} diff --git a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs index 65913c5f..dd01bd8d 100644 --- a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs +++ b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs @@ -1,8 +1,10 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; using SW.Bitween.Model; +using SW.Bitween.Services.Background; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.Xchanges @@ -12,9 +14,30 @@ namespace SW.Bitween.Resources.Xchanges /// matches. Returns what it did: answers the same question /// beforehand, so the caller can show it and be sure the two agree. /// + /// + /// Asked to carry on in the background, as the admin UI asks it, it takes the whole selection a + /// preview works out - up to - saving as it goes, saying + /// how far it has got, and stopping between exchanges when cancelled. It goes at the pipeline's pace: + /// no more than of its exchanges wait to be processed at once, so a retry of + /// hundreds of thousands never floods the queues, and the partners behind them, and the day's own + /// traffic is never more than that many behind it. Asked directly, it keeps to + /// , as it always has. + /// [HandlerName("bulkretry")] public class BulkRetry : ICommandHandler { + /// + /// How many exchanges go into one save. Each save is its own transaction, so a retry that + /// stops part way keeps what it did; and no more than this many are held in memory at once. + /// + private const int SaveEvery = 100; + + /// The most of a background retry's exchanges waiting to be processed at once. Set lower by tests. + internal static int Waiting { get; set; } = 1_000; + + /// How often a retry held back by looks again. Set shorter by tests. + internal static System.TimeSpan LookEvery { get; set; } = System.TimeSpan.FromSeconds(1); + private readonly BitweenDbContext _dbContext; private readonly RequestContext _requestContext; private readonly XchangeService _xchangeService; @@ -32,102 +55,174 @@ public async Task Handle(XchangeBulkRetry request) { await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Exchanges.Operate); - var prepared = await new BulkRetryPlanner(_dbContext).Prepare(request, BulkRetryPlanner.Limit); + var background = BackgroundRequestScope.Current; + var upTo = background != null ? BulkRetryPlanner.MaxSelection : BulkRetryPlanner.Limit; + var prepared = await new BulkRetryPlanner(_dbContext).Prepare(request, upTo, listTargets: background == null); var plan = prepared.Plan; if (plan.OverLimit) - throw new SWValidationException("TOO_MANY", - $"{plan.Selected:n0} exchanges is more than the {BulkRetryPlanner.Limit} this can retry " + - $"in one go. Send the preview's targets back in rounds of {BulkRetryPlanner.Limit}."); + throw new SWValidationException("TOO_MANY", background != null + ? $"{plan.Selected:n0} exchanges is more than the {BulkRetryPlanner.MaxSelection:n0} one retry " + + "takes. Narrow the selection." + : $"{plan.Selected:n0} exchanges is more than the {BulkRetryPlanner.Limit} this can retry " + + $"in one go. Send the preview's targets back in rounds of {BulkRetryPlanner.Limit}."); + var total = prepared.Targets.Count; var retried = 0; + var handled = 0; + // The retries this run made that the pipeline hasn't finished, and this save's targets. + var unfinished = new List(); + var batch = new List(); + background?.Progress(0, total, Said(0, plan)); foreach (var id in prepared.Targets) { - var xchange = await _dbContext.Set().AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == id); - if (xchange == null) + // Between exchanges only, so the one under way is either done or not started. + if (background?.CancelRequested.IsCancellationRequested == true || + background != null && !await RoomFor(unfinished, background, handled, total, retried, plan)) { - plan.Skipped.Add(new XchangeRetrySkip - { - Id = id, - Reason = "This exchange no longer exists." - }); - continue; + plan.StoppedAfter = handled; + break; } - var subscription = await _dbContext.Subscriptions() - .FirstOrDefaultAsync(s => s.Id == xchange.SubscriptionId); + if (await RetryOne(id, request, plan)) retried++; + handled++; + batch.Add(id); + + if (handled % SaveEvery != 0) continue; + await _dbContext.SaveChangesAsync(); + _dbContext.ChangeTracker.Clear(); + if (background != null) unfinished.AddRange(await RetriesOf(batch)); + batch.Clear(); + background?.Progress(handled, total, Said(retried, plan)); + } + + if (retried > 0) + _dbContext.Record(_requestContext, OperatorAction.BulkRetry, $"{retried} exchanges", + $"{retried} retried, {plan.SkippedCount} skipped" + + (plan.StoppedAfter is { } stopped ? $", cancelled after {stopped} of {total}" : "")); + await _dbContext.SaveChangesAsync(); + + plan.WillRetry = retried; + background?.Progress(handled, total, Said(retried, plan)); + return plan; + } + + /// How far it has got, in the words the header shows. + private static string Said(int retried, XchangeBulkRetryPlan plan) => + $"{retried:n0} retried, {plan.SkippedCount:n0} skipped"; - if (request.Reset && subscription == null) + /// + /// Waits while of this run's retries are still to be processed. Each one + /// the pipeline finishes is progress, which keeps the retry's limit from running out; a pipeline + /// that finishes none for the whole limit - stopped, or a paused subscription holding them - ends + /// it there. False when its owner cancels it meanwhile. + /// + private async Task RoomFor(List unfinished, BackgroundRequestScope background, int handled, + int total, int retried, XchangeBulkRetryPlan plan) + { + while (unfinished.Count >= Waiting) + { + var finished = (await _dbContext.Set().AsNoTracking() + .Where(r => unfinished.Contains(r.Id)).Select(r => r.Id).ToListAsync()).ToHashSet(); + if (finished.Count > 0) { - // Reported per exchange rather than thrown. Throwing meant one exchange whose - // subscription had since been deleted took the whole selection down with it, - // and the caller could not tell which one. - plan.Skipped.Add(new XchangeRetrySkip - { - Id = id, - Reason = "Its properties cannot be reset: the subscription no longer exists." - }); + unfinished.RemoveAll(finished.Contains); + background.Progress(handled, total, + $"{Said(retried, plan)} · waiting for the pipeline, {unfinished.Count:n0} to go"); continue; } - // The tolerant read, not GetFile: a retry re-sends the original input, so an - // exchange whose input has been deleted or expired cannot be retried - and one of - // those in a selection of five hundred must not take the other 499 with it. - var xchangeFile = await _xchangeService.ReadInputFile(xchange); - if (xchangeFile == null) + if (background.CancelRequested.IsCancellationRequested) return false; + await Task.Delay(LookEvery, background.Stopped); + } + + return true; + } + + /// The retries just saved for . + private Task> RetriesOf(List targets) => + _dbContext.Set().AsNoTracking() + .Where(x => targets.Contains(x.RetryFor)).Select(x => x.Id).ToListAsync(); + + /// Retries one target, or says in the plan why it didn't; true when it did. + private async Task RetryOne(string id, XchangeBulkRetry request, XchangeBulkRetryPlan plan) + { + var xchange = await _dbContext.Set().AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id); + if (xchange == null) + { + BulkRetryPlanner.Skip(plan, new XchangeRetrySkip + { + Id = id, + Reason = "This exchange no longer exists." + }); + return false; + } + + var subscription = await _dbContext.Subscriptions() + .FirstOrDefaultAsync(s => s.Id == xchange.SubscriptionId); + + if (request.Reset && subscription == null) + { + // Reported per exchange rather than thrown. Throwing meant one exchange whose + // subscription had since been deleted took the whole selection down with it, + // and the caller could not tell which one. + BulkRetryPlanner.Skip(plan, new XchangeRetrySkip { - plan.Skipped.Add(new XchangeRetrySkip - { - Id = id, - Reason = "Its input document could not be read, so there is nothing to re-send." - }); - continue; - } + Id = id, + Reason = "Its properties cannot be reset: the subscription no longer exists." + }); + return false; + } + + // The tolerant read, not GetFile: a retry re-sends the original input, so an + // exchange whose input has been deleted or expired cannot be retried - and one of + // those in a selection of five hundred must not take the other 499 with it. + var xchangeFile = await _xchangeService.ReadInputFile(xchange); + if (xchangeFile == null) + { + BulkRetryPlanner.Skip(plan, new XchangeRetrySkip + { + Id = id, + Reason = "Its input document could not be read, so there is nothing to re-send." + }); + return false; + } - try + try + { + if (request.Reset) { - if (request.Reset) - { - await _xchangeService.CreateXchange(subscription, xchange, xchangeFile, - manualRetry: true); - } - else - { - // Null when the subscription has since been deleted, which a document-only - // exchange also has from the start. The single-exchange retry has always allowed - // for it; without the same here, one such id in a selection threw and took the - // whole bulk retry down with it. - await _xchangeService.CreateXchange(xchange, xchangeFile, subscription?.WorkGroup, - manualRetry: true); - } + await _xchangeService.CreateXchange(subscription, xchange, xchangeFile, + manualRetry: true); } - catch (SWValidationException e) when ( - e.Validations.Any(v => v.Key == "ALREADY_RETRIED")) + else { - // The planner resolves every selection to the end of its chain, so this is not - // reachable by choosing badly - it means someone retried this attempt in the - // moment between the plan being worked out and it being carried out. Reported - // like the other per-exchange refusals rather than thrown, so one racing - // operator cannot cancel another's whole recovery. - plan.Skipped.Add(new XchangeRetrySkip - { - Id = id, - Reason = "It was retried by someone else a moment ago. Its own retry can be retried instead." - }); - continue; + // Null when the subscription has since been deleted, which a document-only + // exchange also has from the start. The single-exchange retry has always allowed + // for it; without the same here, one such id in a selection threw and took the + // whole bulk retry down with it. + await _xchangeService.CreateXchange(xchange, xchangeFile, subscription?.WorkGroup, + manualRetry: true); } - - retried++; + } + catch (SWValidationException e) when ( + e.Validations.Any(v => v.Key == "ALREADY_RETRIED")) + { + // The planner resolves every selection to the end of its chain, so this is not + // reachable by choosing badly - it means someone retried this attempt in the + // moment between the plan being worked out and it being carried out. Reported + // like the other per-exchange refusals rather than thrown, so one racing + // operator cannot cancel another's whole recovery. + BulkRetryPlanner.Skip(plan, new XchangeRetrySkip + { + Id = id, + Reason = "It was retried by someone else a moment ago. Its own retry can be retried instead." + }); + return false; } - if (retried > 0) - _dbContext.Record(_requestContext, OperatorAction.BulkRetry, $"{retried} exchanges", - $"{retried} retried, {plan.Skipped?.Count ?? 0} skipped"); - await _dbContext.SaveChangesAsync(); - - plan.WillRetry = retried; - return plan; + return true; } } } diff --git a/SW.Bitween.Api/Resources/Xchanges/BulkRetryPlanner.cs b/SW.Bitween.Api/Resources/Xchanges/BulkRetryPlanner.cs index 8fc46cf0..974ff07b 100644 --- a/SW.Bitween.Api/Resources/Xchanges/BulkRetryPlanner.cs +++ b/SW.Bitween.Api/Resources/Xchanges/BulkRetryPlanner.cs @@ -20,23 +20,28 @@ namespace SW.Bitween.Resources.Xchanges; internal sealed class BulkRetryPlanner { /// - /// The largest selection one request will carry out. Every exchange retried means reading its - /// input back out of storage and writing a new exchange, in sequence, inside the one request - - /// so the ceiling is about what can finish before something upstream gives up waiting, not - /// about the database. A bigger selection is retried in rounds of this many. + /// The largest selection a retry asked directly carries out. Every exchange retried means reading + /// its input back out of storage and writing a new exchange, in sequence, inside the one request - + /// so the ceiling is about what can finish before something upstream gives up waiting, not about + /// the database. A bigger selection is retried in rounds of this many, or in the background. /// internal const int Limit = 500; /// - /// The largest selection a preview works out. Up to it, the plan lists every attempt that will - /// run, and the caller sends them back in rounds of . Past it the caller is - /// asked to narrow the filter. + /// The largest selection a preview works out, and a retry in the background carries out - as the + /// admin UI asks it. Past it the caller is asked to narrow the selection. /// /// - /// Rounds rather than raising : each round is its own request, so none of them - /// runs long, and a round that fails leaves the ones before it done. + /// A retry this size doesn't put it all on the queues at once: it keeps 's + /// window of exchanges waiting at most, so it goes at the pipeline's pace. /// - internal const int MaxSelection = 5000; + internal const int MaxSelection = 500_000; + + /// + /// How many substitutions, and how many skips, a plan names; the rest are counted. A selection of + /// hundreds of thousands would otherwise answer with tens of megabytes no one reads past the top. + /// + internal const int Listed = 1_000; /// /// Guards the walk to the newest attempt against a cycle in the data, the same way @@ -59,7 +64,11 @@ internal sealed class Prepared /// The largest selection to work out: for a retry, /// for a preview. /// - internal async Task Prepare(XchangeBulkRetry request, int upTo) + /// + /// Whether the plan lists , for a caller retrying in rounds; + /// one retrying in the background doesn't need them. + /// + internal async Task Prepare(XchangeBulkRetry request, int upTo, bool listTargets = true) { var selection = new XchangeSelection(_dbContext); var selected = await selection.Resolve(request, upTo); @@ -75,7 +84,7 @@ internal async Task Prepare(XchangeBulkRetry request, int upTo) // than just "501", and never a count of rows it did not ask about. Selected = string.IsNullOrWhiteSpace(request.Filter) ? selected.Count - : await selection.Count(request), + : await selection.Count(request, upTo), Limit = Limit, MaxSelection = MaxSelection, OverLimit = true @@ -86,7 +95,10 @@ internal async Task Prepare(XchangeBulkRetry request, int upTo) var state = await ReadState(newestAttempt.Values.Distinct().ToList()); var reset = request.Reset; - var plan = new XchangeBulkRetryPlan { Selected = selected.Count, Limit = Limit, MaxSelection = MaxSelection }; + var plan = new XchangeBulkRetryPlan + { + Selected = selected.Count, Limit = Limit, MaxSelection = MaxSelection, Listed = Listed + }; var targets = new List(); // The list keeps the order; the set answers "already in it" without walking up to MaxSelection entries. var targeted = new HashSet(); @@ -96,7 +108,7 @@ internal async Task Prepare(XchangeBulkRetry request, int upTo) var targetId = newestAttempt[selectedId]; if (targetId != selectedId) - plan.Substituted.Add(new XchangeRetrySubstitution + Substitute(plan, new XchangeRetrySubstitution { SelectedId = selectedId, RetryId = targetId @@ -105,7 +117,7 @@ internal async Task Prepare(XchangeBulkRetry request, int upTo) var reason = WhyNot(state.GetValueOrDefault(targetId), targetId != selectedId, reset); if (reason != null) { - plan.Skipped.Add(new XchangeRetrySkip { Id = targetId, Reason = reason }); + Skip(plan, new XchangeRetrySkip { Id = targetId, Reason = reason }); continue; } @@ -116,11 +128,25 @@ internal async Task Prepare(XchangeBulkRetry request, int upTo) } plan.WillRetry = targets.Count; - plan.Targets = targets; + if (listTargets) plan.Targets = targets; plan.Properties = await ReadPromotedProperties(plan); return new Prepared { Plan = plan, Targets = targets }; } + /// Counts a skip, and names it while fewer than are named. + internal static void Skip(XchangeBulkRetryPlan plan, XchangeRetrySkip skip) + { + plan.SkippedCount++; + if (plan.Skipped.Count < Listed) plan.Skipped.Add(skip); + } + + /// Counts a substitution, and names it while fewer than are named. + private static void Substitute(XchangeBulkRetryPlan plan, XchangeRetrySubstitution substitution) + { + plan.SubstitutedCount++; + if (plan.Substituted.Count < Listed) plan.Substituted.Add(substitution); + } + /// /// Why this attempt will be left alone, or null when it will be retried. The wording /// says whose fault it is: the exchange the caller picked, or the later attempt standing in diff --git a/SW.Bitween.Api/Resources/Xchanges/BulkRetryPreview.cs b/SW.Bitween.Api/Resources/Xchanges/BulkRetryPreview.cs index b9cc69bf..389a7c75 100644 --- a/SW.Bitween.Api/Resources/Xchanges/BulkRetryPreview.cs +++ b/SW.Bitween.Api/Resources/Xchanges/BulkRetryPreview.cs @@ -36,7 +36,8 @@ public async Task Handle(XchangeBulkRetry request) // describes an action only they can take, so it is gated with the action. await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Exchanges.Operate); - var prepared = await new BulkRetryPlanner(_dbContext).Prepare(request, BulkRetryPlanner.MaxSelection); + var prepared = await new BulkRetryPlanner(_dbContext).Prepare(request, BulkRetryPlanner.MaxSelection, + listTargets: Services.Background.BackgroundRequestScope.Current == null); return prepared.Plan; } } diff --git a/SW.Bitween.Api/Resources/Xchanges/XchangeSelection.cs b/SW.Bitween.Api/Resources/Xchanges/XchangeSelection.cs index ef1335d8..5ead4642 100644 --- a/SW.Bitween.Api/Resources/Xchanges/XchangeSelection.cs +++ b/SW.Bitween.Api/Resources/Xchanges/XchangeSelection.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -36,16 +37,19 @@ internal async Task> Resolve(IXchangeSelection request, int limit) .ToListAsync(); } - /// How many exchanges a filter's selection really holds, for saying how far past a limit it is. - internal async Task Count(IXchangeSelection request) + /// + /// How many exchanges a filter's selection really holds, for saying how far past + /// it is: counted to ten times past it at most. + /// + internal async Task Count(IXchangeSelection request, int limit) { var exclude = request.ExcludeIds ?? new List(); return await (await Query(request)) .Where(r => !exclude.Contains(r.Id)) // Same reason the search caps its own count: counting every match has to visit every - // matching row. The number is only being used to say "too many", so stopping early - // costs the caller nothing. - .Take(Search.CountCap + 1) + // matching row. The number is only being used to say "too many", so it stops well past + // the limit - far enough that the number said is never one under it. + .Take(Math.Max(Search.CountCap, limit * 10) + 1) .CountAsync(); } diff --git a/SW.Bitween.Api/Services/Background/AskedRequest.cs b/SW.Bitween.Api/Services/Background/AskedRequest.cs new file mode 100644 index 00000000..d6184e67 --- /dev/null +++ b/SW.Bitween.Api/Services/Background/AskedRequest.cs @@ -0,0 +1,145 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Net; +using System.Security.Claims; +using System.Threading; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; + +namespace SW.Bitween.Services.Background; + +/// +/// What a request asked, copied off it, so it can be answered after the request that brought it has +/// ended and its context has been handed to the next caller. +/// +/// +/// Taken once routing, authentication and authorization have run, so a new context made from it +/// carries their outcome - the endpoint, the user, the markers the endpoint middleware checks for - +/// and only what follows them runs again. That is everything from the request context onwards: the +/// handler, model binding and serialization, the same as for any request. +/// +internal sealed class AskedRequest +{ + string method, scheme, protocol, traceId; + HostString host; + PathString pathBase, path; + QueryString query; + HeaderDictionary headers; + ClaimsPrincipal user; + (object Key, object Value)[] markers; + Endpoint endpoint; + RouteValueDictionary routeValues; + IAuthenticateResultFeature authenticated; + IPAddress remoteIp, localIp; + int remotePort, localPort; + byte[] sent; + + /// The largest body a request carried on in the background may send: a selection, not a file. + public const int MaxBody = 4 * 1024 * 1024; + + /// + /// What asked, its body read whole; null for a body too big to carry, + /// which is then asked the usual way. + /// + public static async Task OfAsync(HttpContext context) + { + var request = context.Request; + byte[] sent = []; + if (request.ContentLength is > 0 || request.Headers.TransferEncoding.Count > 0) + { + if (request.ContentLength is > MaxBody) return null; + // Rewound after, so a body too big to carry can still be read by the request as usual. + request.EnableBuffering(); + using var copy = new MemoryStream(); + await request.Body.CopyToAsync(copy, context.RequestAborted); + request.Body.Position = 0; + if (copy.Length > MaxBody) return null; + sent = copy.ToArray(); + } + + return new AskedRequest + { + sent = sent, + method = request.Method, + scheme = request.Scheme, + protocol = request.Protocol, + traceId = context.TraceIdentifier, + host = request.Host, + pathBase = request.PathBase, + path = request.Path, + query = request.QueryString, + headers = new HeaderDictionary(request.Headers.ToDictionary(h => h.Key, h => h.Value)), + user = context.User, + // Only the "this middleware ran" markers: the endpoint middleware refuses an endpoint that + // asks for authorization when the authorization middleware has not marked the request. The + // rest of Items belongs to the request it was set on. + markers = context.Items + .Where(i => i.Key is string key && key.StartsWith("__") && key.EndsWith("MiddlewareWithEndpointInvoked")) + .Select(i => (i.Key, i.Value)) + .ToArray(), + endpoint = context.GetEndpoint(), + routeValues = new RouteValueDictionary(request.RouteValues), + authenticated = context.Features.Get(), + remoteIp = context.Connection.RemoteIpAddress, + localIp = context.Connection.LocalIpAddress, + remotePort = context.Connection.RemotePort, + localPort = context.Connection.LocalPort, + }; + } + + /// The path and query, as asked. + public string PathAndQuery => (pathBase + path + query).ToString(); + + /// Whether it only reads, and so can be asked again from the start. + public bool IsRead => HttpMethods.IsGet(method); + + /// What it asked, as the list of background requests says it: the path, after the method unless a read. + public string Described => IsRead ? PathAndQuery : $"{method} {PathAndQuery}"; + + /// + /// A new context asking the same thing, writing its answer to , and told it + /// was aborted when is cancelled. + /// + public DefaultHttpContext NewContext(System.IServiceProvider services, Stream body, CancellationToken aborted, int attempt) + { + var features = new FeatureCollection(); + features.Set(new HttpRequestFeature + { + Method = method, + Scheme = scheme, + Protocol = protocol, + PathBase = pathBase.Value ?? "", + Path = path.Value ?? "", + QueryString = query.Value ?? "", + RawTarget = PathAndQuery, + Headers = new HeaderDictionary(headers.ToDictionary(h => h.Key, h => h.Value)), + Body = new MemoryStream(sent, writable: false), + }); + features.Set(new HttpResponseFeature()); + features.Set(new StreamResponseBodyFeature(body)); + features.Set(new HttpRequestLifetimeFeature { RequestAborted = aborted }); + features.Set(new HttpConnectionFeature + { + RemoteIpAddress = remoteIp, + LocalIpAddress = localIp, + RemotePort = remotePort, + LocalPort = localPort, + }); + if (authenticated != null) features.Set(authenticated); + + var context = new DefaultHttpContext(features) + { + User = user, + RequestServices = services, + TraceIdentifier = $"{traceId}:{attempt}", + }; + context.Request.Host = host; + foreach (var (key, value) in markers) context.Items[key] = value; + context.SetEndpoint(endpoint); + context.Request.RouteValues = new RouteValueDictionary(routeValues); + return context; + } +} diff --git a/SW.Bitween.Api/Services/Background/BackgroundDownloads.cs b/SW.Bitween.Api/Services/Background/BackgroundDownloads.cs new file mode 100644 index 00000000..1d4cd6a1 --- /dev/null +++ b/SW.Bitween.Api/Services/Background/BackgroundDownloads.cs @@ -0,0 +1,48 @@ +using System; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Configuration; + +namespace SW.Bitween.Services.Background; + +/// +/// Links to a background request's file - an export's zip - that a browser can download the ordinary +/// way, straight to disk, rather than holding it in memory first: a browser following a link can't send +/// the sign-in header. +/// +/// +/// A link carries its expiry and a seal over the request's id and that expiry, made with a secret +/// derived from Token:Key, as are: any node can check it, and changing a +/// character of it breaks it. Only the request's owner is handed one, and it opens that one file for +/// . +/// +public class BackgroundDownloads(IConfiguration configuration) +{ + /// How long a link opens its file: long enough to start the download it was asked for. + public static readonly TimeSpan Lasts = TimeSpan.FromMinutes(10); + + private readonly byte[] key = HKDF.DeriveKey(HashAlgorithmName.SHA256, + Encoding.UTF8.GetBytes(configuration["Token:Key"] ?? string.Empty), 32, + info: Encoding.UTF8.GetBytes("bitween:background-downloads:v1")); + + /// The address, under /api, that downloads 's file until it expires. + public string LinkTo(string id) + { + var expires = DateTimeOffset.UtcNow.Add(Lasts).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + return $"/api/background/{id}/file?expires={expires}&seal={Seal(id, expires)}"; + } + + /// Whether a link's expiry and seal open 's file now. + public bool Opens(string id, string expires, string seal) + { + if (string.IsNullOrEmpty(seal) || !long.TryParse(expires, NumberStyles.None, CultureInfo.InvariantCulture, out var at) || + DateTimeOffset.UtcNow.ToUnixTimeSeconds() > at) + return false; + return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(Seal(id, expires)), Encoding.ASCII.GetBytes(seal)); + } + + private string Seal(string id, string expires) => + Convert.ToBase64String(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}|{expires}"))) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/SW.Bitween.Api/Services/Background/BackgroundRequestHub.cs b/SW.Bitween.Api/Services/Background/BackgroundRequestHub.cs new file mode 100644 index 00000000..5117ad8e --- /dev/null +++ b/SW.Bitween.Api/Services/Background/BackgroundRequestHub.cs @@ -0,0 +1,76 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using SW.Bitween.Domain; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services.Background; + +/// +/// Where the admin UI hears that one of its background requests changed. It only listens: what it +/// does about it - collect the answer, cancel - goes through /api/background. +/// +/// +/// Each connection is its signed-in user's, by the token's name identifier, which is what +/// sends to; a node knows only the connections it holds, so every +/// change is also broadcast for the others to pass on. +/// +[Authorize] +public class BackgroundRequestHub : Hub +{ + public const string Path = "/api/background/hub"; + + /// The message: { id, status }. + public const string Changed = "changed"; +} + +/// A background request changed, said to every node, for each to tell its own connections. +public class BackgroundRequestChanged +{ + public string Id { get; set; } + public string Owner { get; set; } + public BackgroundRequestStatus Status { get; set; } + + /// How far it got, for work that counts its steps. + public int? Done { get; set; } + + public int? Total { get; set; } + public string Summary { get; set; } + + /// The that sent it, which told its own already. + public string Node { get; set; } + + public static BackgroundRequestChanged Of(BackgroundRequest row, string node) => new() + { + Id = row.Id, + Owner = row.Owner, + Status = row.Status, + Done = row.Done, + Total = row.Total, + Summary = row.Summary, + Node = node, + }; +} + +/// Its owner cancelled a background request, said to every node; the one running it stops it. +public class BackgroundRequestCancel +{ + public string Id { get; set; } +} + +public class BackgroundRequestChangedListener(BackgroundRequests requests) : IListen +{ + public Task Process(BackgroundRequestChanged message) => + message == null || message.Node == requests.Node + ? Task.CompletedTask + : requests.PushAsync(message); +} + +public class BackgroundRequestCancelListener(BackgroundRequests requests) : IListen +{ + public Task Process(BackgroundRequestCancel message) + { + if (message?.Id != null) requests.CancelHere(message.Id); + return Task.CompletedTask; + } +} diff --git a/SW.Bitween.Api/Services/Background/BackgroundRequestMiddleware.cs b/SW.Bitween.Api/Services/Background/BackgroundRequestMiddleware.cs new file mode 100644 index 00000000..71a1f0d8 --- /dev/null +++ b/SW.Bitween.Api/Services/Background/BackgroundRequestMiddleware.cs @@ -0,0 +1,136 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; + +namespace SW.Bitween.Services.Background; + +/// +/// Lets work that takes long go on in the background instead of holding its caller: a GET or POST under +/// /api whose caller sent Prefer: respond-async is answered as usual when it answers within +/// the caller's wait (three seconds unless it says), and otherwise with 202 Accepted and the +/// id of a background request that goes on running. The admin UI asks it only of the work that can +/// take long - prod's list to bring from, bringing, bulk retry, exporting files. Every other request +/// passes through untouched - the UI's other pages, the CLI, scripts, another Bitween - so they keep the +/// limits they always had. +/// +/// +/// Sits after authentication and authorization, whose outcome the background request keeps, and before +/// the request context is filled in, which the background request does again for itself; see +/// . +/// +public class BackgroundRequestMiddleware(RequestDelegate next, BackgroundRequests requests) +{ + /// What the UI calls the request, for the header. URL-encoded, being free text. + public const string LabelHeader = "X-Bitween-Label"; + + /// The UI address the request was made from. URL-encoded. + public const string PageHeader = "X-Bitween-Page"; + + static readonly TimeSpan DefaultWait = TimeSpan.FromSeconds(3); + static readonly TimeSpan LongestWait = TimeSpan.FromSeconds(30); + + public async Task InvokeAsync(HttpContext context) + { + if (!Wanted(context, out var wait, out var owner)) + { + await next(context); + return; + } + + var asked = await AskedRequest.OfAsync(context); + if (asked == null) + { + await next(context); + return; + } + + var work = requests.Start(asked, next, owner, Header(context, LabelHeader), Header(context, PageHeader)); + + BackgroundRequests.Answer answer = null; + if (await Task.WhenAny(work.AnsweredHere.Task, Task.Delay(wait, context.RequestAborted)) == work.AnsweredHere.Task) + answer = await work.AnsweredHere.Task; + else if (context.RequestAborted.IsCancellationRequested) + { + // The caller left before it was answered, so nobody will ask for the answer. + work.Cancel(); + return; + } + + if (answer == null && !work.TryMove()) + // Answered between the wait running out and the move: answered as it came. + answer = await work.AnsweredHere.Task; + + if (answer != null) + { + await using (answer.Body) await WriteAsync(context, answer); + return; + } + + var kept = await requests.KeepAsync(work); + context.Response.StatusCode = StatusCodes.Status202Accepted; + context.Response.Headers[HeaderNames.Location] = $"/api/background/{kept.Id}"; + context.Response.Headers["Preference-Applied"] = "respond-async"; + await context.Response.WriteAsJsonAsync(new { id = kept.Id, status = kept.Status.ToString() }); + } + + static bool Wanted(HttpContext context, out TimeSpan wait, out string owner) + { + wait = DefaultWait; + owner = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!(HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsPost(context.Request.Method)) || owner == null || + !context.Request.Path.StartsWithSegments("/api") || + context.Request.Path.StartsWithSegments("/api/background")) + return false; + + var asked = false; + foreach (var preference in context.Request.Headers["Prefer"] + .SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))) + { + if (preference.Equals("respond-async", StringComparison.OrdinalIgnoreCase)) asked = true; + else if (preference.StartsWith("wait=", StringComparison.OrdinalIgnoreCase) && + int.TryParse(preference["wait=".Length..], out var seconds) && seconds >= 0) + wait = TimeSpan.FromSeconds(seconds) < LongestWait ? TimeSpan.FromSeconds(seconds) : LongestWait; + } + + return asked; + } + + static async Task WriteAsync(HttpContext context, BackgroundRequests.Answer answer) + { + switch (answer.Kind) + { + case BackgroundRequests.AnswerKind.Answered: + context.Response.StatusCode = answer.Status; + foreach (var header in answer.Headers) + if (!header.Key.Equals(HeaderNames.ContentLength, StringComparison.OrdinalIgnoreCase) && + !header.Key.Equals(HeaderNames.TransferEncoding, StringComparison.OrdinalIgnoreCase)) + context.Response.Headers[header.Key] = header.Value; + await answer.Body.CopyToAsync(context.Response.Body); + break; + case BackgroundRequests.AnswerKind.TimedOut: + // Not reachable within a wait of seconds; answered the way the database would have. + context.Response.StatusCode = StatusCodes.Status504GatewayTimeout; + break; + default: + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + break; + } + } + + static string Header(HttpContext context, string name) + { + var value = context.Request.Headers[name].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(value)) return null; + try + { + return Uri.UnescapeDataString(value); + } + catch (UriFormatException) + { + return value; + } + } +} diff --git a/SW.Bitween.Api/Services/Background/BackgroundRequestScope.cs b/SW.Bitween.Api/Services/Background/BackgroundRequestScope.cs new file mode 100644 index 00000000..10c3e30c --- /dev/null +++ b/SW.Bitween.Api/Services/Background/BackgroundRequestScope.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Concurrent; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace SW.Bitween.Services.Background; + +/// +/// The background request running on this flow, if one is: how long its database commands may take, +/// what stops them, and where work that counts its steps says how far it has got. +/// +/// +/// +/// A request someone is waiting on keeps the connection's own limit, thirty seconds by default. A +/// background request has nobody waiting, so its commands may use whatever is left of its own limit +/// instead; and stopping it cancels the command the database is running, rather than only the wait +/// for it. Opened like , and read by +/// . +/// +/// +/// Work that reports - a bulk retry, an export - is different in two ways. Each +/// report moves its limit on, since work still making progress isn't stuck. And its owner's cancel +/// doesn't stop it mid-step: it sees between steps and ends there, so +/// what it has done is saved and said. +/// +/// +public sealed class BackgroundRequestScope +{ + static readonly AsyncLocal current = new(); + + readonly ConcurrentDictionary running = new(); + readonly Action progressed; + + BackgroundRequestScope(DateTime deadline, CancellationToken stopped, CancellationToken cancelRequested, + Action progressed) + { + Deadline = deadline; + Stopped = stopped; + CancelRequested = cancelRequested; + this.progressed = progressed; + stopped.Register(CancelRunning); + } + + /// The background request running on this flow, if one is. + public static BackgroundRequestScope Current => current.Value; + + /// + /// Makes what runs on this flow from here on a background request's. Called at the top of an + /// async method, it covers that method and everything it awaits, and ends with it. + /// + public static BackgroundRequestScope Open(DateTime deadline, CancellationToken stopped, + CancellationToken cancelRequested = default, Action progressed = null) + { + var scope = new BackgroundRequestScope(deadline, stopped, cancelRequested, progressed); + current.Value = scope; + return scope; + } + + public DateTime Deadline { get; private set; } + + /// Stops it where it is: once the deadline passes, or its owner cancels work that doesn't count its steps. + public CancellationToken Stopped { get; } + + /// Its owner cancelled it. Work that counts its steps checks this between them, and ends there. + public CancellationToken CancelRequested { get; } + + /// Whether it has said how far it got, which makes a cancel wait for the step it's on. + public bool Counts { get; private set; } + + /// + /// Says how far it has got, and that it is getting somewhere: the limit starts again from now. + /// Cheap enough to call per step; it is passed on at most once a second. + /// + public void Progress(int done, int total, string summary = null) + { + Counts = true; + Deadline = DateTime.UtcNow + BackgroundRequests.Limit; + progressed?.Invoke(done, total, summary); + } + + internal void Starting(DbCommand command) + { + Stopped.ThrowIfCancellationRequested(); + command.CommandTimeout = Math.Max(1, (int)Math.Ceiling((Deadline - DateTime.UtcNow).TotalSeconds)); + running.TryAdd(command, 0); + // A stop landing between the check and the add found nothing to cancel. + if (Stopped.IsCancellationRequested) Cancel(command); + } + + internal void Ended(DbCommand command) => running.TryRemove(command, out _); + + void CancelRunning() + { + foreach (var command in running.Keys) Cancel(command); + } + + static void Cancel(DbCommand command) + { + try + { + command.Cancel(); + } + catch + { + // Already finished, or the provider can't; the deadline still applies. + } + } +} + +/// +/// Applies the background request open on the calling flow to each command EF sends; nothing when +/// none is open. A command that reads stays covered until its reader is disposed, since a large read +/// is still running after the command has returned. +/// +public sealed class BackgroundRequestCommands : DbCommandInterceptor +{ + public static readonly BackgroundRequestCommands Instance = new(); + + BackgroundRequestCommands() + { + } + + static void Starting(DbCommand command) => BackgroundRequestScope.Current?.Starting(command); + static void Ended(DbCommand command) => BackgroundRequestScope.Current?.Ended(command); + + public override InterceptionResult ReaderExecuting(DbCommand command, CommandEventData eventData, + InterceptionResult result) + { + Starting(command); + return result; + } + + public override ValueTask> ReaderExecutingAsync(DbCommand command, + CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + Starting(command); + return ValueTask.FromResult(result); + } + + public override InterceptionResult DataReaderDisposing(DbCommand command, DataReaderDisposingEventData eventData, + InterceptionResult result) + { + Ended(command); + return result; + } + + public override InterceptionResult NonQueryExecuting(DbCommand command, CommandEventData eventData, + InterceptionResult result) + { + Starting(command); + return result; + } + + public override ValueTask> NonQueryExecutingAsync(DbCommand command, + CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + Starting(command); + return ValueTask.FromResult(result); + } + + public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result) + { + Ended(command); + return result; + } + + public override ValueTask NonQueryExecutedAsync(DbCommand command, CommandExecutedEventData eventData, + int result, CancellationToken cancellationToken = default) + { + Ended(command); + return ValueTask.FromResult(result); + } + + public override InterceptionResult ScalarExecuting(DbCommand command, CommandEventData eventData, + InterceptionResult result) + { + Starting(command); + return result; + } + + public override ValueTask> ScalarExecutingAsync(DbCommand command, + CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + Starting(command); + return ValueTask.FromResult(result); + } + + public override object ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object result) + { + Ended(command); + return result; + } + + public override ValueTask ScalarExecutedAsync(DbCommand command, CommandExecutedEventData eventData, + object result, CancellationToken cancellationToken = default) + { + Ended(command); + return ValueTask.FromResult(result); + } + + public override void CommandFailed(DbCommand command, CommandErrorEventData eventData) => Ended(command); + + public override Task CommandFailedAsync(DbCommand command, CommandErrorEventData eventData, + CancellationToken cancellationToken = default) + { + Ended(command); + return Task.CompletedTask; + } + + public override void CommandCanceled(DbCommand command, CommandEndEventData eventData) => Ended(command); + + public override Task CommandCanceledAsync(DbCommand command, CommandEndEventData eventData, + CancellationToken cancellationToken = default) + { + Ended(command); + return Task.CompletedTask; + } +} diff --git a/SW.Bitween.Api/Services/Background/BackgroundRequests.cs b/SW.Bitween.Api/Services/Background/BackgroundRequests.cs new file mode 100644 index 00000000..d8ac3afe --- /dev/null +++ b/SW.Bitween.Api/Services/Background/BackgroundRequests.cs @@ -0,0 +1,601 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; +using SW.Bitween.Domain; +using SW.Bitween.Services.Cluster; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services.Background; + +/// +/// Runs the work the admin UI asks to carry on - prod's list to bring from, a bulk retry, a zip of +/// exchange files - and keeps going with whatever outlasts the wait the UI asked for: that becomes a +/// , whose answer is kept for the UI to collect, and whose owner is told +/// when it changes - by SignalR, on whichever node holds their connection. +/// +/// +/// +/// A request starts the moment it arrives, so one that answers in time is answered exactly as it would +/// have been. What it may not do is run in the background beyond on a node, or +/// of one person's: they are long database work, on the database exchanges are +/// processed on. A read that would go past either is stopped and asked again once a place frees up; a +/// write can't be asked twice, so it goes on regardless. +/// +/// +/// A singleton, and has to be: it holds the places, and the requests running here. +/// +/// +public sealed class BackgroundRequests(IServiceScopeFactory scopes, IHubContext hub, + ICloudFilesService cloudFiles, ILogger logger) +{ + /// The longest a background request may go without saying it got further. + public static readonly TimeSpan Limit = TimeSpan.FromMinutes(10); + + /// How long an answer is kept for its owner to collect. + public static readonly TimeSpan KeptFor = TimeSpan.FromMinutes(30); + + public const int MaxRunning = 10; + public const int MaxPerOwner = 3; + + /// Answers up to this size are kept in the database; larger ones, and files, in storage. + public const int KeptInline = 1024 * 1024; + + /// Where answers kept in storage go, each under its request's id. + public const string StoragePrefix = "background-requests/"; + + static readonly TimeSpan ProgressEvery = TimeSpan.FromSeconds(1); + + /// This instance, among those a change is broadcast to; unique even for two in one process. + public string Node { get; } = $"{NodeHeartbeat.NodeName}/{Guid.NewGuid():N}"; + + readonly SemaphoreSlim places = new(MaxRunning, MaxRunning); + readonly ConcurrentDictionary ownerPlaces = new(); + readonly ConcurrentDictionary background = new(); + + /// Starts answering , by running what follows in the pipeline. + internal Work Start(AskedRequest asked, RequestDelegate next, string owner, string label, string page) + { + var work = new Work(asked, next, owner, label, page); + // Its own flow from the start: nothing set on the request's - the context accessor's holder + // above all, which is cleared when the request ends - may reach it. + using (ExecutionContext.SuppressFlow()) + _ = Task.Run(() => RunAsync(work)); + return work; + } + + /// + /// Keeps going after its caller stopped waiting: records it, and gives it a + /// place - or, for a read, stops it until one frees up. + /// + internal async Task KeepAsync(Work work) + { + try + { + await using var scope = scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await ForgetExpiredAsync(db); + + var row = new BackgroundRequest(work.Id, work.Owner, Clip(work.Asked.Described, 4000), + Clip(work.Label, 200), Clip(work.Page, 2000), NodeHeartbeat.NodeName); + if (TryTakePlaces(work) || !work.Asked.IsRead) row.Run(); + else work.Requeue(); + if (work.Total is { } total) row.Progress(work.Done, total, Clip(work.Summary, 500)); + + background[work.Id] = work; + db.Add(row); + await db.SaveChangesAsync(); + work.Kept.TrySetResult(true); + await NotifyAsync(row); + return row; + } + catch (Exception ex) + { + // Nobody could collect it, so it must not go on. + work.Cancel(hard: true); + background.TryRemove(work.Id, out _); + ReleasePlaces(work); + work.Kept.TrySetResult(false); + logger.LogError(ex, "Couldn't keep a background request for {Path}.", work.Asked.Described); + throw; + } + } + + /// Stops a request running on this node; false when it isn't running here. + public bool CancelHere(string id) + { + if (!background.TryGetValue(id, out var work)) return false; + work.Cancel(); + return true; + } + + /// + /// Stops one of 's requests, wherever it runs. False when there is no such + /// request of theirs. + /// + /// + /// Work that counts its steps ends itself, at the next one, and says how far it got; anything else + /// ends here and now. + /// + public async Task CancelAsync(BitweenDbContext db, string owner, string id) + { + var row = await db.Set().FirstOrDefaultAsync(r => r.Id == id && r.Owner == owner); + if (row == null) return false; + if (row.Finished) return true; + + if (!CancelHere(id)) await BroadcastAsync(new BackgroundRequestCancel { Id = id }); + if (row.Total != null) return true; + + row.End(BackgroundRequestStatus.Cancelled); + await db.SaveChangesAsync(); + await NotifyAsync(row); + return true; + } + + /// + /// Ends whichever of these is still running on a node that has gone, or has restarted since it + /// started it: those will never finish. + /// + public static async Task EndStoppedAsync(BitweenDbContext db, IReadOnlyCollection rows) + { + var unfinished = rows.Where(r => !r.Finished).ToList(); + if (unfinished.Count == 0) return; + + var names = unfinished.Select(r => r.Node).Distinct().ToList(); + var nodes = await db.Set().AsNoTracking() + .Where(n => names.Contains(n.Id)).ToDictionaryAsync(n => n.Id); + var now = DateTime.UtcNow; + var ended = false; + foreach (var row in unfinished) + { + var stopped = nodes.TryGetValue(row.Node, out var node) + ? node.LastSeenOn < now - NodeHeartbeat.GoneAfter || node.StartedOn > row.StartedOn + // A node writes its first beat as it starts; a request that new gets the same grace. + : row.StartedOn < now - NodeHeartbeat.GoneAfter; + if (!stopped) continue; + row.End(BackgroundRequestStatus.Stopped); + ended = true; + } + + if (ended) await db.SaveChangesAsync(); + } + + /// + /// Deletes what is no longer kept - finished longer ago than , or started more + /// than a day ago - with the answers it kept in storage. + /// + async Task ForgetExpiredAsync(BitweenDbContext db) + { + var now = DateTime.UtcNow; + var expired = db.Set() + .Where(r => r.StartedOn < now.AddDays(-1) || (r.FinishedOn != null && r.FinishedOn < now - KeptFor)); + foreach (var key in await expired.Where(r => r.ResultKey != null).Select(r => r.ResultKey).ToListAsync()) + { + try + { + await cloudFiles.DeleteAsync(key); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Couldn't delete a background request's answer, {Key}.", key); + } + } + + await expired.ExecuteDeleteAsync(); + } + + async Task RunAsync(Work work) + { + Answer answer = null; + try + { + answer = await AttemptAsync(work, work.FirstAttempt); + if (work.TryAnswerHere()) + { + work.AnsweredHere.TrySetResult(answer); + answer = null; + return; + } + + if (!await work.Kept.Task) return; + try + { + // A read stopped for want of a place rather than finished: asked again once one frees up. + if (answer.Kind == AnswerKind.Requeued) + { + try + { + await TakePlacesAsync(work); + } + catch (OperationCanceledException) + { + await SaveAsync(work, Answer.Cancelled); + return; + } + + await SetRunningAsync(work); + using var attempt = new CancellationTokenSource(); + answer = await AttemptAsync(work, attempt); + } + + await SaveAsync(work, answer); + } + finally + { + ReleasePlaces(work); + background.TryRemove(work.Id, out _); + } + } + catch (Exception ex) + { + logger.LogError(ex, "A background request for {Path} failed.", work.Asked.Described); + if (!work.AnsweredHere.TrySetException(ex) && work.Kept.Task.IsCompletedSuccessfully && work.Kept.Task.Result) + { + try + { + await SaveAsync(work, Answer.Failed); + } + catch (Exception saving) + { + logger.LogError(saving, "Couldn't record that a background request failed."); + } + } + } + finally + { + if (answer?.Body != null) await answer.Body.DisposeAsync(); + } + } + + /// + /// Asks the request again, in a scope of its own, with to get anywhere and + /// able to end it sooner. + /// + async Task AttemptAsync(Work work, CancellationTokenSource stop) + { + work.Attempt = stop; + stop.CancelAfter(Limit); + BackgroundRequestScope.Open(DateTime.UtcNow + Limit, stop.Token, work.CancelRequested, + (done, total, summary) => Progressed(work, stop, done, total, summary)); + + await using var scope = scopes.CreateAsyncScope(); + // On disk rather than in memory: an answer can be a zip of hundreds of exchanges' files. + var body = new FileStream(Path.Combine(Path.GetTempPath(), $"bitween-background-{work.Id}-{work.Attempts + 1}"), + FileMode.Create, FileAccess.ReadWrite, FileShare.None, 81920, FileOptions.DeleteOnClose | FileOptions.Asynchronous); + var context = work.Asked.NewContext(scope.ServiceProvider, body, stop.Token, ++work.Attempts); + var accessor = scope.ServiceProvider.GetService(); + if (accessor != null) accessor.HttpContext = context; + + try + { + await work.Next(context); + await context.Response.CompleteAsync(); + } + catch (Exception) when (stop.IsCancellationRequested) + { + await body.DisposeAsync(); + return work.CancelRequested.IsCancellationRequested ? Answer.Cancelled + : work.Requeued ? Answer.Requeued + : Answer.TimedOut; + } + catch (Exception ex) + { + await body.DisposeAsync(); + logger.LogError(ex, "A background request for {Path} threw.", work.Asked.Described); + return Answer.Failed; + } + finally + { + if (accessor != null) accessor.HttpContext = null; + } + + body.Position = 0; + // The answer the web host would have rewritten on its way out; see Startup. + var status = context.Response.StatusCode == StatusCodes.Status401Unauthorized && + context.User.Identity?.IsAuthenticated == true + ? StatusCodes.Status403Forbidden + : context.Response.StatusCode; + return new Answer(AnswerKind.Answered, status, context.Response.ContentType, + context.Response.Headers[HeaderNames.ContentDisposition].FirstOrDefault(), context.Response.Headers, body); + } + + /// + /// Work said how far it got: it is still going, so its limit starts again; and its owner hears, + /// once a second at most. + /// + void Progressed(Work work, CancellationTokenSource stop, int done, int total, string summary) + { + work.Counts = true; + try + { + stop.CancelAfter(Limit); + } + catch (ObjectDisposedException) + { + // The attempt it was for is over. + } + + work.Done = done; + work.Total = total; + work.Summary = summary; + var now = DateTime.UtcNow; + if (now - work.ToldOn < ProgressEvery || !work.Kept.Task.IsCompletedSuccessfully || !work.Kept.Task.Result) + return; + work.ToldOn = now; + + // Off the work's own flow, so it neither waits for this nor has its limits applied to it. + using (ExecutionContext.SuppressFlow()) + _ = Task.Run(async () => + { + try + { + await using var scope = scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.Set().FindAsync(work.Id); + if (row == null || row.Finished) return; + row.Progress(done, total, Clip(summary, 500)); + await db.SaveChangesAsync(); + await NotifyAsync(row); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Couldn't record how far a background request got."); + } + }); + } + + async Task SaveAsync(Work work, Answer answer) + { + await using var scope = scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.Set().FindAsync(work.Id); + // Gone, or ended meanwhile: nothing to tell anyone. + if (row == null || row.Finished) return; + if (work.Total is { } total) row.Progress(work.Done, total, Clip(work.Summary, 500)); + + switch (answer.Kind) + { + case AnswerKind.Answered: + var file = answer.Disposition != null || answer.Body.Length > KeptInline; + string key = null; + byte[] inline = null; + if (file) + { + key = StoragePrefix + work.Id; + await cloudFiles.WriteAsync(answer.Body, new WriteFileSettings + { + Key = key, + ContentType = answer.ContentType ?? "application/octet-stream" + }); + } + else + { + using var copy = new MemoryStream(); + await answer.Body.CopyToAsync(copy); + inline = copy.ToArray(); + } + + row.Answer(answer.Status, answer.ContentType, answer.Disposition, inline, key, + cancelled: work.CancelRequested.IsCancellationRequested); + break; + case AnswerKind.Failed: + row.Answer(StatusCodes.Status500InternalServerError, "application/json", null, + Problem("BACKGROUND_FAILED", "It failed while running. The log of the node that ran it says why."), null); + break; + case AnswerKind.TimedOut: + row.End(BackgroundRequestStatus.TimedOut); + break; + default: + row.End(BackgroundRequestStatus.Cancelled); + break; + } + + await db.SaveChangesAsync(); + await NotifyAsync(row); + } + + async Task SetRunningAsync(Work work) + { + await using var scope = scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.Set().FindAsync(work.Id); + if (row == null || row.Finished) return; + row.Run(); + await db.SaveChangesAsync(); + await NotifyAsync(row); + } + + /// A body as the UI reads a refusal: the code, with what to tell the person. + internal static byte[] Problem(string code, string message) => + Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new Dictionary { [code] = [message] })); + + bool TryTakePlaces(Work work) + { + var owners = ownerPlaces.GetOrAdd(work.Owner, _ => new SemaphoreSlim(MaxPerOwner, MaxPerOwner)); + if (!owners.Wait(0)) return false; + if (places.Wait(0)) + { + work.HoldsPlaces = true; + return true; + } + + owners.Release(); + return false; + } + + async Task TakePlacesAsync(Work work) + { + var owners = ownerPlaces.GetOrAdd(work.Owner, _ => new SemaphoreSlim(MaxPerOwner, MaxPerOwner)); + await owners.WaitAsync(work.CancelRequested); + try + { + await places.WaitAsync(work.CancelRequested); + } + catch + { + owners.Release(); + throw; + } + + work.HoldsPlaces = true; + } + + void ReleasePlaces(Work work) + { + if (!work.HoldsPlaces) return; + work.HoldsPlaces = false; + places.Release(); + ownerPlaces[work.Owner].Release(); + } + + /// + /// Tells the owner's connections here, then every other node, whose listener tells theirs. Losing + /// either loses only the push: the UI asks again when it reconnects. + /// + internal async Task NotifyAsync(BackgroundRequest row) + { + var change = BackgroundRequestChanged.Of(row, Node); + await PushAsync(change); + await BroadcastAsync(change); + } + + internal async Task PushAsync(BackgroundRequestChanged change) + { + try + { + await hub.Clients.User(change.Owner).SendAsync(BackgroundRequestHub.Changed, new + { + id = change.Id, + status = change.Status.ToString(), + done = change.Done, + total = change.Total, + summary = change.Summary, + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Couldn't tell a browser about a background request."); + } + } + + // Generic, not object: SW-Bus names a broadcast by the type it is called with, and a listener + // only hears its own type's name. Called with object, every node dropped it without a word. + async Task BroadcastAsync(TMessage message) + { + try + { + await using var scope = scopes.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService().Broadcast(message); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Couldn't tell the other nodes about a background request."); + } + } + + static string Clip(string value, int length) => + value == null || value.Length <= length ? value : value[..length]; + + internal enum AnswerKind + { + Answered, + Failed, + Cancelled, + TimedOut, + Requeued, + } + + /// What an attempt came to. is a temporary file, deleted when disposed. + internal sealed record Answer(AnswerKind Kind, int Status = 0, string ContentType = null, string Disposition = null, + IHeaderDictionary Headers = null, Stream Body = null) + { + public static readonly Answer Failed = new(AnswerKind.Failed); + public static readonly Answer Cancelled = new(AnswerKind.Cancelled); + public static readonly Answer TimedOut = new(AnswerKind.TimedOut); + public static readonly Answer Requeued = new(AnswerKind.Requeued); + } + + /// One request, from when it arrives until it is answered or ends. + internal sealed class Work(AskedRequest asked, RequestDelegate next, string owner, string label, string page) + { + const int Waited = 0, Moved = 1, Answered = 2; + int state; + readonly CancellationTokenSource cancelRequested = new(); + + public string Id { get; } = Guid.NewGuid().ToString("N"); + public AskedRequest Asked { get; } = asked; + public RequestDelegate Next { get; } = next; + public string Owner { get; } = owner; + public string Label { get; } = label; + public string Page { get; } = page; + public int Attempts { get; set; } + public bool HoldsPlaces { get; set; } + public bool Requeued { get; private set; } + + /// It has said how far it got, so a cancel lets it end at its next step. + public bool Counts { get; set; } + + public int Done { get; set; } + public int? Total { get; set; } + public string Summary { get; set; } + public DateTime ToldOn { get; set; } + + /// Its owner's cancel, or its caller leaving before it moved to the background. + public CancellationToken CancelRequested => cancelRequested.Token; + + /// What stops the first attempt: a cancel, a requeue, or the limit. + public CancellationTokenSource FirstAttempt { get; } = new(); + + /// What stops the attempt under way. + public CancellationTokenSource Attempt { get; set; } + + /// The answer, when it came while the caller still waited. + public TaskCompletionSource AnsweredHere { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// True once its record exists, false when keeping it failed. + public TaskCompletionSource Kept { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Claims the answer for the caller still waiting; false once it moved to the background. + public bool TryAnswerHere() => Interlocked.CompareExchange(ref state, Answered, Waited) == Waited; + + /// Claims it for the background; false once its caller has been answered. + public bool TryMove() => Interlocked.CompareExchange(ref state, Moved, Waited) == Waited; + + /// + /// Stops it: where it is, unless it counts its steps - then at the next one, unless + /// . + /// + public void Cancel(bool hard = false) + { + cancelRequested.Cancel(); + if (Counts && !hard) return; + FirstAttempt.Cancel(); + try + { + Attempt?.Cancel(); + } + catch (ObjectDisposedException) + { + // That attempt is over. + } + } + + /// Stops the first attempt, to ask again once there is a place for it. + public void Requeue() + { + Requeued = true; + FirstAttempt.Cancel(); + } + } +} diff --git a/SW.Bitween.HttpTests/BackgroundRequestTests.cs b/SW.Bitween.HttpTests/BackgroundRequestTests.cs new file mode 100644 index 00000000..f5adf189 --- /dev/null +++ b/SW.Bitween.HttpTests/BackgroundRequestTests.cs @@ -0,0 +1,380 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace SW.Bitween.HttpTests; + +/// +/// The admin UI's long reads, carried on in the background when it sends Prefer: respond-async. +/// On a node whose database commands time out after two seconds, so that "longer than the usual +/// limit" takes seconds to show rather than half a minute. +/// +[Collection("Http")] +public class BackgroundRequestTests(HttpFixture fixture) : IAsyncLifetime +{ + WebApplicationFactory node = null!; + + public Task InitializeAsync() + { + node = fixture.SecondNode( + services => services.AddControllers().AddApplicationPart(typeof(SlowReadsController).Assembly), + settings => settings["ConnectionStrings:BitweenDb"] += ";Command Timeout=2"); + return Task.CompletedTask; + } + + public async Task DisposeAsync() => await node.DisposeAsync(); + + [Fact] + public async Task A_read_answered_within_the_wait_is_answered_as_usual() + { + using var admin = await HttpFixture.AdminOf(node); + var before = (await RowsAsync()).Length; + + var response = await admin.SendAsync(Read("/api/slowreads/0", wait: 5)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(0, (double)(await Api.Json(response))["seconds"]!); + Assert.Equal(before, (await RowsAsync()).Length); + } + + [Fact] + public async Task A_read_that_does_not_ask_keeps_the_usual_limit() + { + using var admin = await HttpFixture.AdminOf(node); + + var response = await admin.SendAsync(Read("/api/slowreads/3")); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + [Fact] + public async Task A_read_that_outlasts_the_wait_runs_past_the_usual_limit_and_its_answer_is_kept() + { + using var admin = await HttpFixture.AdminOf(node); + + var accepted = await admin.SendAsync(Read("/api/slowreads/3", wait: 0, label: "Exchanges: failed, last 7 days", + page: "/exchanges?status=failed")); + + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + var id = (string)(await Api.Json(accepted))["id"]!; + Assert.Equal($"/api/background/{id}", accepted.Headers.Location?.ToString()); + + var row = await FinishedAsync(admin, id); + Assert.Equal("Done", (string)row["status"]!); + Assert.Equal("Exchanges: failed, last 7 days", (string)row["label"]!); + Assert.Equal("/exchanges?status=failed", (string)row["page"]!); + Assert.Equal("/api/slowreads/3", (string)row["path"]!); + + var result = await admin.GetAsync($"/api/background/{id}/result"); + Assert.Equal(3, (double)(await Api.Json(result))["seconds"]!); + } + + [Fact] + public async Task An_api_read_in_the_background_answers_as_it_would_have_directly() + { + using var admin = await HttpFixture.AdminOf(node); + + // A list, and a refusal CqApi turns into a 404 of its own. + foreach (var path in new[] { "/api/subscriptions", "/api/subscriptions/987654" }) + { + var direct = await admin.GetAsync(path); + + // Made slow by holding its table: the read waits for the lock, past the caller's wait. + var accepted = await fixture.InDbAsync(async db => + { + await using var transaction = await db.Database.BeginTransactionAsync(); + await db.Database.ExecuteSqlRawAsync("LOCK TABLE infolink.subscription IN ACCESS EXCLUSIVE MODE"); + var response = await admin.SendAsync(Read(path, wait: 1)); + await transaction.RollbackAsync(); + return response; + }); + + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + var id = (string)(await Api.Json(accepted))["id"]!; + Assert.Equal("Done", (string)(await FinishedAsync(admin, id))["status"]!); + var kept = await admin.GetAsync($"/api/background/{id}/result"); + Assert.Equal(direct.StatusCode, kept.StatusCode); + Assert.Equal(direct.Content.Headers.ContentType?.MediaType, kept.Content.Headers.ContentType?.MediaType); + Assert.Equal(await direct.Content.ReadAsStringAsync(), await kept.Content.ReadAsStringAsync()); + } + } + + [Fact] + public async Task Cancelling_stops_the_query_the_database_is_running() + { + using var admin = await HttpFixture.AdminOf(node); + var id = (string)(await Api.Json(await admin.SendAsync(Read("/api/slowreads/60", wait: 0))))["id"]!; + await SleepingAsync(1); + + Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/background/{id}")).StatusCode); + + await SleepingAsync(0); + Assert.Equal("Cancelled", (string)(await FinishedAsync(admin, id))["status"]!); + Assert.Equal(HttpStatusCode.Gone, (await admin.GetAsync($"/api/background/{id}/result")).StatusCode); + } + + [Fact] + public async Task Three_of_a_persons_run_at_once_and_the_next_waits_its_turn() + { + using var admin = await HttpFixture.AdminOf(node); + + var started = await Task.WhenAll(Enumerable.Range(0, 4).Select(async _ => + await Api.Json(await admin.SendAsync(Read("/api/slowreads/2", wait: 0))))); + + Assert.Equal(["Running", "Running", "Running", "Waiting"], started.Select(s => (string)s["status"]!).Order()); + foreach (var request in started) + Assert.Equal("Done", (string)(await FinishedAsync(admin, (string)request["id"]!))["status"]!); + } + + [Fact] + public async Task Its_owner_hears_it_finish_on_another_node() + { + using var admin = await HttpFixture.AdminOf(node); + var heard = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var hub = new HubConnectionBuilder() + .WithUrl("https://localhost/api/background/hub", options => + { + options.Transports = HttpTransportType.LongPolling; + options.HttpMessageHandlerFactory = _ => fixture.App.Server.CreateHandler(); + options.AccessTokenProvider = () => Task.FromResult(admin.DefaultRequestHeaders.Authorization!.Parameter); + }) + .Build(); + hub.On("changed", change => + { + if (change.GetProperty("status").GetString() == "Done") heard.TrySetResult(change.GetProperty("id").GetString()!); + }); + await hub.StartAsync(); + + var id = (string)(await Api.Json(await admin.SendAsync(Read("/api/slowreads/1", wait: 0))))["id"]!; + + Assert.Equal(id, await heard.Task.WaitAsync(TimeSpan.FromSeconds(30))); + } + + [Fact] + public async Task Nobody_else_sees_it_or_its_answer() + { + using var admin = await HttpFixture.AdminOf(node); + var id = (string)(await Api.Json(await admin.SendAsync(Read("/api/slowreads/1", wait: 0))))["id"]!; + + using var other = HttpFixture.ClientOf(node); + other.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TokenFor("987654")); + + Assert.DoesNotContain((await Api.Json(await other.GetAsync("/api/background"))).AsArray(), + r => (string)r!["id"]! == id); + Assert.Equal(HttpStatusCode.NotFound, (await other.GetAsync($"/api/background/{id}/result")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await other.DeleteAsync($"/api/background/{id}")).StatusCode); + Assert.Equal("Done", (string)(await FinishedAsync(admin, id))["status"]!); + } + + [Fact] + public async Task One_on_a_node_that_has_gone_shows_as_stopped() + { + using var admin = await HttpFixture.AdminOf(node); + var real = (string)(await Api.Json(await admin.SendAsync(Read("/api/slowreads/0", wait: 0))))["id"]!; + await FinishedAsync(admin, real); + var owner = (await RowsAsync()).Single(r => r.Id == real).Owner; + + var id = Guid.NewGuid().ToString("N"); + var startedOn = DateTime.UtcNow.AddMinutes(-5); + await fixture.InDbAsync(db => db.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO infolink.background_request (id, owner, path, node, status, started_on) VALUES ({id}, {owner}, {"/api/xchanges"}, {"gone-node:1"}, 1, {startedOn})")); + + var row = (await Api.Json(await admin.GetAsync("/api/background"))).AsArray().Single(r => (string)r!["id"]! == id)!; + Assert.Equal("Stopped", (string)row["status"]!); + Assert.Equal(HttpStatusCode.ServiceUnavailable, (await admin.GetAsync($"/api/background/{id}/result")).StatusCode); + } + + [Fact] + public async Task Work_that_counts_its_steps_says_how_far_it_got_and_a_cancel_keeps_what_it_did() + { + using var admin = await HttpFixture.AdminOf(node); + var request = new HttpRequestMessage(HttpMethod.Post, "/api/slowreads/steps") { Content = JsonContent.Create(new { count = 100 }) }; + request.Headers.TryAddWithoutValidation("Prefer", "respond-async, wait=0"); + var accepted = await admin.SendAsync(request); + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + var id = (string)(await Api.Json(accepted))["id"]!; + + // Said once a second at most, so seen within a few. + JsonNode? row = null; + for (var i = 0; i < 50 && (row == null || (int?)row["done"] is null or 0); i++) + { + await Task.Delay(100); + row = (await Api.Json(await admin.GetAsync("/api/background"))).AsArray().Single(r => (string)r!["id"]! == id); + } + Assert.Equal(100, (int)row!["total"]!); + Assert.True((int)row["done"]! > 0); + Assert.Equal("POST /api/slowreads/steps", (string)row["path"]!); + + Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/background/{id}")).StatusCode); + + var ended = await FinishedAsync(admin, id); + Assert.Equal("Cancelled", (string)ended["status"]!); + var result = await admin.GetAsync($"/api/background/{id}/result"); + var stoppedAt = (int)(await Api.Json(result))["done"]!; + Assert.InRange(stoppedAt, 1, 99); + } + + [Fact] + public async Task An_export_in_the_background_is_kept_in_storage_and_handed_over_as_the_file() + { + var (admin, urlName, _, partnerId, subscriptionId) = await Api.GatewayAsync(fixture); + var key = await Api.NewKeyAsync(admin); + await Api.SetKeysAsync(admin, partnerId, ("export", key)); + foreach (var n in new[] { 1, 2 }) + { + using var partner = fixture.Client(); + using var call = new HttpRequestMessage(HttpMethod.Post, $"/api/gateway/{urlName}/async") + { + Content = new StringContent($"{{\"order\":{n}}}", Encoding.UTF8, "application/json") + }; + call.Headers.Add("partnerkey", key); + Assert.Equal(HttpStatusCode.Accepted, (await partner.SendAsync(call)).StatusCode); + } + // An async call's exchange is made off the call, so it is waited for. + var ids = new System.Collections.Generic.List(); + for (var i = 0; i < 100 && ids.Count < 2; i++) + { + if (i > 0) await Task.Delay(100); + ids = await fixture.InDbAsync(db => db.Set().Where(x => x.SubscriptionId == subscriptionId) + .Select(x => x.Id).ToListAsync()); + } + Assert.Equal(2, ids.Count); + + using var owner = await HttpFixture.AdminOf(node); + var request = new HttpRequestMessage(HttpMethod.Post, "/api/xchanges/export") { Content = JsonContent.Create(new { ids }) }; + request.Headers.TryAddWithoutValidation("Prefer", "respond-async, wait=0"); + var accepted = await owner.SendAsync(request); + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + var id = (string)(await Api.Json(accepted))["id"]!; + Assert.Equal("Done", (string)(await FinishedAsync(owner, id))["status"]!); + + var kept = await fixture.InDbAsync(db => db.Set().AsNoTracking().SingleAsync(r => r.Id == id)); + Assert.Equal($"background-requests/{id}", kept.ResultKey); + Assert.Null(kept.Result); + + // Downloaded from a link the owner is given, by a browser that sends no sign-in. + var link = (string)(await Api.Json(await owner.PostAsync($"/api/background/{id}/link", null)))["url"]!; + using var browser = HttpFixture.ClientOf(node); + var file = await browser.GetAsync(link); + Assert.Equal(HttpStatusCode.OK, file.StatusCode); + Assert.Equal("application/zip", file.Content.Headers.ContentType?.MediaType); + Assert.StartsWith("exchanges-", file.Content.Headers.ContentDisposition?.FileName?.Trim('"')); + using var zip = new System.IO.Compression.ZipArchive(await file.Content.ReadAsStreamAsync()); + Assert.Equal(2, zip.Entries.Count(e => e.FullName.Contains("/input"))); + + // A link changed by a character, or to another request, opens nothing. + Assert.Equal(HttpStatusCode.NotFound, (await browser.GetAsync(link[..^1] + (link[^1] == 'A' ? 'B' : 'A'))).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await browser.GetAsync(link.Replace(id, Guid.NewGuid().ToString("N")))).StatusCode); + // And nobody else is handed one. + using var other = HttpFixture.ClientOf(node); + other.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TokenFor("987654")); + Assert.Equal(HttpStatusCode.NotFound, (await other.PostAsync($"/api/background/{id}/link", null)).StatusCode); + } + + static HttpRequestMessage Read(string path, int? wait = null, string? label = null, string? page = null) + { + var request = new HttpRequestMessage(HttpMethod.Get, path); + if (wait != null) request.Headers.TryAddWithoutValidation("Prefer", $"respond-async, wait={wait}"); + if (label != null) request.Headers.Add("X-Bitween-Label", Uri.EscapeDataString(label)); + if (page != null) request.Headers.Add("X-Bitween-Page", Uri.EscapeDataString(page)); + return request; + } + + /// The request, from the list, once it has stopped running. + static async Task FinishedAsync(HttpClient client, string id) + { + for (var i = 0; i < 300; i++) + { + var row = (await Api.Json(await client.GetAsync("/api/background"))).AsArray() + .FirstOrDefault(r => (string)r!["id"]! == id); + if (row != null && (string)row["status"]! is not ("Waiting" or "Running")) return row; + await Task.Delay(100); + } + + throw new TimeoutException($"Background request {id} didn't finish."); + } + + Task RowsAsync() => + fixture.InDbAsync(db => db.Set().AsNoTracking().ToArrayAsync()); + + /// Waits until the database is running of the slow reads' sleeps. + async Task SleepingAsync(int count) + { + for (var i = 0; i < 100 && await SleepsAsync() != count; i++) await Task.Delay(100); + Assert.Equal(count, await SleepsAsync()); + } + + Task SleepsAsync() => fixture.InDbAsync(db => db.Database + .SqlQueryRaw("SELECT count(*)::int AS \"Value\" FROM pg_stat_activity WHERE state = 'active' AND query LIKE 'SELECT pg_sleep(%'") + .SingleAsync()); + + /// A sign-in for an account that isn't the administrator, signed as this install signs them. + string TokenFor(string accountId) + { + var configuration = node.Services.GetRequiredService(); + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, accountId)]), + Issuer = configuration["Token:Issuer"], + Audience = configuration["Token:Audience"], + Expires = DateTime.UtcNow.AddMinutes(5), + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Token:Key"]!)), SecurityAlgorithms.HmacSha256), + }); + } +} + +/// A read as slow as asked: it sleeps in the database, as one on a big table waits on it. +[ApiController] +[Route("api/slowreads")] +public class SlowReadsController(BitweenDbContext db) : ControllerBase +{ + [HttpGet("{seconds:double}")] + public async Task Get(double seconds) + { + await db.Database.ExecuteSqlRawAsync("SELECT pg_sleep({0})", seconds); + return Ok(new { seconds }); + } + + /// + /// Work that counts its steps, as a bulk retry does: a fifth of a second each, saying how far it got, + /// and stopping between steps once its owner cancels it. + /// + [HttpPost("steps")] + public async Task Steps([FromBody] StepsRequest request) + { + var background = Services.Background.BackgroundRequestScope.Current; + var done = 0; + for (; done < request.Count; done++) + { + background?.Progress(done, request.Count, $"{done} of {request.Count}"); + if (background?.CancelRequested.IsCancellationRequested == true) break; + await db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(0.2)"); + } + + return Ok(new { done }); + } + + public class StepsRequest + { + public int Count { get; set; } + } +} diff --git a/SW.Bitween.HttpTests/HttpFixture.cs b/SW.Bitween.HttpTests/HttpFixture.cs index 3cd04ec4..6ff20ba1 100644 --- a/SW.Bitween.HttpTests/HttpFixture.cs +++ b/SW.Bitween.HttpTests/HttpFixture.cs @@ -48,9 +48,13 @@ public sealed class HttpFixture : IAsyncLifetime /// is. The caller disposes it. /// /// Changes to the node's services, as a test needs them. - public WebApplicationFactory SecondNode(Action? services = null) + /// Changes to the node's settings, made to a copy of the fixture's. + public WebApplicationFactory SecondNode(Action? services = null, + Action>? own = null) { - var node = new BitweenApp(settings, services); + var nodeSettings = new Dictionary(settings); + own?.Invoke(nodeSettings); + var node = new BitweenApp(nodeSettings, services); using var _ = node.CreateClient(); return node; } diff --git a/SW.Bitween.HttpTests/SW.Bitween.HttpTests.csproj b/SW.Bitween.HttpTests/SW.Bitween.HttpTests.csproj index 25a6a55a..085b81e5 100644 --- a/SW.Bitween.HttpTests/SW.Bitween.HttpTests.csproj +++ b/SW.Bitween.HttpTests/SW.Bitween.HttpTests.csproj @@ -8,6 +8,7 @@ + diff --git a/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs b/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs new file mode 100644 index 00000000..8f4f3575 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.Bitween.Services.Background; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// A bulk retry the admin UI carries on in the background: the whole selection at once rather than in +/// rounds of 500, saved as it goes, saying how far it got, and stopping between exchanges when cancelled. +/// +[Collection("Bitween")] +public class BulkRetryInBackgroundTests(BitweenFixture fixture) +{ + /// One more than a retry asked directly takes. + const int PastOneRound = 501; + + [Fact] + public async Task Asked_directly_it_still_takes_one_round_at_most() + { + await using var scope = fixture.CreateScope(); + var (db, xs, ctx) = Services(scope); + var ids = await FailedXchangesAsync(db, xs, PastOneRound); + + var refused = await Assert.ThrowsAsync(() => + new Resources.Xchanges.BulkRetry(db, ctx, xs).Handle(new XchangeBulkRetry { Ids = ids })); + + Assert.Contains(refused.Validations, v => v.Key == "TOO_MANY"); + } + + [Fact] + public async Task In_the_background_it_takes_the_whole_selection_and_says_how_far_it_got() + { + await using var scope = fixture.CreateScope(); + var (db, xs, ctx) = Services(scope); + var ids = await FailedXchangesAsync(db, xs, PastOneRound); + var said = new List<(int Done, int Total, string Summary)>(); + + using var stop = new CancellationTokenSource(); + BackgroundRequestScope.Open(DateTime.UtcNow + BackgroundRequests.Limit, stop.Token, default, + (done, total, summary) => said.Add((done, total, summary))); + var plan = (XchangeBulkRetryPlan)await new Resources.Xchanges.BulkRetry(db, ctx, xs) + .Handle(new XchangeBulkRetry { Ids = ids }); + + Assert.Equal(PastOneRound, plan.WillRetry); + Assert.Null(plan.StoppedAfter); + Assert.Equal(PastOneRound, await db.Set().CountAsync(x => ids.Contains(x.RetryFor))); + // Once at the start, after each save of a hundred, and at the end. + Assert.Equal([0, 100, 200, 300, 400, 500, PastOneRound], said.Select(s => s.Done)); + Assert.All(said, s => Assert.Equal(PastOneRound, s.Total)); + Assert.Equal($"{PastOneRound} retried, 0 skipped", said[^1].Summary); + } + + [Fact] + public async Task Cancelled_it_stops_between_exchanges_and_keeps_what_it_did() + { + await using var scope = fixture.CreateScope(); + var (db, xs, ctx) = Services(scope); + var ids = await FailedXchangesAsync(db, xs, 250); + + using var stop = new CancellationTokenSource(); + using var cancel = new CancellationTokenSource(); + // Cancelled by its owner once the first hundred are saved, as the header's Cancel would. + BackgroundRequestScope.Open(DateTime.UtcNow + BackgroundRequests.Limit, stop.Token, cancel.Token, + (done, _, _) => { if (done >= 100) cancel.Cancel(); }); + var plan = (XchangeBulkRetryPlan)await new Resources.Xchanges.BulkRetry(db, ctx, xs) + .Handle(new XchangeBulkRetry { Ids = ids }); + + Assert.Equal(100, plan.StoppedAfter); + Assert.Equal(100, plan.WillRetry); + Assert.Equal(100, await db.Set().CountAsync(x => ids.Contains(x.RetryFor))); + } + + [Fact] + public async Task It_goes_at_the_pipelines_pace_holding_back_while_too_many_wait() + { + var (waiting, lookEvery) = (Resources.Xchanges.BulkRetry.Waiting, Resources.Xchanges.BulkRetry.LookEvery); + Resources.Xchanges.BulkRetry.Waiting = 150; + Resources.Xchanges.BulkRetry.LookEvery = TimeSpan.FromMilliseconds(50); + try + { + await using var scope = fixture.CreateScope(); + var (db, xs, ctx) = Services(scope); + var ids = await FailedXchangesAsync(db, xs, 250); + var said = new List(); + + using var stop = new CancellationTokenSource(); + var retrying = Task.Run(async () => + { + BackgroundRequestScope.Open(DateTime.UtcNow + BackgroundRequests.Limit, stop.Token, default, + (_, _, summary) => { lock (said) said.Add(summary); }); + return (XchangeBulkRetryPlan)await new Resources.Xchanges.BulkRetry(db, ctx, xs) + .Handle(new XchangeBulkRetry { Ids = ids }); + }); + + // Two saves of a hundred make 200 waiting, past the 150 allowed: it holds there. + await using var check = fixture.CreateScope(); + var checkDb = check.ServiceProvider.GetRequiredService(); + for (var i = 0; i < 100 && await Retries(checkDb, ids) < 200; i++) await Task.Delay(50); + await Task.Delay(300); + Assert.Equal(200, await Retries(checkDb, ids)); + Assert.False(retrying.IsCompleted); + + // The pipeline finishes them, and it carries on to the end. + var made = await checkDb.Set().Where(x => ids.Contains(x.RetryFor)).Select(x => x.Id).ToListAsync(); + checkDb.AddRange(made.Select(id => new XchangeResult(id, null, null, exception: "processed"))); + await checkDb.SaveChangesAsync(); + + var plan = await retrying.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal(250, plan.WillRetry); + Assert.Equal(250, await Retries(checkDb, ids)); + Assert.Contains(said, s => s.Contains("waiting for the pipeline")); + } + finally + { + (Resources.Xchanges.BulkRetry.Waiting, Resources.Xchanges.BulkRetry.LookEvery) = (waiting, lookEvery); + } + } + + [Fact] + public async Task A_plan_counts_every_skip_and_names_the_first_thousand() + { + await using var scope = fixture.CreateScope(); + var (db, xs, ctx) = Services(scope); + var ids = await FailedXchangesAsync(db, xs, 1_005); + // Succeeded since, so all of them are skipped. + await db.Set().Where(r => ids.Contains(r.Id)) + .ExecuteUpdateAsync(u => u.SetProperty(r => r.Success, true)); + + var plan = (XchangeBulkRetryPlan)await new Resources.Xchanges.BulkRetryPreview(db, ctx) + .Handle(new XchangeBulkRetry { Ids = ids }); + + Assert.Equal(1_005, plan.SkippedCount); + Assert.Equal(1_000, plan.Skipped.Count); + Assert.Equal(1_000, plan.Listed); + Assert.Equal(0, plan.WillRetry); + } + + static Task Retries(BitweenDbContext db, List ids) => + db.Set().CountAsync(x => ids.Contains(x.RetryFor)); + + static (BitweenDbContext Db, XchangeService Xs, RequestContext Ctx) Services(AsyncServiceScope scope) => + (scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + scope.Superuser()); + + /// Exchanges that failed, on a subscription of their own, by id. + static async Task> FailedXchangesAsync(BitweenDbContext db, XchangeService xs, int count) + { + var name = $"Bulk background {Guid.NewGuid():N}"[..30]; + var doc = new Document(null, name, DocumentFormat.Json); + db.Set().Add(doc); + await db.SaveChangesAsync(); + var sub = new Subscription(name, doc.Id) { Inactive = false }; + db.Set().Add(sub); + await db.SaveChangesAsync(); + + var ids = new List(); + for (var i = 0; i < count; i++) + { + var xchange = await xs.CreateXchange(sub, new XchangeFile($"{{\"n\":{i}}}")); + db.Set().Add(new XchangeResult(xchange.Id, null, null, exception: "boom")); + ids.Add(xchange.Id); + } + + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + return ids; + } +} diff --git a/SW.Bitween.MsSql/Migrations/20261101090252_AddBackgroundRequests.Designer.cs b/SW.Bitween.MsSql/Migrations/20261101090252_AddBackgroundRequests.Designer.cs new file mode 100644 index 00000000..984d66fa --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20261101090252_AddBackgroundRequests.Designer.cs @@ -0,0 +1,3573 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SW.Bitween.MsSql; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20261101090252_AddBackgroundRequests")] + partial class AddBackgroundRequests + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("DocumentIds"); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("Kind") + .HasColumnType("tinyint"); + + b.Property("LastSignInOn") + .HasColumnType("datetime2"); + + b.Property("LockoutEnd") + .HasColumnType("datetime2"); + + b.Property("LoginMethods") + .HasColumnType("tinyint"); + + b.Property("MicrosoftIdentity") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("MustChangePassword") + .HasColumnType("bit"); + + b.Property("OidcIdentity") + .HasColumnType("nvarchar(max)"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[Email] IS NOT NULL"); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EmailedOn") + .HasColumnType("datetime2"); + + b.Property("ExpiresOn") + .HasColumnType("datetime2"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("AccountLinks", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("LoginMethod") + .HasColumnType("tinyint"); + + b.Property("SupersededOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ExpiresOn") + .HasColumnType("datetime2"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("LastUsedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RevokedOn") + .HasColumnType("datetime2"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("ServiceTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId"); + + b.ToTable("AdapterDrafts", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("DraftId") + .HasColumnType("int"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId", "OccurredOn"); + + b.ToTable("AdapterReleases", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId", "OccurredOn"); + + b.ToTable("AdapterSourceAccesses", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("LastUsedOn") + .HasColumnType("datetime2"); + + b.HasKey("PartnerId", "KeyName"); + + b.ToTable("ApiKeyUses", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Changes") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("varchar(10)"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("OccurredOn"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn"); + + b.ToTable("AuditEntries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Done") + .HasColumnType("int"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Node") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Result") + .HasColumnType("varbinary(max)"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ResultKey") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResultStatus") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Total") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedOn"); + + b.HasIndex("Owner", "StartedOn"); + + b.ToTable("BackgroundRequests", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AcquiredOn") + .HasColumnType("datetime2"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Term") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("ClusterLeases", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("DataSources") + .HasColumnType("bit"); + + b.Property("Host") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("LastSeenOn") + .HasColumnType("datetime2"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("int"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Runtimes") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenOn"); + + b.ToTable("ClusterNodes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("datetime2"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AuthorizedOn") + .HasColumnType("datetime2"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Grant") + .HasColumnType("int"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Password") + .HasColumnType("nvarchar(max)"); + + b.Property("RefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("RenewingUntil") + .HasColumnType("datetime2"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Connections", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedOn") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.HasKey("AdapterId", "InstanceKey", "Name"); + + b.ToTable("AdapterStates", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CpuLimitSamples") + .HasColumnType("int"); + + b.Property("CpuPercentLimit") + .HasColumnType("float"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("int"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("LastException") + .HasColumnType("nvarchar(max)"); + + b.Property("LastHeartbeatOn") + .HasColumnType("datetime2"); + + b.Property("LastKnownState") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnedByNode") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Placement") + .HasColumnType("int"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("SecretProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DataSources", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("CursorColumn") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkGroupId"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique(); + + b.ToTable("DataSourceStatements", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .IsUnicode(false) + .HasColumnType("varchar(400)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("SeenOn") + .HasColumnType("datetime2"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DataSourceId"); + + b.HasIndex("SeenOn"); + + b.ToTable("InboundMessages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("On") + .HasColumnType("datetime2"); + + b.Property("RunFailures") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("NEXT VALUE FOR [DocumentIds]"); + + SqlServerPropertyBuilderExtensions.UseSequence(b.Property("Id"), "DocumentIds"); + + b.Property("BusEnabled") + .HasColumnType("bit"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("bit"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DropDuplicates") + .HasColumnType("bit"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("RetiredOn") + .HasColumnType("datetime2"); + + b.Property("ValidationSchema") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasFilter("[BusMessageTypeName] IS NOT NULL"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Dropped") + .HasColumnType("bit"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "OccurredOn"); + + b.ToTable("DuplicateArrivals", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("FailureAnswer") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Endpoint") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("EndpointProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DataSourceId"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("SecretProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Values") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("Scope", "Key") + .IsUnique(); + + b.ToTable("IdempotencyKeys", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("AdapterId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Role") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Runtime") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Calls") + .HasColumnType("int"); + + b.Property("Crashes") + .HasColumnType("int"); + + b.Property("D0") + .HasColumnType("int"); + + b.Property("D1") + .HasColumnType("int"); + + b.Property("D2") + .HasColumnType("int"); + + b.Property("D3") + .HasColumnType("int"); + + b.Property("D4") + .HasColumnType("int"); + + b.Property("D5") + .HasColumnType("int"); + + b.Property("D6") + .HasColumnType("int"); + + b.Property("D7") + .HasColumnType("int"); + + b.Property("D8") + .HasColumnType("int"); + + b.Property("D9") + .HasColumnType("int"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint"); + + b.Property("DurationSumMs") + .HasColumnType("bigint"); + + b.Property("Failures") + .HasColumnType("int"); + + b.Property("LimitStops") + .HasColumnType("int"); + + b.Property("Timeouts") + .HasColumnType("int"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime"); + + b.ToTable("AdapterStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Scope") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("Count") + .HasColumnType("int"); + + b.Property("LastSeenOn") + .HasColumnType("datetime2"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint"); + + b.ToTable("ErrorStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("BadResponse") + .HasColumnType("int"); + + b.Property("BytesIn") + .HasColumnType("bigint"); + + b.Property("BytesOut") + .HasColumnType("bigint"); + + b.Property("D0") + .HasColumnType("int"); + + b.Property("D1") + .HasColumnType("int"); + + b.Property("D2") + .HasColumnType("int"); + + b.Property("D3") + .HasColumnType("int"); + + b.Property("D4") + .HasColumnType("int"); + + b.Property("D5") + .HasColumnType("int"); + + b.Property("D6") + .HasColumnType("int"); + + b.Property("D7") + .HasColumnType("int"); + + b.Property("D8") + .HasColumnType("int"); + + b.Property("D9") + .HasColumnType("int"); + + b.Property("DbCommands") + .HasColumnType("bigint"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint"); + + b.Property("DurationSumMs") + .HasColumnType("bigint"); + + b.Property("Failed") + .HasColumnType("int"); + + b.Property("FailedAtHandler") + .HasColumnType("int"); + + b.Property("FailedAtMapper") + .HasColumnType("int"); + + b.Property("ManualRetries") + .HasColumnType("int"); + + b.Property("PayloadParses") + .HasColumnType("bigint"); + + b.Property("Retries") + .HasColumnType("int"); + + b.Property("Succeeded") + .HasColumnType("int"); + + b.Property("Total") + .HasColumnType("int"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId"); + + b.ToTable("ExchangeStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("datetime2"); + + b.Property("AckRate") + .HasColumnType("float"); + + b.Property("Consumers") + .HasColumnType("int"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint"); + + b.Property("IncomingRate") + .HasColumnType("float"); + + b.Property("QueueDepth") + .HasColumnType("bigint"); + + b.Property("RetryBacklog") + .HasColumnType("bigint"); + + b.Property("UnhealthyConsumers") + .HasColumnType("int"); + + b.HasKey("At"); + + b.ToTable("QueueSnapshots", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationChannels", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("bit"); + + b.Property("RunOnFailedResult") + .HasColumnType("bit"); + + b.Property("RunOnSubscriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("bit"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("SourceValues") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("OccurredOn"); + + b.ToTable("OperatorActions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("int"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimedUntil") + .HasColumnType("datetime2"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Headers") + .HasColumnType("nvarchar(max)"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PublishedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn") + .HasFilter("[PublishedOn] IS NULL"); + + b.ToTable("OutboxMessages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("SecretProperties") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("LoginIdentity") + .IsUnique() + .HasFilter("[LoginIdentity] IS NOT NULL"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ExchangeIds") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("Outcome") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "StartedOn"); + + b.ToTable("ReceiveAttempts", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AlertChannelId") + .HasColumnType("int"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("AlertMode") + .HasColumnType("tinyint"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryAlertOverrides", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("datetime2"); + + b.Property("LastAttemptOn") + .HasColumnType("datetime2"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryGroupUsages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertChannelId") + .HasColumnType("int"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Groups") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime2"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint"); + + b.Property("AutoPauseAfterFailures") + .HasColumnType("int"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("nvarchar(max)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("DocumentFilter") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ExpectTraffic") + .HasColumnType("nvarchar(max)"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("IsRunning") + .HasColumnType("bit"); + + b.Property("LastException") + .HasColumnType("nvarchar(max)"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notifications") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedAutomatically") + .HasColumnType("bit"); + + b.Property("PausedOn") + .HasColumnType("datetime2"); + + b.Property("QuietSince") + .HasColumnType("datetime2"); + + b.Property("ReceiveOn") + .HasColumnType("datetime2"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiverVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("RunOnBadResponses") + .HasColumnType("bit"); + + b.Property("RunningSince") + .HasColumnType("datetime2"); + + b.Property("Temporary") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("tinyint"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ValidatorVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DataSourceId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SavedOn") + .HasColumnType("datetime2"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique(); + + b.ToTable("SubscriptionRevisions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Options") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("ManualRetry") + .HasColumnType("bit"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("SourceValues") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime2"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Suppressed") + .HasColumnType("bit"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("XchangeId"); + + b.HasIndex("ChannelId", "FinishedOn"); + + b.HasIndex("NotifierId", "FinishedOn"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("PropertiesRaw") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AttemptNumber") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("NodeName") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("OutputBad") + .HasColumnType("bit"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("bit"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RetryGroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("RetryGroupId"); + + b.HasIndex("SubscriptionId", "FinishedOn"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ClaimedOn") + .HasColumnType("datetime2"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeRetryClaims", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("bit"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("nvarchar(max)") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("bit") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("varbinary(max)") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("varbinary(max)") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("nvarchar(450)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bit") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bit") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("nvarchar(450)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bit") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bit") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "ResponseSubscription") + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("bit"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20261101090252_AddBackgroundRequests.cs b/SW.Bitween.MsSql/Migrations/20261101090252_AddBackgroundRequests.cs new file mode 100644 index 00000000..c1ab53e5 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20261101090252_AddBackgroundRequests.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class AddBackgroundRequests : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BackgroundRequests", + columns: table => new + { + Id = table.Column(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false), + Owner = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Path = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: false), + Label = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Page = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + Node = table.Column(type: "varchar(200)", unicode: false, maxLength: 200, nullable: true), + Status = table.Column(type: "int", nullable: false), + StartedOn = table.Column(type: "datetime2", nullable: false), + FinishedOn = table.Column(type: "datetime2", nullable: true), + Done = table.Column(type: "int", nullable: true), + Total = table.Column(type: "int", nullable: true), + Summary = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ResultStatus = table.Column(type: "int", nullable: true), + ResultContentType = table.Column(type: "varchar(200)", unicode: false, maxLength: 200, nullable: true), + ResultDisposition = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + Result = table.Column(type: "varbinary(max)", nullable: true), + ResultKey = table.Column(type: "varchar(500)", unicode: false, maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BackgroundRequests", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_BackgroundRequests_Owner_StartedOn", + table: "BackgroundRequests", + columns: new[] { "Owner", "StartedOn" }); + + migrationBuilder.CreateIndex( + name: "IX_BackgroundRequests_StartedOn", + table: "BackgroundRequests", + column: "StartedOn"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BackgroundRequests"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index f6c147b8..c32adc46 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -559,6 +559,84 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AuditEntries", (string)null); }); + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Done") + .HasColumnType("int"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Node") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Result") + .HasColumnType("varbinary(max)"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ResultKey") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResultStatus") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Total") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedOn"); + + b.HasIndex("Owner", "StartedOn"); + + b.ToTable("BackgroundRequests", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => { b.Property("Id") diff --git a/SW.Bitween.MySql/Migrations/20261101090257_AddBackgroundRequests.Designer.cs b/SW.Bitween.MySql/Migrations/20261101090257_AddBackgroundRequests.Designer.cs new file mode 100644 index 00000000..c85f74b6 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20261101090257_AddBackgroundRequests.Designer.cs @@ -0,0 +1,3564 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SW.Bitween.MySql; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20261101090257_AddBackgroundRequests")] + partial class AddBackgroundRequests + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint unsigned"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("Kind") + .HasColumnType("tinyint unsigned"); + + b.Property("LastSignInOn") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethods") + .HasColumnType("tinyint unsigned"); + + b.Property("MicrosoftIdentity") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("MustChangePassword") + .HasColumnType("tinyint(1)"); + + b.Property("OidcIdentity") + .HasColumnType("longtext"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EmailedOn") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresOn") + .HasColumnType("datetime(6)"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("AccountLinks", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethod") + .HasColumnType("tinyint unsigned"); + + b.Property("SupersededOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresOn") + .HasColumnType("datetime(6)"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("LastUsedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevokedOn") + .HasColumnType("datetime(6)"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("ServiceTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId"); + + b.ToTable("AdapterDrafts", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("DraftId") + .HasColumnType("int"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId", "OccurredOn"); + + b.ToTable("AdapterReleases", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId", "OccurredOn"); + + b.ToTable("AdapterSourceAccesses", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("LastUsedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("PartnerId", "KeyName"); + + b.ToTable("ApiKeyUses", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Changes") + .HasColumnType("longtext"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("varchar(10)"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("OccurredOn"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn"); + + b.ToTable("AuditEntries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Done") + .HasColumnType("int"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Node") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Result") + .HasColumnType("longblob"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ResultKey") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResultStatus") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Total") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedOn"); + + b.HasIndex("Owner", "StartedOn"); + + b.ToTable("BackgroundRequests", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AcquiredOn") + .HasColumnType("datetime(6)"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Term") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("ClusterLeases", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("DataSources") + .HasColumnType("tinyint(1)"); + + b.Property("Host") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("LastSeenOn") + .HasColumnType("datetime(6)"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("int"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Runtimes") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenOn"); + + b.ToTable("ClusterNodes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("longtext"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("datetime(6)"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AuthorizedOn") + .HasColumnType("datetime(6)"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ClientSecret") + .HasColumnType("longtext"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Grant") + .HasColumnType("int"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Password") + .HasColumnType("longtext"); + + b.Property("RefreshToken") + .HasColumnType("longtext"); + + b.Property("RenewingUntil") + .HasColumnType("datetime(6)"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Connections", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("varchar(8000)"); + + b.HasKey("AdapterId", "InstanceKey", "Name"); + + b.ToTable("AdapterStates", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CpuLimitSamples") + .HasColumnType("int"); + + b.Property("CpuPercentLimit") + .HasColumnType("double"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("int"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("LastException") + .HasColumnType("longtext"); + + b.Property("LastHeartbeatOn") + .HasColumnType("datetime(6)"); + + b.Property("LastKnownState") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OwnedByNode") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Placement") + .HasColumnType("int"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("SecretProperties") + .HasColumnType("longtext"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DataSources", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("CursorColumn") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkGroupId"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique(); + + b.ToTable("DataSourceStatements", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .IsUnicode(false) + .HasColumnType("varchar(400)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("SeenOn") + .HasColumnType("datetime(6)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DataSourceId"); + + b.HasIndex("SeenOn"); + + b.ToTable("InboundMessages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("On") + .HasColumnType("datetime(6)"); + + b.Property("RunFailures") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("tinyint(1)"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DropDuplicates") + .HasColumnType("tinyint(1)"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("longtext"); + + b.Property("RetiredOn") + .HasColumnType("datetime(6)"); + + b.Property("ValidationSchema") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Dropped") + .HasColumnType("tinyint(1)"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "OccurredOn"); + + b.ToTable("DuplicateArrivals", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("FailureAnswer") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Endpoint") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("EndpointProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DataSourceId"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("SecretProperties") + .HasColumnType("longtext"); + + b.Property("Values") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("Scope", "Key") + .IsUnique(); + + b.ToTable("IdempotencyKeys", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("datetime(6)"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("AdapterId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Role") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Runtime") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Calls") + .HasColumnType("int"); + + b.Property("Crashes") + .HasColumnType("int"); + + b.Property("D0") + .HasColumnType("int"); + + b.Property("D1") + .HasColumnType("int"); + + b.Property("D2") + .HasColumnType("int"); + + b.Property("D3") + .HasColumnType("int"); + + b.Property("D4") + .HasColumnType("int"); + + b.Property("D5") + .HasColumnType("int"); + + b.Property("D6") + .HasColumnType("int"); + + b.Property("D7") + .HasColumnType("int"); + + b.Property("D8") + .HasColumnType("int"); + + b.Property("D9") + .HasColumnType("int"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint"); + + b.Property("DurationSumMs") + .HasColumnType("bigint"); + + b.Property("Failures") + .HasColumnType("int"); + + b.Property("LimitStops") + .HasColumnType("int"); + + b.Property("Timeouts") + .HasColumnType("int"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime"); + + b.ToTable("AdapterStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("datetime(6)"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Scope") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("Count") + .HasColumnType("int"); + + b.Property("LastSeenOn") + .HasColumnType("datetime(6)"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint"); + + b.ToTable("ErrorStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("datetime(6)"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("BadResponse") + .HasColumnType("int"); + + b.Property("BytesIn") + .HasColumnType("bigint"); + + b.Property("BytesOut") + .HasColumnType("bigint"); + + b.Property("D0") + .HasColumnType("int"); + + b.Property("D1") + .HasColumnType("int"); + + b.Property("D2") + .HasColumnType("int"); + + b.Property("D3") + .HasColumnType("int"); + + b.Property("D4") + .HasColumnType("int"); + + b.Property("D5") + .HasColumnType("int"); + + b.Property("D6") + .HasColumnType("int"); + + b.Property("D7") + .HasColumnType("int"); + + b.Property("D8") + .HasColumnType("int"); + + b.Property("D9") + .HasColumnType("int"); + + b.Property("DbCommands") + .HasColumnType("bigint"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint"); + + b.Property("DurationSumMs") + .HasColumnType("bigint"); + + b.Property("Failed") + .HasColumnType("int"); + + b.Property("FailedAtHandler") + .HasColumnType("int"); + + b.Property("FailedAtMapper") + .HasColumnType("int"); + + b.Property("ManualRetries") + .HasColumnType("int"); + + b.Property("PayloadParses") + .HasColumnType("bigint"); + + b.Property("Retries") + .HasColumnType("int"); + + b.Property("Succeeded") + .HasColumnType("int"); + + b.Property("Total") + .HasColumnType("int"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId"); + + b.ToTable("ExchangeStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("datetime(6)"); + + b.Property("AckRate") + .HasColumnType("double"); + + b.Property("Consumers") + .HasColumnType("int"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint"); + + b.Property("IncomingRate") + .HasColumnType("double"); + + b.Property("QueueDepth") + .HasColumnType("bigint"); + + b.Property("RetryBacklog") + .HasColumnType("bigint"); + + b.Property("UnhealthyConsumers") + .HasColumnType("int"); + + b.HasKey("At"); + + b.ToTable("QueueSnapshots", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationChannels", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnFailedResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnSubscriptions") + .HasColumnType("longtext"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("tinyint(1)"); + + b.Property("CorrelationId") + .HasColumnType("longtext"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("FileName") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("SourceValues") + .HasColumnType("longtext"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("OccurredOn"); + + b.ToTable("OperatorActions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("int"); + + b.Property("Body") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ClaimedUntil") + .HasColumnType("datetime(6)"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Headers") + .HasColumnType("longtext"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("PublishedOn", "CreatedOn"); + + b.ToTable("OutboxMessages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("longtext"); + + b.Property("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("SecretProperties") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("LoginIdentity") + .IsUnique(); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExchangeIds") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "StartedOn"); + + b.ToTable("ReceiveAttempts", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("AlertChannelId") + .HasColumnType("int"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("longtext"); + + b.Property("AlertMode") + .HasColumnType("tinyint unsigned"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryAlertOverrides", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("LastAttemptOn") + .HasColumnType("datetime(6)"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryGroupUsages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AlertChannelId") + .HasColumnType("int"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("longtext"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Groups") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint unsigned"); + + b.Property("AutoPauseAfterFailures") + .HasColumnType("int"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("longtext"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("DocumentFilter") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ExpectTraffic") + .HasColumnType("longtext"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.Property("LastException") + .HasColumnType("longtext"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Notifications") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedAutomatically") + .HasColumnType("tinyint(1)"); + + b.Property("PausedOn") + .HasColumnType("datetime(6)"); + + b.Property("QuietSince") + .HasColumnType("datetime(6)"); + + b.Property("ReceiveOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("longtext"); + + b.Property("ReceiverVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("RunOnBadResponses") + .HasColumnType("tinyint(1)"); + + b.Property("RunningSince") + .HasColumnType("datetime(6)"); + + b.Property("Temporary") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("longtext"); + + b.Property("ValidatorVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DataSourceId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("varchar(255)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SavedOn") + .HasColumnType("datetime(6)"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique(); + + b.ToTable("SubscriptionRevisions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Options") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("ManualRetry") + .HasColumnType("tinyint(1)"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("SourceValues") + .HasColumnType("longtext"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.Property("Suppressed") + .HasColumnType("tinyint(1)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("XchangeId"); + + b.HasIndex("ChannelId", "FinishedOn"); + + b.HasIndex("NotifierId", "FinishedOn"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("PropertiesRaw") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AttemptNumber") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("NodeName") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("OutputBad") + .HasColumnType("tinyint(1)"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("tinyint(1)"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("longtext"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RetryGroupId") + .HasColumnType("char(36)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("RetryGroupId"); + + b.HasIndex("SubscriptionId", "FinishedOn"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ClaimedOn") + .HasColumnType("datetime(6)"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeRetryClaims", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("longtext") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("longtext") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("tinyint(1)") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("longblob") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("longblob") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("varchar(200)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("tinyint(1)") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("tinyint(1)") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("varchar(200)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "ResponseSubscription") + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("tinyint(1)"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint unsigned"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20261101090257_AddBackgroundRequests.cs b/SW.Bitween.MySql/Migrations/20261101090257_AddBackgroundRequests.cs new file mode 100644 index 00000000..410ac380 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20261101090257_AddBackgroundRequests.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class AddBackgroundRequests : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BackgroundRequests", + columns: table => new + { + Id = table.Column(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Owner = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Path = table.Column(type: "varchar(4000)", maxLength: 4000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Label = table.Column(type: "varchar(200)", maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Page = table.Column(type: "varchar(2000)", maxLength: 2000, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Node = table.Column(type: "varchar(200)", unicode: false, maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Status = table.Column(type: "int", nullable: false), + StartedOn = table.Column(type: "datetime(6)", nullable: false), + FinishedOn = table.Column(type: "datetime(6)", nullable: true), + Done = table.Column(type: "int", nullable: true), + Total = table.Column(type: "int", nullable: true), + Summary = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ResultStatus = table.Column(type: "int", nullable: true), + ResultContentType = table.Column(type: "varchar(200)", unicode: false, maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ResultDisposition = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Result = table.Column(type: "longblob", nullable: true), + ResultKey = table.Column(type: "varchar(500)", unicode: false, maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_BackgroundRequests", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_BackgroundRequests_Owner_StartedOn", + table: "BackgroundRequests", + columns: new[] { "Owner", "StartedOn" }); + + migrationBuilder.CreateIndex( + name: "IX_BackgroundRequests_StartedOn", + table: "BackgroundRequests", + column: "StartedOn"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BackgroundRequests"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index 0c84395d..295e2810 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -556,6 +556,84 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AuditEntries", (string)null); }); + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Done") + .HasColumnType("int"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Node") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Result") + .HasColumnType("longblob"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ResultKey") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResultStatus") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Total") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedOn"); + + b.HasIndex("Owner", "StartedOn"); + + b.ToTable("BackgroundRequests", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => { b.Property("Id") diff --git a/SW.Bitween.PgSql/BitweenDbContext.cs b/SW.Bitween.PgSql/BitweenDbContext.cs index 5555f926..77fdc107 100644 --- a/SW.Bitween.PgSql/BitweenDbContext.cs +++ b/SW.Bitween.PgSql/BitweenDbContext.cs @@ -171,6 +171,26 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(u => u.KeyName).HasMaxLength(500); }); + modelBuilder.Entity(b => + { + b.ToTable("background_request"); + b.HasKey(i => i.Id); + b.Property(i => i.Id).HasMaxLength(32); + b.Property(i => i.Owner).IsRequired().HasMaxLength(50); + b.Property(i => i.Path).IsRequired().HasMaxLength(4000); + b.Property(i => i.Label).HasMaxLength(200); + b.Property(i => i.Page).HasMaxLength(2000); + b.Property(i => i.Node).HasMaxLength(200); + b.Property(i => i.ResultContentType).HasMaxLength(200); + b.Ignore(i => i.Finished); + b.Ignore(i => i.Answered); + b.Property(i => i.Summary).HasMaxLength(500); + b.Property(i => i.ResultDisposition).HasMaxLength(500); + b.Property(i => i.ResultKey).HasMaxLength(500); + b.HasIndex(i => new { i.Owner, i.StartedOn }); + b.HasIndex(i => i.StartedOn); + }); + modelBuilder.Entity(b => { b.ToTable("cluster_node"); diff --git a/SW.Bitween.PgSql/Migrations/20261101090246_AddBackgroundRequests.Designer.cs b/SW.Bitween.PgSql/Migrations/20261101090246_AddBackgroundRequests.Designer.cs new file mode 100644 index 00000000..0df3c28c --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261101090246_AddBackgroundRequests.Designer.cs @@ -0,0 +1,4197 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SW.Bitween.Model; +using SW.Bitween.PgSql; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20261101090246_AddBackgroundRequests")] + partial class AddBackgroundRequests + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasColumnName("failed_login_count"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("LastSignInOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in_on"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("MicrosoftIdentity") + .HasColumnType("text") + .HasColumnName("microsoft_identity"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("OidcIdentity") + .HasColumnType("text") + .HasColumnName("oidc_identity"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("email_error"); + + b.Property("EmailedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("emailed_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("Purpose") + .HasColumnType("smallint") + .HasColumnName("purpose"); + + b.HasKey("Id") + .HasName("pk_account_links"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_links_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_account_links_hash"); + + b.ToTable("AccountLinks", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.Property("SupersededOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_on"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("last_used_from"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("revoked_by"); + + b.Property("RevokedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_on"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("shown"); + + b.HasKey("Id") + .HasName("pk_service_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_service_tokens_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_service_tokens_hash"); + + b.ToTable("ServiceTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("base_version"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("files_hash"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("files_json"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("language"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_adapter_draft"); + + b.HasIndex("AdapterId") + .HasDatabaseName("ix_adapter_draft_adapter_id"); + + b.ToTable("adapter_draft", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("action"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("DraftId") + .HasColumnType("integer") + .HasColumnName("draft_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_release"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_release_adapter_id_occurred_on"); + + b.ToTable("adapter_release", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("path"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_source_access"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_source_access_adapter_id_occurred_on"); + + b.ToTable("adapter_source_access", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key_name"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.HasKey("PartnerId", "KeyName") + .HasName("pk_api_key_use"); + + b.ToTable("api_key_use", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Changes") + .HasColumnType("text") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("character varying(36)") + .HasColumnName("correlation_id"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_key"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_name"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Sequence") + .HasColumnType("integer") + .HasColumnName("sequence"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("character varying(10)") + .HasColumnName("state"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_audit_entries"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_entries_correlation_id"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_audit_entries_occurred_on"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn") + .HasDatabaseName("ix_audit_entries_entity_name_entity_key_occurred_on"); + + b.ToTable("AuditEntries", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Done") + .HasColumnType("integer") + .HasColumnName("done"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("Node") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("node"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("owner"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("path"); + + b.Property("Result") + .HasColumnType("bytea") + .HasColumnName("result"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("result_content_type"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_disposition"); + + b.Property("ResultKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_key"); + + b.Property("ResultStatus") + .HasColumnType("integer") + .HasColumnName("result_status"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("summary"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Id") + .HasName("pk_background_request"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_background_request_started_on"); + + b.HasIndex("Owner", "StartedOn") + .HasDatabaseName("ix_background_request_owner_started_on"); + + b.ToTable("background_request", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("AcquiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_on"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("owner_node"); + + b.Property("Term") + .HasColumnType("bigint") + .HasColumnName("term"); + + b.HasKey("Id") + .HasName("pk_cluster_lease"); + + b.ToTable("cluster_lease", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("DataSources") + .HasColumnType("boolean") + .HasColumnName("data_sources"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("host"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("integer") + .HasColumnName("outbox_published_last_hour"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("resident_health"); + + b.Property("Runtimes") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtimes"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_cluster_node"); + + b.HasIndex("LastSeenOn") + .HasDatabaseName("ix_cluster_node_last_seen_on"); + + b.ToTable("cluster_node", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("access_token_expires_on"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("authorize_parameters"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("authorize_url"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("authorized_by"); + + b.Property("AuthorizedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("authorized_on"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Grant") + .HasColumnType("integer") + .HasColumnName("grant"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property("RenewingUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("renewing_until"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("scopes"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("token_auth"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("token_url"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_connections"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_connections_name"); + + b.ToTable("Connections", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("instance_key"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("value"); + + b.HasKey("AdapterId", "InstanceKey", "Name") + .HasName("pk_adapter_state"); + + b.ToTable("adapter_state", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .HasColumnType("text") + .HasColumnName("adapter_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CpuLimitSamples") + .HasColumnType("integer") + .HasColumnName("cpu_limit_samples"); + + b.Property("CpuPercentLimit") + .HasColumnType("double precision") + .HasColumnName("cpu_percent_limit"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("integer") + .HasColumnName("deduplication_window_days"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("hard_memory_limit_mb"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("LastHeartbeatOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_heartbeat_on"); + + b.Property("LastKnownState") + .HasColumnType("text") + .HasColumnName("last_known_state"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnedByNode") + .HasColumnType("text") + .HasColumnName("owned_by_node"); + + b.Property("Placement") + .HasColumnType("integer") + .HasColumnName("placement"); + + b.Property>("Properties") + .HasColumnType("hstore") + .HasColumnName("properties"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("text[]") + .HasColumnName("secret_properties"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("soft_memory_limit_mb"); + + b.HasKey("Id") + .HasName("pk_data_source"); + + b.ToTable("data_source", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("CursorColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("cursor_column"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key_column"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sql"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_data_source_statement"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_data_source_statement_work_group_id"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique() + .HasDatabaseName("ix_data_source_statement_data_source_id_name"); + + b.ToTable("data_source_statement", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("id"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("SeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("seen_on"); + + b.Property("XchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_inbound_message"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_inbound_message_data_source_id"); + + b.HasIndex("SeenOn") + .HasDatabaseName("ix_inbound_message_seen_on"); + + b.ToTable("inbound_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.Property("RunFailures") + .HasColumnType("integer") + .HasColumnName("run_failures"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DropDuplicates") + .HasColumnType("boolean") + .HasColumnName("drop_duplicates"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("json") + .HasColumnName("promoted_properties"); + + b.Property("RetiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_on"); + + b.Property("ValidationSchema") + .HasColumnType("text") + .HasColumnName("validation_schema"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Dropped") + .HasColumnType("boolean") + .HasColumnName("dropped"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("original_xchange_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.HasKey("Id") + .HasName("pk_duplicate_arrival"); + + b.HasIndex("DocumentId", "OccurredOn") + .HasDatabaseName("ix_duplicate_arrival_document_id_occurred_on"); + + b.ToTable("duplicate_arrival", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("integer") + .HasColumnName("auth_method"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FailureAnswer") + .HasColumnType("integer") + .HasColumnName("failure_answer"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_audience"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_issuer"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("jwt_partner_claim"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("partner_key_header"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Endpoint") + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property>("EndpointProperties") + .HasColumnType("hstore") + .HasColumnName("endpoint_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_bus_gateway_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.Property("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_idempotency_key"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_idempotency_key_created_on"); + + b.HasIndex("Scope", "Key") + .IsUnique() + .HasDatabaseName("ix_idempotency_key_scope_key"); + + b.ToTable("idempotency_key", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("Role") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Runtime") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("runtime"); + + b.Property("Calls") + .HasColumnType("integer") + .HasColumnName("calls"); + + b.Property("Crashes") + .HasColumnType("integer") + .HasColumnName("crashes"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failures") + .HasColumnType("integer") + .HasColumnName("failures"); + + b.Property("LimitStops") + .HasColumnType("integer") + .HasColumnName("limit_stops"); + + b.Property("Timeouts") + .HasColumnType("integer") + .HasColumnName("timeouts"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime") + .HasName("pk_adapter_stats"); + + b.ToTable("adapter_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("Scope") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("scope"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope_key"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("fingerprint"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("sample"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint") + .HasName("pk_error_stats"); + + b.ToTable("error_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("BadResponse") + .HasColumnType("integer") + .HasColumnName("bad_response"); + + b.Property("BytesIn") + .HasColumnType("bigint") + .HasColumnName("bytes_in"); + + b.Property("BytesOut") + .HasColumnType("bigint") + .HasColumnName("bytes_out"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DbCommands") + .HasColumnType("bigint") + .HasColumnName("db_commands"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failed") + .HasColumnType("integer") + .HasColumnName("failed"); + + b.Property("FailedAtHandler") + .HasColumnType("integer") + .HasColumnName("failed_at_handler"); + + b.Property("FailedAtMapper") + .HasColumnType("integer") + .HasColumnName("failed_at_mapper"); + + b.Property("ManualRetries") + .HasColumnType("integer") + .HasColumnName("manual_retries"); + + b.Property("PayloadParses") + .HasColumnType("bigint") + .HasColumnName("payload_parses"); + + b.Property("Retries") + .HasColumnType("integer") + .HasColumnName("retries"); + + b.Property("Succeeded") + .HasColumnType("integer") + .HasColumnName("succeeded"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId") + .HasName("pk_exchange_stats"); + + b.ToTable("exchange_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("timestamp with time zone") + .HasColumnName("at"); + + b.Property("AckRate") + .HasColumnType("double precision") + .HasColumnName("ack_rate"); + + b.Property("Consumers") + .HasColumnType("integer") + .HasColumnName("consumers"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint") + .HasColumnName("dead_letter_backlog"); + + b.Property("IncomingRate") + .HasColumnType("double precision") + .HasColumnName("incoming_rate"); + + b.Property("QueueDepth") + .HasColumnType("bigint") + .HasColumnName("queue_depth"); + + b.Property("RetryBacklog") + .HasColumnType("bigint") + .HasColumnName("retry_backlog"); + + b.Property("UnhealthyConsumers") + .HasColumnType("integer") + .HasColumnName("unhealthy_consumers"); + + b.HasKey("At") + .HasName("pk_queue_snapshot"); + + b.ToTable("queue_snapshot", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_notification_channel"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_channel_name"); + + b.ToTable("notification_channel", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.PrimitiveCollection("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("detail"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("target"); + + b.HasKey("Id") + .HasName("pk_operator_action"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_operator_action_occurred_on"); + + b.ToTable("operator_action", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ClaimedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_until"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_on"); + + b.HasKey("Id") + .HasName("pk_outbox_message"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_outbox_message_created_on") + .HasFilter("published_on IS NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("outbox_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("login_identity"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.HasIndex("LoginIdentity") + .IsUnique() + .HasDatabaseName("ix_partner_login_identity"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.PrimitiveCollection("ExchangeIds") + .HasColumnType("text[]") + .HasColumnName("exchange_ids"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_receive_attempt"); + + b.HasIndex("SubscriptionId", "StartedOn") + .HasDatabaseName("ix_receive_attempt_subscription_id_started_on"); + + b.ToTable("receive_attempt", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("AlertMode") + .HasColumnType("smallint") + .HasColumnName("alert_mode"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_alert_override"); + + b.ToTable("retry_alert_override", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AttemptsUsed") + .HasColumnType("integer") + .HasColumnName("attempts_used"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("exhausted_notified_on"); + + b.Property("LastAttemptOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_on"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_group_usage"); + + b.ToTable("retry_group_usage", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_settings"); + + b.ToTable("Settings", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("AutoPauseAfterFailures") + .HasColumnType("integer") + .HasColumnName("auto_pause_after_failures"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ExpectTraffic") + .HasColumnType("text") + .HasColumnName("expect_traffic"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Notifications") + .HasColumnType("text") + .HasColumnName("notifications"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedAutomatically") + .HasColumnType("boolean") + .HasColumnName("paused_automatically"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("QuietSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("quiet_since"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ReceiverVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("receiver_version"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("RunOnBadResponses") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_responses"); + + b.Property("RunningSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("running_since"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("ValidatorVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("validator_version"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_subscription_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("saved_by"); + + b.Property("SavedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("saved_on"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("text") + .HasColumnName("snapshot"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_revision"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique() + .HasDatabaseName("ix_subscription_revision_subscription_id_number"); + + b.ToTable("subscription_revision", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("files_prefix"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer") + .HasColumnName("channel_id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Suppressed") + .HasColumnType("boolean") + .HasColumnName("suppressed"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.HasIndex("XchangeId") + .HasDatabaseName("ix_xchange_notification_xchange_id"); + + b.HasIndex("ChannelId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_channel_id_finished_on"); + + b.HasIndex("NotifierId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_notifier_id_finished_on"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.PrimitiveCollection("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("NodeName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node_name"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("retry_blocked_reason"); + + b.Property("RetryGroupId") + .HasColumnType("uuid") + .HasColumnName("retry_group_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("warning"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.HasIndex("RetryGroupId") + .HasDatabaseName("ix_xchange_result_retry_group_id"); + + b.HasIndex("SubscriptionId", "FinishedOn") + .HasDatabaseName("ix_xchange_result_subscription_id_finished_on") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("ClaimedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_on"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_retry_claim"); + + b.ToTable("xchange_retry_claim", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_links_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_service_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_data_source_statement_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_data_source_statement_work_group_work_group_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inbound_message_data_source_data_source_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .HasConstraintName("fk_bus_gateway_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("key_prefix"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "ResponseSubscription") + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_revision_subscription_subscription_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_retry_claim_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20261101090246_AddBackgroundRequests.cs b/SW.Bitween.PgSql/Migrations/20261101090246_AddBackgroundRequests.cs new file mode 100644 index 00000000..5bea6dc6 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261101090246_AddBackgroundRequests.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class AddBackgroundRequests : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "background_request", + schema: "infolink", + columns: table => new + { + id = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + owner = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + path = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + label = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + page = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + node = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + status = table.Column(type: "integer", nullable: false), + started_on = table.Column(type: "timestamp with time zone", nullable: false), + finished_on = table.Column(type: "timestamp with time zone", nullable: true), + done = table.Column(type: "integer", nullable: true), + total = table.Column(type: "integer", nullable: true), + summary = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + result_status = table.Column(type: "integer", nullable: true), + result_content_type = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + result_disposition = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + result = table.Column(type: "bytea", nullable: true), + result_key = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_background_request", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_background_request_owner_started_on", + schema: "infolink", + table: "background_request", + columns: new[] { "owner", "started_on" }); + + migrationBuilder.CreateIndex( + name: "ix_background_request_started_on", + schema: "infolink", + table: "background_request", + column: "started_on"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "background_request", + schema: "infolink"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index 492ba258..f04b40d5 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -679,6 +679,100 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AuditEntries", "infolink"); }); + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Done") + .HasColumnType("integer") + .HasColumnName("done"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("Node") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("node"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("owner"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("path"); + + b.Property("Result") + .HasColumnType("bytea") + .HasColumnName("result"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("result_content_type"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_disposition"); + + b.Property("ResultKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_key"); + + b.Property("ResultStatus") + .HasColumnType("integer") + .HasColumnName("result_status"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("summary"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Id") + .HasName("pk_background_request"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_background_request_started_on"); + + b.HasIndex("Owner", "StartedOn") + .HasDatabaseName("ix_background_request_owner_started_on"); + + b.ToTable("background_request", "infolink"); + }); + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => { b.Property("Id") diff --git a/SW.Bitween.Sdk/Model/Xchange.cs b/SW.Bitween.Sdk/Model/Xchange.cs index 902ba788..7b9bcfb6 100644 --- a/SW.Bitween.Sdk/Model/Xchange.cs +++ b/SW.Bitween.Sdk/Model/Xchange.cs @@ -114,8 +114,9 @@ public class XchangeBulkRetryPlan public int WillRetry { get; set; } /// - /// The largest selection one retry request carries out. A bigger one is retried in rounds of - /// this many, from . + /// The largest selection a retry asked directly carries out; a bigger one is retried in rounds + /// of this many, from . One asked to carry on in the background + /// (Prefer: respond-async) takes up to at once. /// public int Limit { get; set; } @@ -144,6 +145,24 @@ public class XchangeBulkRetryPlan /// Selections nothing will be done about, each with the reason. public List Skipped { get; set; } = new List(); + /// + /// Set when the retry was cancelled part way: how many of it had dealt with + /// when it stopped. Those were retried or skipped as said; the rest were left as they were. + /// + public int? StoppedAfter { get; set; } + + /// + /// How many selections were substituted, and how many skipped, in all. + /// and list the first of each. + /// + public int SubstitutedCount { get; set; } + + /// + public int SkippedCount { get; set; } + + /// How many of each kind the plan lists by name; a selection of hundreds of thousands lists the first ones. + public int Listed { get; set; } + /// /// Promoted properties for every exchange named in or /// , keyed by id, so the caller can name them the way the exchange diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index 78aa5ce0..39ea00b4 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -118,6 +118,10 @@ public void ConfigureServices(IServiceCollection services) // The bell a sync gateway call waits on for its exchange's result. services.AddSingleton(); services.AddSingleton(); + // The admin UI's long reads, carried on in the background; their owners hear over SignalR. + services.AddSingleton(); + services.AddSingleton(); + services.AddSignalR(); // Metrics: counted in memory as work happens, added to hourly rows once a minute per node. services.AddSingleton(); services.AddSingleton(); @@ -578,6 +582,12 @@ public void ConfigureServices(IServiceCollection services) { if (context.Request.Path.StartsWithSegments("/api/gateway")) context.NoResult(); + // A browser can't put a header on a WebSocket, so SignalR sends the token in + // the address instead; taken from there for the hub only. + else if (context.Request.Path.StartsWithSegments( + SW.Bitween.Services.Background.BackgroundRequestHub.Path) && + context.Request.Query["access_token"].FirstOrDefault() is { Length: > 0 } token) + context.Token = token; return Task.CompletedTask; }, }; @@ -1069,6 +1079,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) await next(); }); + // After everything that decides whether a request may run, and before the request context + // is filled in: a read the admin UI lets go on in the background keeps the first's outcome + // and fills in the second for itself. + app.UseMiddleware(); + app.UseHttpAsRequestContext(); SW.Logger.Console.IAppBuilderExtensions.UseRequestContextLogEnricher(app); @@ -1079,6 +1094,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseEndpoints(endpoints => { endpoints.MapControllers(); + endpoints.MapHub( + SW.Bitween.Services.Background.BackgroundRequestHub.Path); // /health keeps answering as it always has - the process is up - because probes // already point at it. /health/live says the same under the conventional name, and // /health/ready also asks the database and the broker; see DependencyHealthChecks. diff --git a/docs/api-reference.md b/docs/api-reference.md index b028f801..054dc612 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -30,6 +30,23 @@ Endpoints marked *searchy* accept these parameters. They return `{ "result": [...], "totalCount": 123 }`. +### Work that takes long + +A `GET` or `POST` that sends `Prefer: respond-async` is answered as usual when it answers within the wait it asks for (`Prefer: respond-async, wait=3`: three seconds if it names none, thirty at most). Otherwise it answers `202 Accepted` with `{ "id": "...", "status": "Running" }` and a `Location` header, and carries on in the background. The admin UI asks this way for the work that can take long: prod's list to bring from and bringing it, bulk retry and its preview, and exporting exchange files. Every other call, the UI's own included, is answered as before. + +- It runs as the caller, with ten minutes to get anywhere rather than the database's usual limit (30 seconds unless the connection string sets another). Work that says how far it has got, like a bulk retry, has its ten minutes start again each time it does. +- A node runs at most 10 of them at a time, and 3 of one person's. A read over either waits (`Waiting`) and is asked again from the start once there is a place; a write, which can't be asked twice, goes on. +- Its answer is kept for 30 minutes, for the person who asked only. A file, such as an export's zip, is kept in storage under `background-requests/`; a small answer in the database. + +| Endpoint | What it does | +|---|---| +| `GET /api/background` | The caller's background requests, newest first: those running and those finished in the last 30 minutes, at most 20. `status` is `Waiting`, `Running`, `Done`, `Cancelled`, `TimedOut` or `Stopped` (the node running it stopped first). Work that counts its steps also has `done`, `total` and `summary`. `path` starts with the method for anything but a `GET`. | +| `GET /api/background/{id}/result` | The answer as the request would have given it: its status, content type, `Content-Disposition` and body - including what work cancelled part way did before it stopped. Otherwise `409` while it runs, `410` cancelled, `504` timed out, `503` stopped, and `404` once it is no longer kept. | +| `POST /api/background/{id}/link` | `{ url }`: a link that downloads the file it answered with, such as an export's zip, for 10 minutes. Only its owner gets one. | +| `GET /api/background/{id}/file?expires=&seal=` | The file, with no sign-in: the link's seal, made from `Token:Key`, is what opens it, so a browser downloads it the ordinary way, straight to disk. A changed or expired link answers `404`. | +| `DELETE /api/background/{id}` | Cancels it, on whichever node runs it. Work that counts its steps stops after the one it is on and answers with what it did; anything else stops at once, with the database command it is running. | +| `/api/background/hub` | SignalR hub. Sends `changed` with `{ id, status, done, total, summary }` to the owner's connections when one of theirs changes. A browser sends its token as `access_token` in the address, since it can't put a header on a WebSocket. | + ## Partner endpoints | Method and path | Description | @@ -109,9 +126,9 @@ Exchange search filters, besides ordinary fields such as `SubscriptionId`, `Part Rule 2 turns each of these round except `StatusFilter` and `LatestOnly`: `ErrorContains:2:timeout` is "the error does not contain timeout", and it keeps the exchanges with no error. Each `PromotedProperty` turns round on its own. In the older form, send the key and the text with the same rule, since they turn round together. -Bulk retry takes `{ ids, filter, excludeIds, reason, reset }`. `filter` is the same query-string fragment the search takes, such as `filter=StatusFilter:1:3&filter=LatestOnly:1:true`, and it replaces `ids` when present. The plan returned by both endpoints lists how many were selected and will be retried, attempts substituted with the end of their chain, and skipped exchanges with reasons, plus `targets`, the attempts that will run. The retry carries out at most `limit` (500) per request; the preview plans up to `maxSelection` (5,000), so a larger selection is retried by sending `targets` back as `ids`, `limit` at a time. +Bulk retry takes `{ ids, filter, excludeIds, reason, reset }`. `filter` is the same query-string fragment the search takes, such as `filter=StatusFilter:1:3&filter=LatestOnly:1:true`, and it replaces `ids` when present. The plan returned by both endpoints lists how many were selected and will be retried, attempts substituted with the end of their chain, and skipped exchanges with reasons, plus `targets`, the attempts that will run. Asked directly, the retry carries out at most `limit` (500) per request, so a larger selection is retried by sending `targets` back as `ids`, `limit` at a time. Asked to carry on in the background ([Work that takes long](#work-that-takes-long)), it takes up to `maxSelection` (500,000) at once, which is also as far as the preview plans, keeping at most 1,000 of its exchanges waiting in the pipeline at a time; `targets` is left empty then. Cancelled part way, its plan's `stoppedAfter` says how many it dealt with. `substitutedCount` and `skippedCount` count all of them; `substituted` and `skipped` name the first `listed` (1,000) of each. -Export takes `{ ids, filter, excludeIds }`, chosen the same way, and streams back a zip (`application/zip`) with a folder per exchange and a `missing.txt` listing files that storage no longer has. At most 500 exchanges go in one zip; a larger selection is refused with `TOO_MANY`. `export/check` takes the same body and returns `{ count, withoutFiles, keptDays }`: how many exchanges the zip would take, how many of them are older than the bucket's rule keeps files, and that rule's days when one covers them all. +Export takes `{ ids, filter, excludeIds }`, chosen the same way, and streams back a zip (`application/zip`) with a folder per exchange and a `missing.txt` listing files that storage no longer has. At most 500 exchanges go in one zip asked directly, and 50,000 carried on in the background ([Work that takes long](#work-that-takes-long)), as the admin UI asks; a larger selection is refused with `TOO_MANY`, and `export/check` counts against the 50,000. A zip stops taking exchanges once it passes 1 GB, and `missing.txt` lists where it stopped. The UI downloads it from a link rather than the result: see `/link` below. `export/check` takes the same body and returns `{ count, withoutFiles, keptDays }`: how many exchanges the zip would take, how many of them are older than the bucket's rule keeps files, and that rule's days when one covers them all. The retry tree returns `{ rootId, nodes, truncated }`. Each node has its id, `retryFor`, promoted properties, times, status, exception, whether it was manual, and any scheduled retry or blocked reason. It stops at 100 levels or 500 attempts. diff --git a/docs/caveats.md b/docs/caveats.md index 3f65ca28..1ed5c02f 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -96,6 +96,18 @@ These behaviours were found while documenting Bitween from its source code, and | A replica that stops leaves its scheduled jobs idle for up to 30 seconds, Quartz's idle wait, before another replica picks them up. | `SW.Bitween.Web/Startup.cs` | | Consecutive failures pause a subscription only when its **Auto-pause** is set; it is off by default. | `Services/XchangeService.cs` | +## Background requests + +| Issue | Where | +|---|---| +| Cancelling stops the database commands a background request runs. Anything else it waits on, such as a call to another system, runs to its end, and the answer is dropped. | `Services/Background/BackgroundRequestScope.cs` | +| The limits, 10 running and 3 of one person's, are per node, so three nodes run up to 30. A write over them - a bulk retry, an export, bringing - goes on regardless, since it can't be asked twice. | `Services/Background/BackgroundRequests.cs` | +| A read that waits for a place starts again from the beginning when it gets one, so its first seconds of work are done twice. | `Services/Background/BackgroundRequests.cs` | +| A bulk retry whose exchanges stop being processed - a paused subscription holds them - waits at its 1,000, and stops once 10 minutes pass without one finishing. It keeps what it did, but the audit trail doesn't record that run. | `Resources/Xchanges/BulkRetry.cs` | +| An export's download link opens without a sign-in for 10 minutes, to whoever has it: the owner is the only one handed it. | `Services/Background/BackgroundDownloads.cs` | +| A bulk retry stopped by a restart keeps what it saved, a hundred at a time, and the rest is not retried. Retrying the same selection again picks up the attempts it made too, since exchanges with no result yet are included. | `Resources/Xchanges/BulkRetry.cs` | +| The hub signs in with the token in its address, as a browser's WebSocket requires. A proxy that logs full addresses logs the token; Bitween's own logs don't. | `SW.Bitween.Web/Startup.cs` | + ## Deployment and tooling | Issue | Where | diff --git a/docs/deployment.md b/docs/deployment.md index 70d764d3..4dc301a6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -57,6 +57,7 @@ Things to know about the chart: - The Ingress exposes `/api`, `/swagger` and `/` by default, so the admin UI and `/blank.html` are served from the same host as the API. A deployment that overrides `ingress.paths` replaces the whole list, so it has to include `/` itself. - Probes are on by default: a startup probe and a liveness probe on `/health/live`, which only checks that the process answers, and a readiness probe on `/health/ready`, which also checks the database and that the bus is connected to RabbitMQ. A pod whose broker is down starts and stays not ready until the bus connects; it does not restart. The exception is an installation's first start on a version that records the environment name its queues are made under: it [won't start](#when-bitween-wont-start) until RabbitMQ answers which name that was. The startup probe allows 40 minutes for migrations (`probes.startupFailureThreshold`). - A stopping pod lets the exchanges it is running finish for up to 30 seconds before it closes its queues, so they are acknowledged instead of run again elsewhere. The app waits up to 45 seconds to stop, and the chart gives the pod 60 (`terminationGracePeriodSeconds`); keep that value above 45. +- While work carries on in the background, the admin UI holds a WebSocket open to `/api/background/hub`, which ingress-nginx and Gateway API routes pass through as they are. Where a proxy blocks WebSockets the UI asks every three seconds instead, and where one cuts long connections it reconnects. No sticky sessions are needed: nodes pass the news to each other over RabbitMQ. - The `rabbitmq` values are not used by any template. - `charts/default/README.md` explains the two routing modes. diff --git a/docs/retries-and-alerts.md b/docs/retries-and-alerts.md index a2caf9bd..4612e8a0 100644 --- a/docs/retries-and-alerts.md +++ b/docs/retries-and-alerts.md @@ -22,7 +22,10 @@ Content-Type: application/json `POST /api/xchanges/bulkretry` retries either a list of `ids`, or everything a search `filter` matches minus `excludeIds`. `POST /api/xchanges/bulkretrypreview` returns the same plan without running it, and the UI shows that plan before you confirm. -- One retry request carries out at most 500 exchanges. The preview works out up to 5,000 and lists the attempts that will run in `targets`; the UI sends them back as `ids` in rounds of 500 and shows how far it has got. If a round fails, the rounds before it are done and the rest are not retried. Past 5,000, narrow the filter. +- The UI retries the whole selection in one request, up to 500,000 exchanges, which carries on in the background once it takes more than a few seconds ([Work that takes long](api-reference.md#work-that-takes-long)). It saves every hundred, so the top bar shows how far it has got, and **Cancel** there stops it after the exchange it is on: those before it stay retried, and the result says how many. Past 500,000, narrow the filter. +- It goes at the pipeline's pace: no more than 1,000 of its exchanges wait to be processed at once, and it sends more as those finish. A big retry never floods the queues or the partners behind them, and the day's own traffic is never more than 1,000 behind it. One whose exchanges stop being processed - a paused subscription holds them - stops after 10 minutes without progress, keeping what it did. +- The plan counts every substitution and skip (`substitutedCount`, `skippedCount`) and names the first 1,000 of each. +- Asked directly, without carrying on in the background, one retry request still carries out at most 500; the preview lists the attempts that will run in `targets`, to send back as `ids` in rounds. - An exchange that was already retried is replaced by the newest attempt of its chain. Two selections that lead to the same attempt are retried once. - The plan skips, with a reason, exchanges that no longer exist, that succeeded, that have an automatic retry waiting, whose subscription is gone when `reset` is on, or whose input file cannot be read. - Exchanges with no result yet are included, so work stranded by an outage can be recovered. Make sure nothing is still processing before retrying it. From ecb484e55f71ae01bf5c3bf6ae6b312cb6ab0c22 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 1 Nov 2026 15:14:45 +0300 Subject: [PATCH 02/24] feat: show background work in the top bar --- SW.Bitween.Web/ClientApp/package.json | 1 + .../src/__tests__/support/renderApp.tsx | 2 + SW.Bitween.Web/ClientApp/src/api/client.ts | 9 +- .../ClientApp/src/api/http/background.ts | 302 ++++++++++++++++++ .../ClientApp/src/api/http/exchanges.ts | 50 ++- .../ClientApp/src/api/http/mirror.ts | 27 +- .../ClientApp/src/api/http/request.ts | 57 +++- SW.Bitween.Web/ClientApp/src/api/types.ts | 13 +- .../src/components/layout/AppShell.tsx | 14 +- .../src/components/layout/BackgroundWork.tsx | 239 ++++++++++++++ .../src/components/layout/TopBar.tsx | 2 + .../layout/__tests__/BackgroundWork.test.tsx | 162 ++++++++++ .../ClientApp/src/components/ui/Popover.tsx | 15 +- SW.Bitween.Web/ClientApp/src/lib/saveFile.ts | 12 + .../ClientApp/src/lib/useBackground.ts | 15 + .../src/pages/exchanges/ExchangesPage.tsx | 91 ++---- .../__tests__/ExchangesList.test.tsx | 238 +++++++------- .../ClientApp/src/pages/exchanges/shared.tsx | 48 +-- SW.Bitween.Web/ClientApp/yarn.lock | 113 ++++++- docs/admin-ui.md | 4 + 20 files changed, 1169 insertions(+), 245 deletions(-) create mode 100644 SW.Bitween.Web/ClientApp/src/api/http/background.ts create mode 100644 SW.Bitween.Web/ClientApp/src/components/layout/BackgroundWork.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/components/layout/__tests__/BackgroundWork.test.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/lib/saveFile.ts create mode 100644 SW.Bitween.Web/ClientApp/src/lib/useBackground.ts diff --git a/SW.Bitween.Web/ClientApp/package.json b/SW.Bitween.Web/ClientApp/package.json index dfc6e1c2..e8f9eb23 100644 --- a/SW.Bitween.Web/ClientApp/package.json +++ b/SW.Bitween.Web/ClientApp/package.json @@ -26,6 +26,7 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@headlessui/react": "^2.2.10", "@lezer/highlight": "^1.2.3", + "@microsoft/signalr": "^10.0.11", "@tailwindcss/vite": "^4.3.2", "@tanstack/react-query": "^5.101.2", "@uiw/react-codemirror": "^4.25.11", diff --git a/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx b/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx index 73ffb0da..59f98965 100644 --- a/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx +++ b/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx @@ -91,6 +91,8 @@ export function renderApp(path: string, { as = {}, config = {}, handlers = [] }: http.get(apiPath("/mirror"), () => HttpResponse.json({ target: false, sourceUrl: null, signIn: null, configured: false })), // Which of a list's items prod has too, which a list on a mirror shows: none, unless a test says. http.get(apiPath("/mirror/listed"), () => HttpResponse.json({ items: [], testing: null, waiting: [], error: null })), + // Reads carried on in the background, which the header asks for as the app loads: none, unless a test says. + http.get(apiPath("/background"), () => HttpResponse.json([])), ); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index c83e77a3..b8cc369c 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -775,8 +775,13 @@ export interface ApiClient { /** Retries many; exchanges with a pending auto-retry are skipped, not failed. */ bulkRetryExchanges(selection: BulkRetrySelection, opts: { reset: boolean }): Promise; previewBulkRetry(selection: BulkRetrySelection, opts: { reset: boolean }): Promise; - /** The selection's input, mapped and handled files as one zip, a folder per exchange. */ - exportExchangeFiles(selection: BulkRetrySelection): Promise<{ blob: Blob; fileName: string }>; + /** + * The selection's input, mapped and handled files as one zip, a folder per exchange, built in the + * background; answers the link that downloads it. + */ + exportExchangeFiles(selection: BulkRetrySelection): Promise<{ url: string }>; + /** A link to the file a background request answered with, such as an export's zip. */ + linkToBackgroundFile(id: string): Promise<{ url: string }>; /** How many of the selection's exchanges the export would come without files for. Changes nothing. */ checkExchangeFilesExport(selection: BulkRetrySelection): Promise; getRetryTree(id: string): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/background.ts b/SW.Bitween.Web/ClientApp/src/api/http/background.ts new file mode 100644 index 00000000..e7ffd4fa --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/background.ts @@ -0,0 +1,302 @@ +import type { HubConnection } from "@microsoft/signalr"; +import { API_BASE, getToken, request } from "./request"; + +/** + * Background requests, the admin UI's side. + * + * Work that can take long - prod's list to bring from, bringing, a bulk retry, an export - is sent + * asking to carry on in the background (`request`'s `background`). If the server is still at it after + * a few seconds it answers 202 with an id and carries on there (BackgroundRequestMiddleware). The call + * here keeps waiting - its page still gets the answer when it comes - while the header shows it, so + * the person can go and do something else. The server says when one changes, and how far it has got, + * over SignalR; this keeps the list, and collects the answer for the call that wanted it. + */ + +export type BackgroundStatus = "Waiting" | "Running" | "Done" | "Cancelled" | "TimedOut" | "Stopped"; + +export interface BackgroundRequest { + id: string; + /** What the work is called, as the call that started it named it. */ + label: string | null; + /** The UI address it was asked from, which is where its answer is shown. */ + page: string | null; + /** The API path asked, without the /api prefix; a write's begins with its method. */ + path: string; + status: BackgroundStatus; + startedOn: string; + finishedOn: string | null; + /** How far it has got, for work that counts its steps. */ + done?: number | null; + total?: number | null; + summary?: string | null; +} + +export interface BackgroundState { + requests: BackgroundRequest[]; +} + +/** How long the server is asked to wait before carrying work on in the background. */ +export const WAIT_SECONDS = 3; + +/** How often to ask for the list while SignalR can't be reached and something is running. */ +const POLL_MS = 3_000; + +/** How long the connection stays open with nothing running, before it's closed. */ +const IDLE_MS = 60_000; + +const DISMISSED_KEY = "bitween_background_dismissed"; + +export const isRunning = (status: BackgroundStatus): boolean => status === "Waiting" || status === "Running"; + +let state: BackgroundState = { requests: [] }; +const listeners = new Set<() => void>(); +/** Calls waiting for a request to finish, by its id. */ +const waiters = new Map void>>(); + +const dismissed = new Set(readDismissed()); + +function readDismissed(): string[] { + try { + return JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? "[]") as string[]; + } catch { + return []; + } +} + +function saveDismissed(): void { + try { + // Ids only last as long as the server keeps their answers, so the newest hundred is plenty. + localStorage.setItem(DISMISSED_KEY, JSON.stringify([...dismissed].slice(-100))); + } catch { + /* storage can be unavailable; a dismissed row then comes back after a reload, nothing worse */ + } +} + +function publish(next: BackgroundState): void { + state = next; + for (const listener of listeners) listener(); + for (const r of state.requests) if (!isRunning(r.status)) wake(r.id); + if (state.requests.some((r) => isRunning(r.status))) listen(); + else idle(); +} + +function wake(id: string): void { + const waiting = waiters.get(id); + if (!waiting) return; + waiters.delete(id); + for (const resolve of waiting) resolve(); +} + +export const subscribeBackground = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export const backgroundState = (): BackgroundState => state; + +function upsert(changed: Partial & { id: string }): boolean { + const known = state.requests.find((r) => r.id === changed.id); + if (!known && !changed.path) return false; + const next = known ? { ...known, ...changed } : (changed as BackgroundRequest); + if (!isRunning(next.status) && !next.finishedOn) next.finishedOn = new Date().toISOString(); + publish({ requests: [next, ...state.requests.filter((r) => r.id !== changed.id)] }); + return true; +} + +let page: string | null = null; + +/** + * Where the person is, as the router says it; the shell tells, before any page it shows asks for + * anything, so work is known by the page that asked for it. + */ +export function setCurrentPage(at: string): void { + page = at; +} + +/** The UI address of the page asking. */ +export function currentPage(): string { + return page ?? window.location.pathname + window.location.search; +} + +/** Answers "Open" asked to show, by read: the request whose kept answer the read is given. */ +const toShow = new Map(); + +/** The request whose kept answer a read should be given instead of asking again, once; see `openBackground`. */ +export function takeShown(path: string): BackgroundRequest | undefined { + const r = toShow.get(path); + toShow.delete(path); + return r; +} + +/** A call's work carried on in the background: shown in the header until it finishes, which this waits for. */ +export async function carriedOn(request: Omit): Promise { + if (!state.requests.some((r) => r.id === request.id)) + upsert({ ...request, startedOn: new Date().toISOString(), finishedOn: null }); + const known = state.requests.find((r) => r.id === request.id); + if (known && !isRunning(known.status)) return; + await new Promise((resolve) => waiters.set(request.id, [...(waiters.get(request.id) ?? []), resolve])); +} + +let refreshing: Promise | null = null; + +/** + * Brings the list up to date from the server: on loading the app, on reconnecting, and while the + * server can only be asked. + */ +export function refreshBackground(): Promise { + refreshing ??= request("/background") + .then((rows) => { + const known = new Map(state.requests.map((r) => [r.id, r])); + for (const row of rows ?? []) { + if (dismissed.has(row.id) && !isRunning(row.status)) continue; + const path = row.path.replace(/^(\w+ )?\/api(?=\/)/, "$1"); + known.set(row.id, { ...known.get(row.id), ...row, path }); + } + publish({ + requests: [...known.values()] + .filter((r) => !dismissed.has(r.id) || isRunning(r.status)) + .sort((a, b) => b.startedOn.localeCompare(a.startedOn)), + }); + }) + .catch(() => { + /* signed out, or the server is older than background requests: nothing to show */ + }) + .finally(() => { + refreshing = null; + }); + return refreshing; +} + +/** Stops them, wherever they run. Work that counts its steps ends at its next one, and says how far it got. */ +export async function cancelBackground(ids: string[]): Promise { + await Promise.all(ids.map((id) => request(`/background/${id}`, { method: "DELETE" }).catch(() => undefined))); + // The server says when they've ended; until then the list says it was asked. + void refreshBackground(); +} + +/** Takes them off the list: their answers have been seen, or aren't wanted. */ +export function dismissBackground(ids: string[]): void { + for (const id of ids) dismissed.add(id); + saveDismissed(); + publish({ requests: state.requests.filter((r) => !ids.includes(r.id) || isRunning(r.status)) }); +} + +/** + * Going back to what they were for: the page they were asked from is told to show their kept + * answers rather than ask again, and they leave the list. + */ +export function openBackground(requests: BackgroundRequest[]): void { + for (const r of requests) if (r.status === "Done" && !/^\w+ /.test(r.path)) toShow.set(r.path, r); + dismissBackground(requests.filter((r) => !isRunning(r.status)).map((r) => r.id)); +} + +/** The session ended: nothing of it may show to whoever signs in next in this tab. */ +export function resetBackground(): void { + const connection = hub; + hub = null; + void connection?.stop(); + if (polling) clearInterval(polling); + if (idling) clearTimeout(idling); + polling = null; + idling = null; + toShow.clear(); + // The calls still waiting are told they're over; their answers are refused, as the session is. + const waiting = [...waiters.keys()]; + state = { requests: [] }; + for (const id of waiting) wake(id); + for (const listener of listeners) listener(); +} + +let hub: HubConnection | null = null; +let connecting = false; +let polling: ReturnType | null = null; +let idling: ReturnType | null = null; + +interface Change { + id: string; + status: BackgroundStatus; + done?: number | null; + total?: number | null; + summary?: string | null; +} + +function onChanged(change: Change): void { + // One another tab started, or this tab before a reload: the list says what it is. + if (!upsert(change)) void refreshBackground(); +} + +/** + * Opens the connection the server tells changes over, or asks it every few seconds if it can't. The + * SignalR client is loaded only then: most visits never carry anything on, and needn't download it. + */ +function listen(): void { + if (idling) { + clearTimeout(idling); + idling = null; + } + if (hub || polling || connecting) return; + connecting = true; + import("@microsoft/signalr") + .then(({ HubConnectionBuilder, HttpTransportType, LogLevel }) => { + connecting = false; + if (hub || polling || !state.requests.some((r) => isRunning(r.status))) return; + // WebSockets only: no negotiation, so no request has to land on the same node as the last + // one, and a deployment needs no sticky sessions. Where they're blocked, the list is asked. + const connection = new HubConnectionBuilder() + .withUrl(`${API_BASE}/background/hub`, { + accessTokenFactory: () => getToken() ?? "", + skipNegotiation: true, + transport: HttpTransportType.WebSockets, + }) + .withAutomaticReconnect() + .configureLogging(LogLevel.None) + .build(); + connection.on("changed", onChanged); + // Anything that changed while it was away is told no more; the list says. + connection.onreconnected(() => void refreshBackground()); + connection.onclose(() => { + if (hub !== connection) return; + hub = null; + if (state.requests.some((r) => isRunning(r.status))) poll(); + }); + hub = connection; + return connection + .start() + .then(() => refreshBackground()) + .catch(() => { + if (hub !== connection) return; + hub = null; + poll(); + }); + }) + .catch(() => { + connecting = false; + poll(); + }); +} + +function poll(): void { + if (polling) return; + polling = setInterval(() => { + if (!state.requests.some((r) => isRunning(r.status))) { + clearInterval(polling!); + polling = null; + return; + } + void refreshBackground(); + }, POLL_MS); +} + +function idle(): void { + if (idling || (!hub && !polling)) return; + idling = setTimeout(() => { + idling = null; + if (polling) { + clearInterval(polling); + polling = null; + } + const connection = hub; + hub = null; + void connection?.stop(); + }, IDLE_MS); +} diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index e551d995..8343f127 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -14,7 +14,8 @@ import type { ScheduledRetryRow, } from "../types"; import { partnerMethods } from "./partners"; -import { download, get, post, request } from "./request"; +import { ApiRequestError } from "../types"; +import { get, post, request } from "./request"; import { searchyQueryString } from "./searchQuery"; // --- backend shapes (camelCase over the wire) --- @@ -83,6 +84,9 @@ interface RawBulkRetryPlan { maxSelection: number; overLimit: boolean; targets: string[] | null; + stoppedAfter?: number | null; + substitutedCount?: number; + skippedCount?: number; substituted: { selectedId: string; retryId: string }[] | null; skipped: { id: string; reason: string }[] | null; properties: Record> | null; @@ -272,8 +276,14 @@ async function postBulkRetry( url: string, selection: BulkRetrySelection, reset: boolean, + background: string, ): Promise { - const plan = await post(url, { ...selectionBody(selection), reason: "Bulk retry", reset }); + // In the background when it takes long, where the retry takes the whole selection at once. + const plan = await request(url, { + method: "POST", + body: { ...selectionBody(selection), reason: "Bulk retry", reset }, + background, + }); return { selected: plan.selected, willRetry: plan.willRetry, @@ -281,6 +291,10 @@ async function postBulkRetry( maxSelection: plan.maxSelection, overLimit: plan.overLimit, targets: plan.targets ?? [], + stoppedAfter: plan.stoppedAfter ?? null, + // From a server older than these totals, the lists are whole. + substitutedCount: plan.substitutedCount ?? plan.substituted?.length ?? 0, + skippedCount: plan.skippedCount ?? plan.skipped?.length ?? 0, substituted: plan.substituted ?? [], skipped: plan.skipped ?? [], properties: plan.properties ?? {}, @@ -369,18 +383,38 @@ export const exchangeMethods = { /** What a bulk retry would do, so it can be shown before anyone commits to it. */ previewBulkRetry(selection: BulkRetrySelection, { reset }: { reset: boolean }): Promise { - return postBulkRetry("/xchanges/bulkretrypreview", selection, reset); + return postBulkRetry("/xchanges/bulkretrypreview", selection, reset, "Bulk retry: working out what runs"); }, /** Runs the retry and reports what it actually did, in the same shape as the preview. */ bulkRetryExchanges(selection: BulkRetrySelection, { reset }: { reset: boolean }): Promise { - return postBulkRetry("/xchanges/bulkretry", selection, reset); + return postBulkRetry("/xchanges/bulkretry", selection, reset, "Bulk retry"); }, - /** The selected exchanges' files as one zip, built and streamed by the server. */ - async exportExchangeFiles(selection: BulkRetrySelection): Promise<{ blob: Blob; fileName: string }> { - const { blob, fileName } = await download("/xchanges/export", selectionBody(selection)); - return { blob, fileName: fileName ?? "exchanges.zip" }; + /** + * The selected exchanges' files as one zip, built by the server in the background and kept there; the + * answer is a link the browser downloads it from, straight to disk. + */ + async exportExchangeFiles(selection: BulkRetrySelection): Promise<{ url: string }> { + const { id } = await request<{ id: string }>("/xchanges/export", { + method: "POST", + body: selectionBody(selection), + background: "Exporting exchange files", + wait: 0, + leaveAnswer: true, + }); + return exchangeMethods.linkToBackgroundFile(id); + }, + + /** A link to a background request's file; when it has none, the reason it ended without one. */ + async linkToBackgroundFile(id: string): Promise<{ url: string }> { + try { + return await post<{ url: string }>(`/background/${id}/link`); + } catch (e) { + // Refused, failed or cancelled before it had anything: what it answered says why. + if (e instanceof ApiRequestError && e.code === "BACKGROUND_NO_FILE") await request(`/background/${id}/result`); + throw e; + } }, checkExchangeFilesExport(selection: BulkRetrySelection): Promise { diff --git a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts index 112b8ef4..c997497b 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts @@ -9,7 +9,14 @@ import type { MirrorSubscriptionAnswer, MirrorTest, } from "../types"; -import { get, post } from "./request"; +import { get, post, request } from "./request"; + +/** What a section's items are called, for the header's line about bringing them. */ +const SECTION: Record = { + subscriptions: "subscriptions", + apiGateways: "API gateways", + busGateways: "bus gateways", +}; export const mirrorMethods = { getMirror(): Promise { @@ -21,9 +28,14 @@ export const mirrorMethods = { return get("/mirror/prodkinds"); }, - /** Prod's items of one kind, busiest first, with whether staging has each; reads prod and staging, a few seconds. */ + /** + * Prod's items of one kind, busiest first, with whether staging has each; reads prod and staging, + * which can take a while, so it carries on in the background when it does. + */ getMirrorProd(section: MirrorBringSection, type?: string): Promise { - return get(`/mirror/prod?section=${section}${type ? `&type=${encodeURIComponent(type)}` : ""}`); + return request(`/mirror/prod?section=${section}${type ? `&type=${encodeURIComponent(type)}` : ""}`, { + background: `Bring from prod: prod's ${SECTION[section]}`, + }); }, /** One of staging's subscriptions beside prod's; reads prod and staging, a few seconds. */ @@ -70,8 +82,13 @@ export const mirrorMethods = { return post<{ closed: number }>("/mirror/filledin", { section, key }); }, - /** What bringing the picked items from prod does; with `apply`, brings them. */ + /** What bringing the picked items from prod does; with `apply`, brings them. Carried on in the background when slow. */ bringFromProd(section: MirrorBringSection, keys: string[], apply: boolean): Promise { - return post("/mirror/bring", { section, keys, apply }); + const what = `${keys.length} ${keys.length === 1 ? SECTION[section].replace(/s$/, "") : SECTION[section]}`; + return request("/mirror/bring", { + method: "POST", + body: { section, keys, apply }, + background: apply ? `Bringing ${what} from prod` : `Bring from prod: what comes with ${what}`, + }); }, } satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/request.ts b/SW.Bitween.Web/ClientApp/src/api/http/request.ts index 10bbd62e..8360335c 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/request.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/request.ts @@ -1,4 +1,5 @@ import { ApiRequestError } from "../types"; +import { carriedOn, currentPage, resetBackground, takeShown, WAIT_SECONDS, type BackgroundStatus } from "./background"; /** * All endpoints live under /api (UrlPrefix="api"). @@ -89,6 +90,7 @@ export const setToken = (jwt: string): void => { export const clearToken = (): void => { accessToken = null; + resetBackground(); safeStorage(() => localStorage.removeItem(SIGNED_IN_KEY)); channel?.postMessage({ type: "signed-out" } satisfies SessionMessage); }; @@ -198,6 +200,18 @@ export interface RequestOptions { file?: Blob; /** Lets a caller cancel the request - only `logout` needs this, and says why. */ signal?: AbortSignal; + /** + * Work that can take long - prod's list to bring from, a bulk retry, an export - carried on in the + * background if it outlasts a few seconds, and shown in the header under this name meanwhile. + */ + background?: string; + /** With `background`: how long the server waits before carrying it on, in seconds; three unless said. */ + wait?: number; + /** + * With `background`: once it has finished, answers `{ id }` rather than collecting the answer - for + * a file the browser then downloads from a link, rather than holding it in memory. + */ + leaveAnswer?: boolean; } /** @@ -218,19 +232,29 @@ export async function request(path: string, opts: RequestOptions = {}): Promi } /** - * A file the server builds from a POST, such as a zip, with the name it gave the file. Goes through - * the same sign-in handling as `request`; a refusal still comes back as the usual error. + * Sends a request and returns the successful response; see `request`. + * + * Work asked to carry on in the background (`background`, see `./background`) asks the server to + * answer within a few seconds or say it carried on, and the answer it keeps is what this then + * returns, as if the work had simply been slow. The page waiting for it never knows the difference. */ -export async function download(path: string, body: unknown): Promise<{ blob: Blob; fileName: string | null }> { - const res = await send(path, { method: "POST", body }); - const fileName = /filename="?([^";]+)"?/i.exec(res.headers.get("content-disposition") ?? "")?.[1] ?? null; - return { blob: await res.blob(), fileName }; -} - -/** Sends a request and returns the successful response; see `request`. */ async function send(path: string, opts: RequestOptions): Promise { const token = getToken(); const method = opts.method ?? "GET"; + const carryOn = Boolean(opts.background); + + // "Open" in the header asked for this read's kept answer, rather than asking all over again. + const kept = carryOn && method === "GET" ? takeShown(path) : undefined; + if (kept) { + try { + return await send(`/background/${kept.id}/result`, {}); + } catch { + /* no longer kept: asked again below */ + } + } + + const page = carryOn ? currentPage() : null; + const label = opts.background ?? null; // Backend command handlers (POST) bind a JSON body, so they always need // `Content-Type: application/json` - even a body-less command like logout. // Without it the framework rejects the call with 415 before the handler runs; @@ -243,11 +267,26 @@ async function send(path: string, opts: RequestOptions): Promise { ...(sendJson ? { "Content-Type": "application/json" } : {}), ...(opts.file ? { "Content-Type": opts.file.type || "application/zip" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(carryOn + ? { + Prefer: `respond-async, wait=${opts.wait ?? WAIT_SECONDS}`, + "X-Bitween-Page": encodeURIComponent(page!), + "X-Bitween-Label": encodeURIComponent(label!), + } + : {}), }, body: opts.file ?? (sendJson ? JSON.stringify(opts.body ?? {}) : undefined), ...(opts.signal ? { signal: opts.signal } : {}), }); + if (res.status === 202 && carryOn) { + const started = (await res.json()) as { id: string; status: BackgroundStatus }; + await carriedOn({ id: started.id, status: started.status, path: method === "GET" ? path : `${method} ${path}`, page, label }); + if (opts.leaveAnswer) + return new Response(JSON.stringify({ id: started.id }), { headers: { "Content-Type": "application/json" } }); + return await send(`/background/${started.id}/result`, {}); + } + if (res.status === 401 && !opts._retried) { const refreshed = await silentRefresh(); if (refreshed) return send(path, { ...opts, _retried: true }); diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 0a39d6ed..04528063 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1546,14 +1546,19 @@ export interface ExportFilesCheck { export interface BulkRetryPlan { selected: number; willRetry: number; - /** The most one retry request carries out; a bigger selection goes in rounds of this many. */ + /** The most a retry asked directly carries out. The UI retries in the background, where `maxSelection` applies. */ limit: number; - /** The most a preview works out. */ + /** The most a preview works out, and a retry in the background carries out at once. */ maxSelection: number; - /** True when the selection is past `maxSelection` (or, for a retry, past `limit`); nothing else is filled in. */ + /** True when the selection is past `maxSelection`; nothing else is filled in. */ overLimit: boolean; - /** The attempts that will run - what the rounds send back as ids when `selected` is past `limit`. */ + /** The attempts that will run. */ targets: string[]; + /** Set when the retry was cancelled part way: how many of `targets` it dealt with first. */ + stoppedAfter: number | null; + /** How many were substituted and skipped in all; `substituted` and `skipped` list the first ones. */ + substitutedCount: number; + skippedCount: number; substituted: RetrySubstitution[]; skipped: RetrySkip[]; /** diff --git a/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx b/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx index b8aadcb6..a4d71a57 100644 --- a/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -29,6 +29,8 @@ import { PageTitleContext, type NamedPage } from "../../lib/pageTitle"; import { EnvironmentBadge, TopBar } from "./TopBar"; import { CommandPalette } from "./CommandPalette"; import { recordVisit } from "../../lib/recentPages"; +import { refreshBackground, setCurrentPage } from "../../api/http/background"; +import { BackgroundReadyNotice, BackgroundTray } from "./BackgroundWork"; /** * Shown on every page while unsaved setting changes exist - the whole app @@ -383,7 +385,7 @@ const RAILED_KEY = "bitween-nav-railed"; export function AppShell() { const [mobileOpen, setMobileOpen] = useState(false); const [railed, setRailed] = useState(() => localStorage.getItem(RAILED_KEY) === "1"); - const { pathname } = useLocation(); + const { pathname, search } = useLocation(); // Pages' own names, once they have loaded what they show (see `usePageTitle`). Kept by the // address each was given for, so the next page never wears the last one's name, and a crumb // leading back up can still say which gateway or policy it leads to. @@ -410,6 +412,12 @@ export function AppShell() { if (current && title) recordVisit({ path: pathname, title, kind: PAGES[current].title }); }, [current, title, pathname]); + // Work carried on in the background is known by the page that asked for it. Layout, not passive: + // a page asks for things in its own effects, which run after this but before a passive one. + useLayoutEffect(() => setCurrentPage(pathname + search), [pathname, search]); + // Work still running from before a reload, or finished while away, shows in the header again. + useEffect(() => void refreshBackground(), []); + // ⌘K / Ctrl+K anywhere, as in most tools people already use; "/" too, when not typing. useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -487,6 +495,7 @@ export function AppShell() { /> + + ) : ( + <> + + + + )} + + ); + })} + + )} + + ); +} + +function LineIcon({ request: r }: { request: BackgroundRequest }) { + const className = "mt-0.5 size-4 shrink-0"; + if (r.status === "Waiting") return ; + if (r.status === "Running") return ; + if (r.status === "Done") return ; + return ; +} + +/** + * Says so when work finishes while the person is on another page, with the way back to it. Finishing + * on the page that asked needs no notice - the page simply fills in - so that work leaves the list. + */ +export function BackgroundReadyNotice() { + const { requests } = useBackground(); + const { pathname, search } = useLocation(); + const navigate = useNavigate(); + const here = pathname + search; + // Only work this tab saw running: what the list brings back already over - after a reload, or + // from another tab - was there to see in the header, and isn't news. + const seenRunning = useRef(new Set()); + const [notice, setNotice] = useState(null); + + useEffect(() => { + for (const r of requests) { + if (isRunning(r.status)) { + seenRunning.current.add(r.id); + continue; + } + if (!seenRunning.current.delete(r.id)) continue; + // An export left on its own page still saves itself, so it says nothing there either. + if (r.page === here) dismissBackground([r.id]); + else setNotice(r); + } + }, [requests, here]); + + useEffect(() => { + if (!notice) return; + const timer = setTimeout(() => setNotice(null), 15_000); + return () => clearTimeout(timer); + }, [notice]); + + if (!notice) return null; + const ready = notice.status === "Done"; + return ( +
+ {ready ? ( + + ) : ( + + )} + + {notice.label ?? "Background work"}{" "} + {ready ? "is ready" : notice.status === "Cancelled" ? "was cancelled" : "ended without an answer"} + + + +
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/components/layout/TopBar.tsx b/SW.Bitween.Web/ClientApp/src/components/layout/TopBar.tsx index ec2db6f5..d45d4388 100644 --- a/SW.Bitween.Web/ClientApp/src/components/layout/TopBar.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/layout/TopBar.tsx @@ -5,6 +5,7 @@ import { Link, useMatches } from "react-router"; import { NAV_GROUPS } from "../../nav"; import { PAGES, pageTrail, pathOf, type PageId } from "../../pages"; import type { Branding } from "../../lib/branding"; +import { BackgroundTray } from "./BackgroundWork"; /** * Where you are, and which instance you are on. @@ -32,6 +33,7 @@ export function TopBar({ return (
{page && }
+ @@ -109,7 +116,7 @@ export function Popover({ style={{ top: pos.top, left: pos.left }} className={`fixed z-50 ${width} max-h-80 overflow-y-auto rounded-xl border border-ink-100 bg-white p-2 shadow-lg`} > - {children} + {typeof children === "function" ? children(() => setOpen(false)) : children}
, document.body, )} diff --git a/SW.Bitween.Web/ClientApp/src/lib/saveFile.ts b/SW.Bitween.Web/ClientApp/src/lib/saveFile.ts new file mode 100644 index 00000000..9ef16328 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/lib/saveFile.ts @@ -0,0 +1,12 @@ +/** + * Downloads from a link, the ordinary way: the browser writes it straight to disk as it arrives, so a + * file of any size is fine. The server names the file. + */ +export function followDownload(url: string): void { + const a = document.createElement("a"); + a.href = url; + a.download = ""; + document.body.appendChild(a); + a.click(); + a.remove(); +} diff --git a/SW.Bitween.Web/ClientApp/src/lib/useBackground.ts b/SW.Bitween.Web/ClientApp/src/lib/useBackground.ts new file mode 100644 index 00000000..ae6cc985 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/lib/useBackground.ts @@ -0,0 +1,15 @@ +import { useSyncExternalStore } from "react"; +import { backgroundState, isRunning, subscribeBackground, type BackgroundState } from "../api/http/background"; + +/** The work carried on in the background, kept current. */ +export const useBackground = (): BackgroundState => useSyncExternalStore(subscribeBackground, backgroundState); + +/** + * How far the newest work of one kind under way has got - `POST /xchanges/bulkretry`, say - for a + * page that shows it beside the header; null when none is running or it hasn't said. + */ +export function useBackgroundProgress(path: string): { done: number; total: number } | null { + const { requests } = useBackground(); + const running = requests.find((r) => r.path === path && isRunning(r.status) && r.total != null); + return running ? { done: running.done ?? 0, total: running.total! } : null; +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index 01cc3cae..39ef5c14 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -4,13 +4,14 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta import { ArrowUp, Download, Pause, Play, Plus, RefreshCw, RotateCcw, TriangleAlert, X } from "lucide-react"; import { api, - type BulkRetryPlan, type BulkRetrySelection, type ExchangeStatus, type ExportFilesCheck, } from "../../api"; import { Can } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; +import { useBackgroundProgress } from "../../lib/useBackground"; +import { followDownload } from "../../lib/saveFile"; import { Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { ConfirmDialog } from "../../components/ui/overlays"; import { openDialogs } from "../../components/ui/dialogStack"; @@ -40,8 +41,11 @@ import { type ParamChanges, } from "./exchangeFilters"; -/** The most exchanges one files export takes: `XchangeExportController.Limit` on the server. */ -const EXPORT_LIMIT = 500; +/** + * The most exchanges one files export takes: `XchangeExportController.MaxInBackground` on the server, + * since the export is carried on in the background. + */ +const EXPORT_LIMIT = 50_000; const exchangesCount = (n: number) => (n === 1 ? "1 exchange" : `${n.toLocaleString()} exchanges`); @@ -312,56 +316,19 @@ export function ExchangesPage() { staleTime: 30_000, }); - /** How far a retry in rounds has got. */ - const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number } | null>(null); - /** Set when the confirm is closed mid-way: the rounds stop after the one under way. */ - const stopRounds = useRef(false); + /** How far a retry under way in the background has got, as the header has it. */ + const bulkProgress = useBackgroundProgress("POST /xchanges/bulkretry"); /** * The run the open confirm belongs to. Closing it moves this on, so a run that ends afterwards * reports what it did but can't close a confirm opened since, or clear a selection made since. */ const bulkRun = useRef(0); - /** - * A selection past what one request carries out: the preview listed every attempt that will run, - * and they go back as ids, a round at a time. No request runs long, and a round that fails leaves - * the ones before it done. The attempts, not the selection, so a round can't reach past them to an - * attempt an earlier round just made. - */ - async function retryInRounds(planned: BulkRetryPlan, reset: boolean) { - const done = { ...planned, willRetry: 0, skipped: [...planned.skipped], stoppedAt: null as number | null }; - const total = planned.targets.length; - stopRounds.current = false; - setBulkProgress({ done: 0, total }); - for (let at = 0; at < total; at += planned.limit) { - if (stopRounds.current) { - done.stoppedAt = at; - break; - } - let round: BulkRetryPlan; - try { - round = await api.bulkRetryExchanges({ ids: planned.targets.slice(at, at + planned.limit) }, { reset }); - } catch (e) { - throw new Error( - `${done.willRetry.toLocaleString()} of ${total.toLocaleString()} were retried, then a round failed` + - ` (${e instanceof Error ? e.message : String(e)}). The rest were not retried.`, - ); - } - done.willRetry += round.willRetry; - done.skipped.push(...round.skipped); - setBulkProgress({ done: Math.min(at + planned.limit, total), total }); - } - return done; - } - const bulkRetry = useMutation({ - mutationFn: (reset: boolean) => - plan && plan.selected > plan.limit - ? retryInRounds(plan, reset) - : api.bulkRetryExchanges(selection, { reset }).then((done) => ({ ...done, stoppedAt: null })), + // One request for the whole selection, carried on in the background when it takes long. + mutationFn: (reset: boolean) => api.bulkRetryExchanges(selection, { reset }), onMutate: () => ({ run: ++bulkRun.current }), onSuccess: (done, _reset, started) => { - setBulkProgress(null); if (started?.run === bulkRun.current) { setBulkConfirm(false); setBulkReset(false); @@ -369,16 +336,16 @@ export function ExchangesPage() { } setBulkResult( `${done.willRetry.toLocaleString()} retr${done.willRetry === 1 ? "y" : "ies"} started` + - (done.substituted.length > 0 ? `, ${done.substituted.length} continuing an existing chain` : "") + - (done.skipped.length > 0 ? `, ${done.skipped.length} skipped` : "") + - (done.stoppedAt != null - ? `. Stopped after ${done.stoppedAt.toLocaleString()} of ${done.targets.length.toLocaleString()}; the rest were not retried` + (done.substitutedCount > 0 ? `, ${done.substitutedCount.toLocaleString()} continuing an existing chain` : "") + + (done.skippedCount > 0 ? `, ${done.skippedCount.toLocaleString()} skipped` : "") + + (done.stoppedAfter != null + ? `. Cancelled after ${done.stoppedAfter.toLocaleString()} of ${done.targets.length.toLocaleString()}; the rest were not retried` : "") + ".", ); void queryClient.invalidateQueries({ queryKey: keys.exchanges.all }); }, - // The list changed as far as the rounds got. + // The list changed as far as the retry got. onError: () => void queryClient.invalidateQueries({ queryKey: keys.exchanges.all }), }); @@ -399,18 +366,8 @@ export function ExchangesPage() { /** The zip comes back whole and is saved from memory: the endpoint needs the sign-in header, which a plain link can't send. */ const exportFiles = useMutation({ mutationFn: () => api.exportExchangeFiles(selection), - onSuccess: ({ blob, fileName }) => { - // In the document while clicked, and revoked a while later: Firefox and Safari ignore a click on a - // detached link, and start the download after the click returns, so an address revoked at once fails it. - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 60_000); - }, + // Downloaded when it's ready, whichever page the person has gone on to meanwhile. + onSuccess: ({ url }) => followDownload(url), }); const pickStatus = (status: ExchangeStatus | null) => apply({ status }); @@ -644,7 +601,7 @@ export function ExchangesPage() { onClick={() => checkExport.mutate()} title={ selectedCount > EXPORT_LIMIT - ? `One export takes at most ${EXPORT_LIMIT} exchanges. Narrow the filter or tick fewer rows.` + ? `One export takes at most ${EXPORT_LIMIT.toLocaleString()} exchanges. Narrow the filter or tick fewer rows.` : "Download the input, mapped and handled files of the selected exchanges as one zip, a folder per exchange" } > @@ -716,21 +673,19 @@ export function ExchangesPage() { {bulkConfirm && ( bulkRetry.mutate(reset)} onResetChange={setBulkReset} onClose={() => { - stopRounds.current = true; + // Closing doesn't stop a retry under way: it carries on, and the header has Cancel. bulkRun.current++; setBulkConfirm(false); - setBulkProgress(null); - // Not while rounds are still under way: Retry would be offered again, and a second run - // beside the first would retry what the first just did. if (bulkRetry.isError) bulkRetry.reset(); // The dialog starts unticked each time it opens, so the mirrored copy has to as well. setBulkReset(false); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx index d45757f9..8ea77a79 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx @@ -1,8 +1,39 @@ -import { cleanup, screen, within } from "@testing-library/react"; +import { act, cleanup, screen, waitFor, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { afterEach, describe, expect, it, vi } from "vitest"; import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; +/** The SignalR connection background work is told about over, faked: each test says what it hears. */ +type Change = { id: string; status: string; done?: number; total?: number }; +const hub = vi.hoisted(() => ({ changed: null as null | ((change: Change) => void) })); +vi.mock("@microsoft/signalr", () => ({ + HubConnectionBuilder: class { + withUrl() { + return this; + } + withAutomaticReconnect() { + return this; + } + configureLogging() { + return this; + } + build() { + return { + on: (_: string, handler: (change: Change) => void) => { + hub.changed = handler; + }, + onreconnected() {}, + onclose() {}, + start: () => Promise.resolve(), + stop: () => Promise.resolve(), + }; + } + }, + HttpTransportType: { WebSockets: 1 }, + LogLevel: { None: 6 }, +})); +const tell = (change: Change) => act(() => hub.changed!(change)); + /** * The exchanges list: selecting across a whole filter, narrowing to the newest attempt of each * chain, and the promoted-property chips each row is named by. @@ -172,107 +203,86 @@ describe("the exchanges list", () => { }); /** - * Past what one retry carries out, the confirm doesn't send the selection: it sends the attempts - * the preview listed, a round at a time. Here the limit is 2, so 5 attempts are 3 rounds. + * A big retry goes as one request for the whole selection, asked to carry on in the background: + * the dialog shows how far it has got, as the header does, and can be closed meanwhile. */ - describe("past one retry, in rounds", () => { + describe("a big retry, in the background", () => { const targets = [11, 12, 13, 14, 15].map((n) => exchange(n).id); - const preview = http.post(apiPath("/xchanges/bulkretrypreview"), () => - HttpResponse.json({ - selected: 59, - willRetry: 5, - limit: 2, - maxSelection: 5000, - overLimit: false, - targets, - substituted: [], - skipped: [{ id: exchange(16).id, reason: "It succeeded, so there is nothing to retry." }], - properties: {}, - }), - ); - /** - * The retry endpoint, keeping every round it was sent; `fails` answers that round with a 500, - * and `holds` keeps that round waiting until `release` is called. - */ - function rounds({ fails, holds }: { fails?: number; holds?: number } = {}) { - const sent: { ids: string[] }[] = []; - let release = () => {}; - const held = new Promise((resolve) => (release = resolve)); - const handler = http.post(apiPath("/xchanges/bulkretry"), async ({ request }) => { - const body = (await request.json()) as { ids: string[] }; - sent.push(body); - if (sent.length === holds) await held; - if (sent.length === fails) return HttpResponse.json({ message: "Broker unreachable" }, { status: 500 }); - const n = body.ids.length; - return HttpResponse.json({ selected: n, willRetry: n, limit: 2, maxSelection: 5000, overLimit: false, targets: body.ids }); - }); - return { handler, sent, release: () => release() }; + const plan = (extra: Record = {}) => ({ + selected: 59, + willRetry: 5, + limit: 2, + maxSelection: 50000, + overLimit: false, + targets, + substituted: [], + skipped: [{ id: exchange(16).id, reason: "It succeeded, so there is nothing to retry." }], + properties: {}, + ...extra, + }); + const preview = http.post(apiPath("/xchanges/bulkretrypreview"), () => HttpResponse.json(plan())); + + /** The retry, carried on in the background as `bulk-1`, answering `answer` once it's collected. */ + function background(answer: Record) { + const sent: { prefer: string | null; body: Record }[] = []; + return { + sent, + handlers: [ + http.post(apiPath("/xchanges/bulkretry"), async ({ request }) => { + sent.push({ prefer: request.headers.get("Prefer"), body: (await request.json()) as Record }); + return HttpResponse.json({ id: "bulk-1", status: "Running" }, { status: 202 }); + }), + http.get(apiPath("/background/bulk-1/result"), () => HttpResponse.json(answer)), + ], + }; } - it("sends the planned attempts a round at a time, then says what ran", async () => { - const retry = rounds({ holds: 2 }); + it("sends the whole selection once, shows how far it has got, then says what ran", async () => { + const retry = background(plan()); const { user } = renderApp("/exchanges?status=failed", { - handlers: [exchanges({ total: 60 }).handler, ...filterOptions, preview, retry.handler], + handlers: [exchanges({ total: 60 }).handler, ...filterOptions, preview, ...retry.handlers], }); await selectAllMatching(user); await user.click(screen.getByRole("button", { name: "Retry selected…" })); const dialog = await screen.findByRole("dialog"); - expect(await within(dialog).findByText(/they go in 3 rounds of up to 2/)).toBeVisible(); + expect(await within(dialog).findByText(/A retry this size runs in the background/)).toBeVisible(); await user.click(within(dialog).getByRole("button", { name: "Retry" })); - // While the second round is out, the first one's attempts are counted. - expect(await within(dialog).findByText("Retried 2 of 5…")).toBeVisible(); + await waitFor(() => expect(retry.sent).toHaveLength(1)); + expect(retry.sent[0].prefer).toBe("respond-async, wait=3"); + expect(retry.sent[0].body).toMatchObject({ filter: expect.any(String), reset: false }); + + tell({ id: "bulk-1", status: "Running", done: 2, total: 5 }); + expect(await within(dialog).findByText(/Retried 2 of 5…/)).toBeVisible(); expect(within(dialog).getByRole("progressbar", { name: "Retried so far" })).toHaveAttribute("aria-valuenow", "2"); - retry.release(); + tell({ id: "bulk-1", status: "Done", done: 5, total: 5 }); expect(await screen.findByText("5 retries started, 1 skipped.")).toBeVisible(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(retry.sent.map((r) => r.ids)).toEqual([targets.slice(0, 2), targets.slice(2, 4), targets.slice(4)]); }); - it("closed mid-way, stops after the round under way and leaves a confirm opened since alone", async () => { - const retry = rounds({ holds: 1 }); + it("closed, carries on; cancelled from the header, says how far it got", async () => { + const retry = background(plan({ willRetry: 2, stoppedAfter: 2 })); const { user } = renderApp("/exchanges?status=failed", { - handlers: [exchanges({ total: 60 }).handler, ...filterOptions, preview, retry.handler], + handlers: [exchanges({ total: 60 }).handler, ...filterOptions, preview, ...retry.handlers], }); await selectAllMatching(user); await user.click(screen.getByRole("button", { name: "Retry selected…" })); const dialog = await screen.findByRole("dialog"); - await within(dialog).findByText(/they go in 3 rounds/); + await within(dialog).findByText(/A retry this size runs in the background/); await user.click(within(dialog).getByRole("button", { name: "Retry" })); - await user.click(within(dialog).getByRole("button", { name: "Cancel" })); - - await user.click(screen.getByRole("button", { name: "Retry selected…" })); - await screen.findByRole("dialog"); - retry.release(); + tell({ id: "bulk-1", status: "Running", done: 2, total: 5 }); + // The dialog's own Close, which stops being "Cancel" once it's under way - not the corner's. + await user.click((await within(dialog).findAllByRole("button", { name: "Close" })).find((b) => b.textContent === "Close")!); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + tell({ id: "bulk-1", status: "Cancelled", done: 2, total: 5 }); expect( - await screen.findByText("2 retries started, 1 skipped. Stopped after 2 of 5; the rest were not retried."), + await screen.findByText("2 retries started, 1 skipped. Cancelled after 2 of 5; the rest were not retried."), ).toBeVisible(); expect(retry.sent).toHaveLength(1); - expect(screen.getByRole("dialog")).toBeVisible(); - expect(screen.getByRole("button", { name: "Retry selected…" })).toBeVisible(); - }); - - it("stops at a round that fails, says how far it got, and doesn't offer to go again", async () => { - const retry = rounds({ fails: 2 }); - const { user } = renderApp("/exchanges?status=failed", { - handlers: [exchanges({ total: 60 }).handler, ...filterOptions, preview, retry.handler], - }); - - await selectAllMatching(user); - await user.click(screen.getByRole("button", { name: "Retry selected…" })); - const dialog = await screen.findByRole("dialog"); - await within(dialog).findByText(/they go in 3 rounds/); - await user.click(within(dialog).getByRole("button", { name: "Retry" })); - - expect(await within(dialog).findByRole("alert")).toHaveTextContent( - /^2 of 5 were retried, then a round failed .*The rest were not retried\.$/, - ); - expect(retry.sent).toHaveLength(2); - expect(within(dialog).queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); }); }); }); @@ -370,38 +380,34 @@ describe("the exchanges list", () => { const exportCheck = (withoutFiles = 0, count = 2, keptDays: number | null = 30) => http.post(apiPath("/xchanges/export/check"), () => HttpResponse.json({ count, withoutFiles, keptDays })); - afterEach(() => { - vi.restoreAllMocks(); - Reflect.deleteProperty(URL, "createObjectURL"); - Reflect.deleteProperty(URL, "revokeObjectURL"); - }); + afterEach(() => vi.restoreAllMocks()); + + /** + * The export, which the server carries on in the background as `exp-1` - the UI asks it to at once - + * keeping every request it was sent; done, it's downloaded from the link the server makes for it. + */ + function exportInBackground(exported: unknown[]) { + return [ + http.post(apiPath("/xchanges/export"), async ({ request }) => { + exported.push(await request.json()); + return HttpResponse.json({ id: "exp-1", status: "Running" }, { status: 202 }); + }), + http.post(apiPath("/background/exp-1/link"), () => + HttpResponse.json({ url: "/api/background/exp-1/file?expires=1&seal=s" }), + ), + ]; + } it("lets a viewer download the ticked rows' files, without offering a retry", async () => { const exported: unknown[] = []; - URL.createObjectURL = vi.fn(() => "blob:export"); - URL.revokeObjectURL = vi.fn(); - const saved: string[] = []; + const followed: string[] = []; vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { - saved.push(this.download); + followed.push(new URL(this.href).pathname); }); const { user } = renderApp("/exchanges", { as: { permissions: ["exchanges.view"] }, - handlers: [ - exchanges({ total: 3 }).handler, - ...filterOptions, - exportCheck(), - http.post(apiPath("/xchanges/export"), async ({ request }) => { - exported.push(await request.json()); - // Bytes, not a Blob: jsdom's Blob can't be streamed into a response. - return new HttpResponse(new Uint8Array([0x50, 0x4b]), { - headers: { - "Content-Type": "application/zip", - "Content-Disposition": "attachment; filename=exchanges-2026-10-01-101500.zip", - }, - }); - }), - ], + handlers: [exchanges({ total: 3 }).handler, ...filterOptions, exportCheck(), ...exportInBackground(exported)], }); await screen.findAllByRole("checkbox", { name: /^Select (?!all\b)/ }); @@ -410,27 +416,19 @@ describe("the exchanges list", () => { expect(screen.queryByRole("button", { name: "Retry selected…" })).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Export files" })); + await vi.waitFor(() => expect(exported).toEqual([{ ids: [exchange(0).id, exchange(2).id] }])); - await vi.waitFor(() => expect(saved).toEqual(["exchanges-2026-10-01-101500.zip"])); - expect(exported).toEqual([{ ids: [exchange(0).id, exchange(2).id] }]); + // Downloaded from the link once it's done, not held in the page's memory first. + tell({ id: "exp-1", status: "Done" }); + await vi.waitFor(() => expect(followed).toEqual(["/api/background/exp-1/file"])); }); it("over an unfiltered list, select all matching still sends a filter that matches everything", async () => { const exported: unknown[] = []; - URL.createObjectURL = vi.fn(() => "blob:export"); - URL.revokeObjectURL = vi.fn(); vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); const { user } = renderApp("/exchanges", { - handlers: [ - exchanges({ total: 30 }).handler, - ...filterOptions, - exportCheck(0, 30), - http.post(apiPath("/xchanges/export"), async ({ request }) => { - exported.push(await request.json()); - return new HttpResponse(new Uint8Array([0x50, 0x4b]), { headers: { "Content-Type": "application/zip" } }); - }), - ], + handlers: [exchanges({ total: 30 }).handler, ...filterOptions, exportCheck(0, 30), ...exportInBackground(exported)], }); await user.click(await screen.findByRole("checkbox", { name: "Select all on this page" })); @@ -439,24 +437,15 @@ describe("the exchanges list", () => { // An empty filter would read as no filter, and select nothing. await vi.waitFor(() => expect(exported).toEqual([{ filter: "filter=LatestOnly%3A1%3Afalse", excludeIds: [] }])); + tell({ id: "exp-1", status: "Done" }); }); it("says first when some exchanges are past the files storage keeps, and exports the rest on confirm", async () => { const exported: unknown[] = []; - URL.createObjectURL = vi.fn(() => "blob:export"); - URL.revokeObjectURL = vi.fn(); vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); const { user } = renderApp("/exchanges", { - handlers: [ - exchanges({ total: 3 }).handler, - ...filterOptions, - exportCheck(1, 3), - http.post(apiPath("/xchanges/export"), async ({ request }) => { - exported.push(await request.json()); - return new HttpResponse(new Uint8Array([0x50, 0x4b]), { headers: { "Content-Type": "application/zip" } }); - }), - ], + handlers: [exchanges({ total: 3 }).handler, ...filterOptions, exportCheck(1, 3), ...exportInBackground(exported)], }); await screen.findAllByRole("checkbox", { name: /^Select (?!all\b)/ }); @@ -471,6 +460,7 @@ describe("the exchanges list", () => { await user.click(within(dialog).getByRole("button", { name: "Export the other 2 exchanges" })); await vi.waitFor(() => expect(exported).toHaveLength(1)); + tell({ id: "exp-1", status: "Done" }); }); it("downloads nothing when none of the selection has files left, and says why", async () => { @@ -500,15 +490,15 @@ describe("the exchanges list", () => { it("past the limit, says why it can't rather than failing on the server", async () => { const { user } = renderApp("/exchanges?status=failed", { - handlers: [exchanges({ total: 600 }).handler, ...filterOptions], + handlers: [exchanges({ total: 60000 }).handler, ...filterOptions], }); await user.click(await screen.findByRole("checkbox", { name: "Select all on this page" })); - await user.click(screen.getByRole("button", { name: "Select all 600 matching this filter" })); + await user.click(screen.getByRole("button", { name: /^Select all .* matching this filter$/ })); const button = screen.getByRole("button", { name: "Export files" }); expect(button).toBeDisabled(); - expect(button).toHaveAttribute("title", expect.stringContaining("at most 500 exchanges")); + expect(button).toHaveAttribute("title", expect.stringContaining("at most 50,000 exchanges")); }); }); }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx index 39982b85..976f34a0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx @@ -129,6 +129,16 @@ function ExchangeIdentity({ ); } +/** The rest of a list the plan names only the first of: a selection of hundreds of thousands. */ +function MoreNotListed({ listed, count }: { listed: number; count: number }) { + if (count <= listed) return null; + return ( +
  • + …and {(count - listed).toLocaleString()} more +
  • + ); +} + /** * Shared confirm for single and bulk retries - carries the "reset adapter properties" choice * that decides whether the retry re-resolves config. @@ -138,8 +148,8 @@ function ExchangeIdentity({ * ones that have since succeeded drop out, and two selections in one chain come to the same * attempt. All of that is shown before anyone commits, since a retry cannot be taken back. * - * A selection past what one retry request carries out goes in rounds; `progress` says how far - * they have got. + * A bulk retry runs in the background once it takes more than a few seconds, so the dialog can be + * closed while it runs; `progress` says how far it has got meanwhile, as the header does. */ export function RetryDialog({ count, @@ -157,7 +167,7 @@ export function RetryDialog({ plan?: BulkRetryPlan | null; planLoading?: boolean; busy: boolean; - /** Bulk retries in rounds: the attempts retried so far, of all that will run. */ + /** A bulk retry under way in the background: the attempts dealt with so far, of all that will run. */ progress?: { done: number; total: number } | null; /** Why the retry stopped, when it did. */ error?: string | null; @@ -172,7 +182,6 @@ export function RetryDialog({ const [reset, setReset] = useState(false); const bulk = count !== 1; const nothingToDo = plan != null && !plan.overLimit && plan.willRetry === 0; - const rounds = plan != null && plan.selected > plan.limit ? Math.ceil(plan.willRetry / plan.limit) : 0; return ( - {rounds > 1 && !progress && ( + {plan != null && plan.willRetry > plan.limit && !progress && (

    - That's more than one retry carries out, so they go in {rounds} rounds of up to{" "} - {plan!.limit.toLocaleString()}, one after another. Keep this page open until they finish. + A retry this size runs in the background. You can close this and keep working: the top bar + shows how far it has got, with Cancel.

    )} {progress && (

    - Retried {progress.done.toLocaleString()} of {progress.total.toLocaleString()}… + Retried {progress.done.toLocaleString()} of {progress.total.toLocaleString()}… You can close this: + it carries on, and the top bar has Cancel.

    )} - {plan != null && plan.substituted.length > 0 && ( + {plan != null && plan.substitutedCount > 0 && (

    - {plan.substituted.length.toLocaleString()} of - these {plan.substituted.length === 1 ? "has" : "have"} already been retried. An + {plan.substitutedCount.toLocaleString()} of + these {plan.substitutedCount === 1 ? "has" : "have"} already been retried. An exchange is only retried once, so{" "} - {plan.substituted.length === 1 ? "its newest attempt runs" : "their newest attempts run"}{" "} + {plan.substitutedCount === 1 ? "its newest attempt runs" : "their newest attempts run"}{" "} instead.

    @@ -251,15 +261,16 @@ export function RetryDialog({ ))} +
    )} - {plan != null && plan.skipped.length > 0 && ( + {plan != null && plan.skippedCount > 0 && (

    - {plan.skipped.length.toLocaleString()}{" "} + {plan.skippedCount.toLocaleString()}{" "} will be skipped.

    @@ -273,12 +284,13 @@ export function RetryDialog({ - {s.reason} ))} +
    )} - {/* Not once rounds are under way: they keep the choice they started with. */} + {/* Not once it's under way: it keeps the choice it started with. */} {!progress && ( - {/* Not again after a failure: the plan it would repeat is out of date by the rounds already done. */} - {!plan?.overLimit && !nothingToDo && !error && ( + {/* Not again after a failure: the plan it would repeat is out of date by what was already done. */} + {!plan?.overLimit && !nothingToDo && !error && !progress && ( diff --git a/SW.Bitween.Web/ClientApp/yarn.lock b/SW.Bitween.Web/ClientApp/yarn.lock index 77109452..869e3371 100644 --- a/SW.Bitween.Web/ClientApp/yarn.lock +++ b/SW.Bitween.Web/ClientApp/yarn.lock @@ -525,6 +525,17 @@ resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz#42c2aea61cda307cdb1347444792452d7b5dbfb4" integrity sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ== +"@microsoft/signalr@^10.0.11": + version "10.0.11" + resolved "https://registry.yarnpkg.com/@microsoft/signalr/-/signalr-10.0.11.tgz#2b96b016ed38f9754178d389ca1d7fd89666f471" + integrity sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw== + dependencies: + abort-controller "^3.0.0" + eventsource "^2.0.2" + fetch-cookie "^2.0.3" + node-fetch "^2.6.7" + ws "^7.5.10" + "@mswjs/interceptors@^0.41.3": version "0.41.9" resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.41.9.tgz#9d90bbd60d1ddc30dbcbb827a9bb2e470493530d" @@ -1136,6 +1147,13 @@ convert-source-map "^2.0.0" tinyrainbow "^3.1.0" +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -1360,6 +1378,16 @@ estree-walker@^3.0.3: dependencies: "@types/estree" "^1.0.0" +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +eventsource@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" + integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== + expect-type@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" @@ -1389,6 +1417,14 @@ fdir@^6.5.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +fetch-cookie@^2.0.3: + version "2.2.0" + resolved "https://registry.yarnpkg.com/fetch-cookie/-/fetch-cookie-2.2.0.tgz#01086b6b5b1c3e08f15ffd8647b02ca100377365" + integrity sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ== + dependencies: + set-cookie-parser "^2.4.8" + tough-cookie "^4.0.0" + fsevents@2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" @@ -1656,6 +1692,13 @@ nanoid@^3.3.19: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.20.tgz#dfd4444d85aa10111ef66ba85ed72e08ba0855d8" integrity sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ== +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + obug@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.3.tgz#c02c60f95abd603409330e767db7f2823193331e" @@ -1750,11 +1793,23 @@ pretty-format@^27.0.2: ansi-styles "^5.0.0" react-is "^17.0.1" -punycode@^2.3.1: +psl@^1.1.33: + version "1.15.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.15.0.tgz#bdace31896f1d97cec6a79e8224898ce93d974c6" + integrity sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w== + dependencies: + punycode "^2.3.1" + +punycode@^2.1.1, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + react-aria@^3.48.0: version "3.50.0" resolved "https://registry.yarnpkg.com/react-aria/-/react-aria-3.50.0.tgz#75cb002c8ff94be3bc1afb6f717b37c6d0548119" @@ -1825,6 +1880,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + rettime@^0.11.11: version "0.11.11" resolved "https://registry.yarnpkg.com/rettime/-/rettime-0.11.11.tgz#fe8fb192e1877bb0080fc1a640cb08eededd7d12" @@ -1876,6 +1936,11 @@ scheduler@^0.27.0: resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== +set-cookie-parser@^2.4.8: + version "2.7.3" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz#ded8e573414ceb6f5752f610dc9751f4eda0295d" + integrity sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg== + set-cookie-parser@^3.0.1: version "3.1.2" resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz#f4e490298759d756a68eabcbcd0fc9261ad0fee0" @@ -2009,6 +2074,16 @@ tldts@^7.0.5: dependencies: tldts-core "^7.4.12" +tough-cookie@^4.0.0: + version "4.1.4" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" + integrity sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + tough-cookie@^6.0.1, tough-cookie@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.2.tgz#7b1f22fcf2daf06c4ff9d53ec1845f44c6627062" @@ -2023,6 +2098,11 @@ tr46@^6.0.0: dependencies: punycode "^2.3.1" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + tslib@^2.0.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" @@ -2055,11 +2135,24 @@ undici@^8.9.0: resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.2.tgz#960bcb0b43c267f86910ea7ca408ada843a44bc7" integrity sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + until-async@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/until-async/-/until-async-3.0.2.tgz#447f1531fdd7bb2b4c7a98869bdb1a4c2a23865f" integrity sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw== +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + use-sync-external-store@^1.5.0, use-sync-external-store@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" @@ -2116,6 +2209,11 @@ w3c-xmlserializer@^5.0.0: dependencies: xml-name-validator "^5.0.0" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + webidl-conversions@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz#0657e571fe6f06fcb15ca50ed1fdbcb495cd1686" @@ -2144,6 +2242,14 @@ whatwg-url@^17.1.0: tr46 "^6.0.0" webidl-conversions "^8.0.1" +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + why-is-node-running@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" @@ -2161,6 +2267,11 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +ws@^7.5.10: + version "7.5.13" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.13.tgz#12aa507eaca76c295c278b1aebf4698ab2c1845f" + integrity sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA== + xml-name-validator@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" diff --git a/docs/admin-ui.md b/docs/admin-ui.md index 338ba83d..33ba6a4e 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -8,6 +8,10 @@ Every page and action is gated by permissions. Anything a member cannot use is h The sign-in page offers email and password, Microsoft, an [OpenID Connect provider](security.md#openid-connect), or any of them together, depending on settings. With a mail server it also offers **Forgot password?**, and invite and reset emails lead to a page where the member chooses their password. After sign-in, members land on the first page their permissions allow. Sessions end after 30 minutes with no activity. +## Work that takes long + +Four things can take long, and carry on in the background once they take more than three seconds: Bring from prod's list and bringing what was picked, a bulk retry and working out what it will run, and exporting exchange files. You can go on to other pages meanwhile. The top bar shows what is running, for how long and, for a bulk retry or an export, how far it has got, with **Cancel**. A bulk retry takes up to 500,000 exchanges and goes at the pipeline's pace; an export takes up to 50,000, and downloads straight to disk when it's ready. When one finishes while you're elsewhere, a notice says so with **Open**, which goes back to where it was asked from - Bring from prod's window reopens - or, for an export, **Download**. Every other page loads as it always has. See [Work that takes long](api-reference.md#work-that-takes-long). + ## Operate | Page | What it does | From 90e89cc94f01530204dc38429c40ce86bf565785 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 1 Nov 2026 15:14:45 +0300 Subject: [PATCH 03/24] feat: open the bring dialog from a link --- .../src/pages/mirror/BringFromProd.tsx | 15 ++++++++++++-- .../mirror/__tests__/BringFromProd.test.tsx | 20 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx index 49330bdb..dbd8d8bb 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CircleAlert, CircleCheck, Download } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Link } from "react-router"; +import { Link, useSearchParams } from "react-router"; import { api, type MirrorBring, @@ -42,12 +42,23 @@ const PIECES: { state: MirrorPieceState; title: string; description: string }[] * "Bring from prod" on a list page: on a staging that mirrors prod, for whoever may operate the * mirror, and only while prod has something this page lists - or prod can't be read, which the * picker then says. Opens the picker. + * + * Open while the address says `?bring`, so the way back to it is a link: prod's list can take long + * enough to run in the background, and "Open" in the header goes back to the picker it was for. */ export function BringFromProd({ section, type, label }: { section: MirrorBringSection; type?: string; label: string }) { const mirror = useMirror(); const ready = mirror.ready && mirror.canOperate; const kinds = useQuery({ queryKey: keys.mirror.prodKinds, queryFn: () => api.getMirrorProdKinds(), enabled: ready, staleTime: 60_000 }); - const [open, setOpen] = useState(false); + const [params, setParams] = useSearchParams(); + const open = params.has("bring"); + const setOpen = (on: boolean) => + setParams((was) => { + const next = new URLSearchParams(was); + if (on) next.set("bring", "1"); + else next.delete("bring"); + return next; + }); if (!ready || !kinds.data) return null; const count = section === "subscriptions" ? (kinds.data.subscriptions[type ?? "all"] ?? 0) : kinds.data[section]; diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/BringFromProd.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/BringFromProd.test.tsx index 47fa82aa..0dcf78ab 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/BringFromProd.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/__tests__/BringFromProd.test.tsx @@ -1,4 +1,4 @@ -import { screen, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; import type { MirrorBring, MirrorProdKinds, MirrorProdList } from "../../../api"; @@ -123,6 +123,24 @@ describe("bringing prod's items from a list page", () => { expect(asked[1]).toEqual({ section: "subscriptions", keys: ["Orders rollup"], apply: true }); }); + it("opens from its address, so a link - the header's Open - can go back to it", async () => { + const { user, router } = renderApp("/aggregations?bring=1", { + handlers: [ + mirror, + kinds({ all: 5, Aggregation: 2 }), + http.get(apiPath("/mirror/prod"), () => HttpResponse.json(prodList)), + ...aggregationsPage, + ], + }); + + const dialog = await screen.findByRole("dialog", { name: "Bring aggregations from prod" }); + expect(await within(dialog).findByText("1,204")).toBeVisible(); + + await user.keyboard("{Escape}"); + await waitFor(() => expect(router.state.location.search).toBe("")); + expect(screen.queryByRole("dialog", { name: "Bring aggregations from prod" })).not.toBeInTheDocument(); + }); + it("isn't offered where prod has none of what the page lists", async () => { let read = false; renderApp("/aggregations", { From adc1e53c2821c125d247593f55fb7ab06a7e1aac Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 1 Nov 2026 15:59:01 +0300 Subject: [PATCH 04/24] fix: code review findings on background requests --- .../BackgroundRequestsController.cs | 3 +++ .../Controllers/XchangeExportController.cs | 4 +++- .../Resources/Xchanges/BulkRetry.cs | 20 +++++++++++----- .../Services/Background/AskedRequest.cs | 10 ++++++-- .../Services/Background/BackgroundRequests.cs | 24 +++++++++++++++---- .../Tests/BulkRetryInBackgroundTests.cs | 19 +++++++++------ .../ClientApp/src/api/http/background.ts | 22 ++++++++++++++--- .../layout/__tests__/BackgroundWork.test.tsx | 3 +++ .../src/pages/exchanges/ExchangesPage.tsx | 2 +- .../__tests__/ExchangesList.test.tsx | 8 +++---- .../ClientApp/src/pages/exchanges/shared.tsx | 4 ++-- 11 files changed, 88 insertions(+), 31 deletions(-) diff --git a/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs b/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs index 3e1c8b8a..24ae6f55 100644 --- a/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs +++ b/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs @@ -32,6 +32,7 @@ public async Task List() { var owner = Owner; if (owner == null) return Unauthorized(); + await requests.ForgetExpiredAsync(db); var unfinished = await db.Set() .Where(r => r.Owner == owner && @@ -74,6 +75,7 @@ public async Task Result(string id) { var owner = Owner; if (owner == null) return Unauthorized(); + await requests.ForgetExpiredAsync(db); var row = await db.Set().FirstOrDefaultAsync(r => r.Id == id && r.Owner == owner); if (row == null) @@ -121,6 +123,7 @@ public async Task Link(string id) { var owner = Owner; if (owner == null) return Unauthorized(); + await requests.ForgetExpiredAsync(db); var row = await db.Set().AsNoTracking().FirstOrDefaultAsync(r => r.Id == id && r.Owner == owner); if (row == null) diff --git a/SW.Bitween.Api/Controllers/XchangeExportController.cs b/SW.Bitween.Api/Controllers/XchangeExportController.cs index f7b34d2d..866efb3e 100644 --- a/SW.Bitween.Api/Controllers/XchangeExportController.cs +++ b/SW.Bitween.Api/Controllers/XchangeExportController.cs @@ -69,7 +69,7 @@ public class XchangeExportController(BitweenDbContext dbContext, RequestContext /// /// What an export of would hold, asked before it's downloaded: how many /// exchanges it takes, and how many are older than the bucket's rule keeps their files. Refuses what - /// the export itself would refuse. + /// the export in the background would refuse, past . /// [HttpPost("check")] public async Task Check([FromBody] XchangeFilesExport request) @@ -169,6 +169,8 @@ await Add(zip, xchange, XchangeFileType.Response, $"{folder}/handled", result.NotStoredReason(XchangeFileType.Response)); } + background?.Progress(exported, xchanges.Count, $"{exported:n0} of {xchanges.Count:n0} exchanges"); + if (missing.Count > 0) { await using var list = await zip.CreateEntry(MissingList).OpenAsync(aborted); diff --git a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs index dd01bd8d..3cfbc6ef 100644 --- a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs +++ b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs @@ -76,17 +76,24 @@ public async Task Handle(XchangeBulkRetry request) background?.Progress(0, total, Said(0, plan)); foreach (var id in prepared.Targets) { - // Between exchanges only, so the one under way is either done or not started. + // Between exchanges only, so the one under way is either done or not started; and room + // for a whole save at its start, so what waits never passes Waiting. if (background?.CancelRequested.IsCancellationRequested == true || - background != null && !await RoomFor(unfinished, background, handled, total, retried, plan)) + background != null && handled % SaveEvery == 0 && + !await RoomFor(unfinished, background, handled, total, retried, plan)) { plan.StoppedAfter = handled; break; } - if (await RetryOne(id, request, plan)) retried++; + // Only those it retried: a skipped one's retry, if it has one, is someone else's. + if (await RetryOne(id, request, plan)) + { + retried++; + batch.Add(id); + } + handled++; - batch.Add(id); if (handled % SaveEvery != 0) continue; await _dbContext.SaveChangesAsync(); @@ -112,7 +119,8 @@ private static string Said(int retried, XchangeBulkRetryPlan plan) => $"{retried:n0} retried, {plan.SkippedCount:n0} skipped"; /// - /// Waits while of this run's retries are still to be processed. Each one + /// Waits until a save's worth more of this run's retries can join those still to be processed + /// without passing . Each one /// the pipeline finishes is progress, which keeps the retry's limit from running out; a pipeline /// that finishes none for the whole limit - stopped, or a paused subscription holding them - ends /// it there. False when its owner cancels it meanwhile. @@ -120,7 +128,7 @@ private static string Said(int retried, XchangeBulkRetryPlan plan) => private async Task RoomFor(List unfinished, BackgroundRequestScope background, int handled, int total, int retried, XchangeBulkRetryPlan plan) { - while (unfinished.Count >= Waiting) + while (unfinished.Count > 0 && unfinished.Count + SaveEvery > Waiting) { var finished = (await _dbContext.Set().AsNoTracking() .Where(r => unfinished.Contains(r.Id)).Select(r => r.Id).ToListAsync()).ToHashSet(); diff --git a/SW.Bitween.Api/Services/Background/AskedRequest.cs b/SW.Bitween.Api/Services/Background/AskedRequest.cs index d6184e67..b23e92b9 100644 --- a/SW.Bitween.Api/Services/Background/AskedRequest.cs +++ b/SW.Bitween.Api/Services/Background/AskedRequest.cs @@ -48,13 +48,19 @@ public static async Task OfAsync(HttpContext context) { var request = context.Request; byte[] sent = []; - if (request.ContentLength is > 0 || request.Headers.TransferEncoding.Count > 0) + // Any body but one known to be empty: HTTP/2 can send one with neither a length nor chunks. + if (request.ContentLength is not 0) { if (request.ContentLength is > MaxBody) return null; // Rewound after, so a body too big to carry can still be read by the request as usual. request.EnableBuffering(); + // No more than one past the most it carries, whatever the body says of itself. using var copy = new MemoryStream(); - await request.Body.CopyToAsync(copy, context.RequestAborted); + var chunk = new byte[81920]; + int got; + while (copy.Length <= MaxBody && + (got = await request.Body.ReadAsync(chunk, context.RequestAborted)) > 0) + copy.Write(chunk, 0, got); request.Body.Position = 0; if (copy.Length > MaxBody) return null; sent = copy.ToArray(); diff --git a/SW.Bitween.Api/Services/Background/BackgroundRequests.cs b/SW.Bitween.Api/Services/Background/BackgroundRequests.cs index d8ac3afe..cfc78195 100644 --- a/SW.Bitween.Api/Services/Background/BackgroundRequests.cs +++ b/SW.Bitween.Api/Services/Background/BackgroundRequests.cs @@ -134,8 +134,17 @@ public async Task CancelAsync(BitweenDbContext db, string owner, string id if (row == null) return false; if (row.Finished) return true; - if (!CancelHere(id)) await BroadcastAsync(new BackgroundRequestCancel { Id = id }); - if (row.Total != null) return true; + // Here, the run itself says whether it counts: its first count may not be saved yet. + if (background.TryGetValue(id, out var work)) + { + work.Cancel(); + if (work.Counts) return true; + } + else + { + await BroadcastAsync(new BackgroundRequestCancel { Id = id }); + if (row.Total != null) return true; + } row.End(BackgroundRequestStatus.Cancelled); await db.SaveChangesAsync(); @@ -173,9 +182,10 @@ public static async Task EndStoppedAsync(BitweenDbContext db, IReadOnlyCollectio /// /// Deletes what is no longer kept - finished longer ago than , or started more - /// than a day ago - with the answers it kept in storage. + /// than a day ago - with the answers it kept in storage. On every read of them too, so nothing is + /// served past that when no new request comes along to clear it. /// - async Task ForgetExpiredAsync(BitweenDbContext db) + internal async Task ForgetExpiredAsync(BitweenDbContext db) { var now = DateTime.UtcNow; var expired = db.Set() @@ -240,7 +250,9 @@ async Task RunAsync(Work work) catch (Exception ex) { logger.LogError(ex, "A background request for {Path} failed.", work.Asked.Described); - if (!work.AnsweredHere.TrySetException(ex) && work.Kept.Task.IsCompletedSuccessfully && work.Kept.Task.Result) + // Its caller still waits, and is told; or it moved, and its record says it failed. + if (work.TryAnswerHere()) work.AnsweredHere.TrySetException(ex); + else if (work.IsMoved && await work.Kept.Task) { try { @@ -572,6 +584,8 @@ internal sealed class Work(AskedRequest asked, RequestDelegate next, string owne /// Claims it for the background; false once its caller has been answered. public bool TryMove() => Interlocked.CompareExchange(ref state, Moved, Waited) == Waited; + public bool IsMoved => Volatile.Read(ref state) == Moved; + /// /// Stops it: where it is, unless it counts its steps - then at the next one, unless /// . diff --git a/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs b/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs index 8f4f3575..23f63f9c 100644 --- a/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs @@ -102,18 +102,23 @@ public async Task It_goes_at_the_pipelines_pace_holding_back_while_too_many_wait .Handle(new XchangeBulkRetry { Ids = ids }); }); - // Two saves of a hundred make 200 waiting, past the 150 allowed: it holds there. + // A second save of a hundred would make 200 waiting, past the 150 allowed: it holds at 100. await using var check = fixture.CreateScope(); var checkDb = check.ServiceProvider.GetRequiredService(); - for (var i = 0; i < 100 && await Retries(checkDb, ids) < 200; i++) await Task.Delay(50); + for (var i = 0; i < 100 && await Retries(checkDb, ids) < 100; i++) await Task.Delay(50); await Task.Delay(300); - Assert.Equal(200, await Retries(checkDb, ids)); + Assert.Equal(100, await Retries(checkDb, ids)); Assert.False(retrying.IsCompleted); - // The pipeline finishes them, and it carries on to the end. - var made = await checkDb.Set().Where(x => ids.Contains(x.RetryFor)).Select(x => x.Id).ToListAsync(); - checkDb.AddRange(made.Select(id => new XchangeResult(id, null, null, exception: "processed"))); - await checkDb.SaveChangesAsync(); + // The pipeline finishes what it sends, and it carries on to the end. + var processed = new HashSet(); + for (var i = 0; i < 300 && !retrying.IsCompleted; i++) + { + var made = await checkDb.Set().Where(x => ids.Contains(x.RetryFor)).Select(x => x.Id).ToListAsync(); + checkDb.AddRange(made.Where(processed.Add).Select(id => new XchangeResult(id, null, null, exception: "processed"))); + await checkDb.SaveChangesAsync(); + await Task.Delay(100); + } var plan = await retrying.WaitAsync(TimeSpan.FromSeconds(30)); Assert.Equal(250, plan.WillRetry); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/background.ts b/SW.Bitween.Web/ClientApp/src/api/http/background.ts index e7ffd4fa..32855bb7 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/background.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/background.ts @@ -94,10 +94,19 @@ export const subscribeBackground = (listener: () => void): (() => void) => { export const backgroundState = (): BackgroundState => state; +/** + * `changed` over what was `known` of it - unless it says running about work already over, which is + * only news that came late: a progress push after the answer, a list read before it. + */ +function merged(known: BackgroundRequest | undefined, changed: Partial): BackgroundRequest { + if (known && !isRunning(known.status) && changed.status && isRunning(changed.status)) return known; + return { ...known, ...changed } as BackgroundRequest; +} + function upsert(changed: Partial & { id: string }): boolean { const known = state.requests.find((r) => r.id === changed.id); if (!known && !changed.path) return false; - const next = known ? { ...known, ...changed } : (changed as BackgroundRequest); + const next = merged(known, changed); if (!isRunning(next.status) && !next.finishedOn) next.finishedOn = new Date().toISOString(); publish({ requests: [next, ...state.requests.filter((r) => r.id !== changed.id)] }); return true; @@ -139,18 +148,23 @@ export async function carriedOn(request: Omit | null = null; +/** Which session a list asked for belongs to: one asked before signing out mustn't fill the next. */ +let session = 0; + /** * Brings the list up to date from the server: on loading the app, on reconnecting, and while the * server can only be asked. */ export function refreshBackground(): Promise { + const asked = session; refreshing ??= request("/background") .then((rows) => { + if (asked !== session) return; const known = new Map(state.requests.map((r) => [r.id, r])); for (const row of rows ?? []) { if (dismissed.has(row.id) && !isRunning(row.status)) continue; const path = row.path.replace(/^(\w+ )?\/api(?=\/)/, "$1"); - known.set(row.id, { ...known.get(row.id), ...row, path }); + known.set(row.id, merged(known.get(row.id), { ...row, path })); } publish({ requests: [...known.values()] @@ -162,7 +176,7 @@ export function refreshBackground(): Promise { /* signed out, or the server is older than background requests: nothing to show */ }) .finally(() => { - refreshing = null; + if (asked === session) refreshing = null; }); return refreshing; } @@ -192,6 +206,8 @@ export function openBackground(requests: BackgroundRequest[]): void { /** The session ended: nothing of it may show to whoever signs in next in this tab. */ export function resetBackground(): void { + session++; + refreshing = null; const connection = hub; hub = null; void connection?.stop(); diff --git a/SW.Bitween.Web/ClientApp/src/components/layout/__tests__/BackgroundWork.test.tsx b/SW.Bitween.Web/ClientApp/src/components/layout/__tests__/BackgroundWork.test.tsx index 8eef6aa6..b0d5ecea 100644 --- a/SW.Bitween.Web/ClientApp/src/components/layout/__tests__/BackgroundWork.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/layout/__tests__/BackgroundWork.test.tsx @@ -113,6 +113,9 @@ describe("work carried on in the background", () => { const notice = await screen.findByRole("status"); expect(notice).toHaveTextContent("Bring from prod: prod's subscriptions is ready"); + // A progress push that arrives after it finished is old news: it stays ready. + tell({ id: "bg-2", status: "Running", done: 1, total: 2 }); + expect(await screen.findAllByRole("button", { name: "Background work: 1 ready" })).not.toHaveLength(0); await user.click(within(notice).getByRole("button", { name: "Open" })); await waitFor(() => expect(router.state.location.search).toBe("?bring=1")); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index 39ef5c14..aaf5326b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -339,7 +339,7 @@ export function ExchangesPage() { (done.substitutedCount > 0 ? `, ${done.substitutedCount.toLocaleString()} continuing an existing chain` : "") + (done.skippedCount > 0 ? `, ${done.skippedCount.toLocaleString()} skipped` : "") + (done.stoppedAfter != null - ? `. Cancelled after ${done.stoppedAfter.toLocaleString()} of ${done.targets.length.toLocaleString()}; the rest were not retried` + ? `. Cancelled after ${done.stoppedAfter.toLocaleString()}; the rest were not retried` : "") + ".", ); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx index 8ea77a79..8f243eca 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx @@ -254,8 +254,8 @@ describe("the exchanges list", () => { expect(retry.sent[0].body).toMatchObject({ filter: expect.any(String), reset: false }); tell({ id: "bulk-1", status: "Running", done: 2, total: 5 }); - expect(await within(dialog).findByText(/Retried 2 of 5…/)).toBeVisible(); - expect(within(dialog).getByRole("progressbar", { name: "Retried so far" })).toHaveAttribute("aria-valuenow", "2"); + expect(await within(dialog).findByText(/2 of 5 done…/)).toBeVisible(); + expect(within(dialog).getByRole("progressbar", { name: "How far it has got" })).toHaveAttribute("aria-valuenow", "2"); tell({ id: "bulk-1", status: "Done", done: 5, total: 5 }); expect(await screen.findByText("5 retries started, 1 skipped.")).toBeVisible(); @@ -263,7 +263,7 @@ describe("the exchanges list", () => { }); it("closed, carries on; cancelled from the header, says how far it got", async () => { - const retry = background(plan({ willRetry: 2, stoppedAfter: 2 })); + const retry = background(plan({ willRetry: 2, stoppedAfter: 2, targets: [] })); const { user } = renderApp("/exchanges?status=failed", { handlers: [exchanges({ total: 60 }).handler, ...filterOptions, preview, ...retry.handlers], }); @@ -280,7 +280,7 @@ describe("the exchanges list", () => { tell({ id: "bulk-1", status: "Cancelled", done: 2, total: 5 }); expect( - await screen.findByText("2 retries started, 1 skipped. Cancelled after 2 of 5; the rest were not retried."), + await screen.findByText("2 retries started, 1 skipped. Cancelled after 2; the rest were not retried."), ).toBeVisible(); expect(retry.sent).toHaveLength(1); }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx index 976f34a0..69ae5167 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/shared.tsx @@ -221,12 +221,12 @@ export function RetryDialog({ {progress && (

    - Retried {progress.done.toLocaleString()} of {progress.total.toLocaleString()}… You can close this: + {progress.done.toLocaleString()} of {progress.total.toLocaleString()} done… You can close this: it carries on, and the top bar has Cancel.

    Date: Sun, 1 Nov 2026 16:09:31 +0300 Subject: [PATCH 05/24] fix: show what a bring brought after its dialog closed --- SW.Bitween.Web/ClientApp/src/api/client.ts | 8 +- .../ClientApp/src/api/http/background.ts | 11 +++ .../ClientApp/src/api/http/mirror.ts | 13 ++- .../ClientApp/src/api/http/request.ts | 3 + SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 1 + .../src/components/layout/AppShell.tsx | 12 ++- .../src/components/layout/BackgroundWork.tsx | 23 ++++- .../layout/__tests__/BackgroundWork.test.tsx | 88 ++++++++++++++++++- .../src/pages/mirror/BringFromProd.tsx | 55 +++++++++++- docs/admin-ui.md | 2 +- 10 files changed, 206 insertions(+), 10 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index b8cc369c..c54c3605 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -683,7 +683,13 @@ export interface ApiClient { getMirror(): Promise; getMirrorProdKinds(): Promise; getMirrorProd(section: MirrorBringSection, type?: string): Promise; - bringFromProd(section: MirrorBringSection, keys: string[], apply: boolean): Promise; + bringFromProd( + section: MirrorBringSection, + keys: string[], + apply: boolean, + onBackground?: (id: string) => void, + ): Promise; + broughtInBackground(id: string): Promise; getMirrorSubscription(id: number): Promise; getMirrorTest(id: number): Promise; takeMirrorTest(id: number): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/background.ts b/SW.Bitween.Web/ClientApp/src/api/http/background.ts index 32855bb7..199bbe13 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/background.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/background.ts @@ -127,6 +127,16 @@ export function currentPage(): string { return page ?? window.location.pathname + window.location.search; } +/** Work whose window closed before its answer came: nothing on its page shows it, so the header must. */ +const leftToHeader = new Set(); + +/** The call that asked for it won't show its answer: say when it's ready, even on its own page. */ +export function leaveToHeader(id: string): void { + leftToHeader.add(id); +} + +export const isLeftToHeader = (id: string): boolean => leftToHeader.has(id); + /** Answers "Open" asked to show, by read: the request whose kept answer the read is given. */ const toShow = new Map(); @@ -216,6 +226,7 @@ export function resetBackground(): void { polling = null; idling = null; toShow.clear(); + leftToHeader.clear(); // The calls still waiting are told they're over; their answers are refused, as the session is. const waiting = [...waiters.keys()]; state = { requests: [] }; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts index c997497b..278cfbe3 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/mirror.ts @@ -83,12 +83,23 @@ export const mirrorMethods = { }, /** What bringing the picked items from prod does; with `apply`, brings them. Carried on in the background when slow. */ - bringFromProd(section: MirrorBringSection, keys: string[], apply: boolean): Promise { + bringFromProd( + section: MirrorBringSection, + keys: string[], + apply: boolean, + onBackground?: (id: string) => void, + ): Promise { const what = `${keys.length} ${keys.length === 1 ? SECTION[section].replace(/s$/, "") : SECTION[section]}`; return request("/mirror/bring", { method: "POST", body: { section, keys, apply }, background: apply ? `Bringing ${what} from prod` : `Bring from prod: what comes with ${what}`, + onBackground, }); }, + + /** What a bring carried on in the background answered, kept for its owner. */ + broughtInBackground(id: string): Promise { + return request(`/background/${id}/result`); + }, } satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/request.ts b/SW.Bitween.Web/ClientApp/src/api/http/request.ts index 8360335c..e69af493 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/request.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/request.ts @@ -212,6 +212,8 @@ export interface RequestOptions { * a file the browser then downloads from a link, rather than holding it in memory. */ leaveAnswer?: boolean; + /** With `background`: told its id once it carries on there. */ + onBackground?: (id: string) => void; } /** @@ -281,6 +283,7 @@ async function send(path: string, opts: RequestOptions): Promise { if (res.status === 202 && carryOn) { const started = (await res.json()) as { id: string; status: BackgroundStatus }; + opts.onBackground?.(started.id); await carriedOn({ id: started.id, status: started.status, path: method === "GET" ? path : `${method} ${path}`, page, label }); if (opts.leaveAnswer) return new Response(JSON.stringify({ id: started.id }), { headers: { "Content-Type": "application/json" } }); diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index d2c37c45..004830ba 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -221,6 +221,7 @@ export const keys = { subscription: (id: number) => ["mirror", "subscription", id] as const, test: (id: number) => ["mirror", "test", id] as const, listed: (section: string) => ["mirror", "listed", section] as const, + brought: (id: string) => ["mirror", "brought", id] as const, }, settings: { diff --git a/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx b/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx index a4d71a57..503b39f3 100644 --- a/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Link, NavLink, Outlet, useLocation, useNavigate } from "react-router"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -32,6 +32,11 @@ import { recordVisit } from "../../lib/recentPages"; import { refreshBackground, setCurrentPage } from "../../api/http/background"; import { BackgroundReadyNotice, BackgroundTray } from "./BackgroundWork"; +/** What a bring carried on in the background brought; loaded only when the header's Open asks for it. */ +const BroughtFromBackground = lazy(() => + import("../../pages/mirror/BringFromProd").then((m) => ({ default: m.BroughtFromBackground })), +); + /** * Shown on every page while unsaved setting changes exist - the whole app * renders with the draft applied, so this is the "you're previewing" strip @@ -508,6 +513,11 @@ export function AppShell() { {paletteOpen && } + {new URLSearchParams(search).has("brought") && ( + + + + )} {/* mobile drawer */} {mobileOpen && ( diff --git a/SW.Bitween.Web/ClientApp/src/components/layout/BackgroundWork.tsx b/SW.Bitween.Web/ClientApp/src/components/layout/BackgroundWork.tsx index 4b9dc59e..1b725881 100644 --- a/SW.Bitween.Web/ClientApp/src/components/layout/BackgroundWork.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/layout/BackgroundWork.tsx @@ -4,6 +4,7 @@ import { useLocation, useNavigate } from "react-router"; import { cancelBackground, dismissBackground, + isLeftToHeader, isRunning, openBackground, type BackgroundRequest, @@ -29,6 +30,17 @@ const ENDED: Record = { /** An export's answer is the zip, which "Download" saves again rather than going back to the page. */ const isExport = (r: BackgroundRequest) => r.path.startsWith("POST /xchanges/export"); +/** A bring's answer is what it brought, which "Open" shows on its page: `?brought=` its id. */ +const isBring = (r: BackgroundRequest) => r.path.startsWith("POST /mirror/bring"); + +/** `page`, showing what the bring `id` brought, rather than the picker it was asked from. */ +function showingBrought(page: string, id: string): string { + const url = new URL(page, window.location.origin); + url.searchParams.delete("bring"); + url.searchParams.set("brought", id); + return url.pathname + url.search; +} + /** What a line says under its name. */ function said(r: BackgroundRequest, now: number): { text: string; title: string } { const counted = r.total != null ? ` · ${(r.done ?? 0).toLocaleString()} of ${r.total.toLocaleString()}` : ""; @@ -55,13 +67,18 @@ function duration(from: string, to: number): string { return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; } -/** Goes back to what it was for: its page - or, for an export, its zip again. */ +/** Goes back to what it was for: its page - or, for an export, its zip again; for a bring, what it brought. */ async function openWork(r: BackgroundRequest, navigate: (to: string) => void): Promise { if (isExport(r) && r.status === "Done") { followDownload((await api.linkToBackgroundFile(r.id)).url); dismissBackground([r.id]); return; } + if (isBring(r) && r.status === "Done") { + dismissBackground([r.id]); + navigate(showingBrought(r.page ?? window.location.pathname, r.id)); + return; + } openBackground([r]); if (r.page) navigate(r.page); } @@ -186,8 +203,8 @@ export function BackgroundReadyNotice() { continue; } if (!seenRunning.current.delete(r.id)) continue; - // An export left on its own page still saves itself, so it says nothing there either. - if (r.page === here) dismissBackground([r.id]); + // Its page shows it - unless its window closed first, as a bring's can. + if (r.page === here && !isLeftToHeader(r.id)) dismissBackground([r.id]); else setNotice(r); } }, [requests, here]); diff --git a/SW.Bitween.Web/ClientApp/src/components/layout/__tests__/BackgroundWork.test.tsx b/SW.Bitween.Web/ClientApp/src/components/layout/__tests__/BackgroundWork.test.tsx index b0d5ecea..2d1dfc27 100644 --- a/SW.Bitween.Web/ClientApp/src/components/layout/__tests__/BackgroundWork.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/layout/__tests__/BackgroundWork.test.tsx @@ -1,10 +1,10 @@ import { act, screen, waitFor, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; -import type { MirrorProdKinds, MirrorProdList } from "../../../api"; +import type { MirrorBring, MirrorProdKinds, MirrorProdList } from "../../../api"; import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; import { empty, none, page } from "../../../__tests__/support/listPage"; -import { raw } from "../../../pages/subscriptions/__tests__/subscriptionBackend"; +import { backend, raw, sub } from "../../../pages/subscriptions/__tests__/subscriptionBackend"; /** The SignalR connection, faked: each test says what the server tells it. */ type Change = { id: string; status: string; done?: number; total?: number; summary?: string }; @@ -71,6 +71,16 @@ const prodList: MirrorProdList = { ], }; +/** What bringing "Orders rollup" comes to, or - `applied` - brought. */ +const brought = (applied: boolean): MirrorBring => ({ + applied, + pieces: [ + { section: "subscriptions", noun: "subscription", key: "Orders rollup", state: "new", why: "Picked.", reason: null, stagingId: applied ? "41" : null, type: "Aggregation" }, + ], + needsYou: [], + failed: [], +}); + /** Prod's list, which the server carries on in the background as `id`. */ function slowProdList(id: string, asked: { prefer: string | null; label: string }[] = []) { return [ @@ -123,6 +133,80 @@ describe("work carried on in the background", () => { expect(await within(dialog).findByText("1,204")).toBeVisible(); }); + it("says when a bring whose window closed is done, and Open shows what it brought", async () => { + const { user, router } = renderApp("/aggregations?bring=1", { + handlers: [ + ...mirror, + http.get(apiPath("/mirror/prod"), () => HttpResponse.json(prodList)), + http.post(apiPath("/mirror/bring"), async ({ request }) => { + const { apply } = (await request.json()) as { apply: boolean }; + return apply ? HttpResponse.json({ id: "bg-5", status: "Running" }, { status: 202 }) : HttpResponse.json(brought(false)); + }), + http.get(apiPath("/background/bg-5/result"), () => HttpResponse.json(brought(true))), + ], + }); + const dialog = await screen.findByRole("dialog", { name: "Bring aggregations from prod" }); + await user.click(await within(dialog).findByRole("checkbox", { name: "Pick “Orders rollup”" })); + await user.click(within(dialog).getByRole("button", { name: "See what comes" })); + await user.click(await within(dialog).findByRole("button", { name: "Bring 1 item" })); + await screen.findAllByRole("button", { name: "Background work: 1 running" }); + + // Closed while it brings, on the page it was asked from: nothing there will show what it brought. + await user.click(within(dialog).getAllByRole("button", { name: "Close" })[0]); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + tell({ id: "bg-5", status: "Done" }); + + const notice = await screen.findByRole("status"); + expect(notice).toHaveTextContent("Bringing 1 subscription from prod is ready"); + await user.click(within(notice).getByRole("button", { name: "Open" })); + + const shown = await screen.findByRole("dialog", { name: "Brought from prod" }); + expect(await within(shown).findByText("Brought 1 item")).toBeVisible(); + expect(router.state.location.search).toBe("?brought=bg-5"); + await user.click(within(shown).getByRole("button", { name: "Done" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(router.state.location.search).toBe(""); + }); + + it("says so too when that window closed on the page itself, where nothing else would show it", async () => { + const { handlers } = backend([sub()]); + const { user, router } = renderApp("/subscriptions/10", { + handlers: [ + ...mirror, + http.get(apiPath("/mirror/subscription"), () => + HttpResponse.json({ + key: "Acme orders", onProd: true, prodId: "31", prodUrl: "https://bitween.acme.test", used: [], needsYou: [], + toBring: [{ section: "partners", noun: "partner", key: "Globex", state: "new", why: "Picked.", reason: null, stagingId: null, type: null }], + }), + ), + http.get(apiPath("/mirror/test"), () => + HttpResponse.json({ + state: "idle", busy: null, done: 0, total: 0, error: null, takenOn: null, reason: null, families: [], ranOn: null, runs: [], + setupError: null, made: [], exchanges: 0, last: null, + }), + ), + http.post(apiPath("/mirror/bring"), async ({ request }) => { + const { apply } = (await request.json()) as { apply: boolean }; + return apply ? HttpResponse.json({ id: "bg-6", status: "Running" }, { status: 202 }) : HttpResponse.json(brought(false)); + }), + http.get(apiPath("/background/bg-6/result"), () => HttpResponse.json(brought(true))), + ...handlers, + ], + }); + await user.click(await screen.findByRole("button", { name: "Bring it from prod" })); + const dialog = await screen.findByRole("dialog", { name: "Bring what it needs from prod" }); + await user.click(await within(dialog).findByRole("button", { name: "Bring 1 item" })); + await screen.findAllByRole("button", { name: "Background work: 1 running" }); + await user.click(within(dialog).getAllByRole("button", { name: "Close" })[0]); + tell({ id: "bg-6", status: "Done" }); + + const notice = await screen.findByRole("status"); + await user.click(within(notice).getByRole("button", { name: "Open" })); + const shown = await screen.findByRole("dialog", { name: "Brought from prod" }); + expect(await within(shown).findByText("Brought 1 item")).toBeVisible(); + expect(router.state.location.pathname).toBe("/subscriptions/10"); + }); + it("shows how far work that counts its steps has got, and Cancel asks the server to stop it", async () => { let cancelled = false; const { user } = renderApp("/aggregations?bring=1", { diff --git a/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx index dbd8d8bb..ccfe10ef 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx @@ -13,6 +13,7 @@ import { type MirrorProdCounts, type MirrorProdItem, } from "../../api"; +import { leaveToHeader } from "../../api/http/background"; import { keys } from "../../api/queryKeys"; import { Sparkline } from "../../components/charts/Sparkline"; import { Badge, Button, FormError, LoadError, LoadingBlock } from "../../components/ui/basics"; @@ -99,8 +100,22 @@ export function BringDialog({ section, type, label, picked: given, onClose }: { started.current = true; preview.mutate(); }, [given, preview]); + // Closed while it brings: the header says when it's done, and its Open shows what was brought. + const carried = useRef(null); + const closed = useRef(false); + useEffect(() => { + closed.current = false; + return () => { + closed.current = true; + if (carried.current) leaveToHeader(carried.current); + }; + }, []); const bring = useMutation({ - mutationFn: () => api.bringFromProd(section, [...picked], true), + mutationFn: () => + api.bringFromProd(section, [...picked], true, (id) => { + carried.current = id; + if (closed.current) leaveToHeader(id); + }), onSuccess: (result) => { setShown(result); // A bring touches several kinds at once: every list may show something new. @@ -500,6 +515,44 @@ function PieceList({ pieces, linked = false }: { pieces: MirrorPiece[]; linked?: ); } +/** What a bring carried on in the background brought, opened from the header: `?brought=` its id. */ +export function BroughtFromBackground() { + const [params, setParams] = useSearchParams(); + const id = params.get("brought"); + const answer = useQuery({ + queryKey: keys.mirror.brought(id ?? ""), + queryFn: () => api.broughtInBackground(id!), + enabled: id != null, + staleTime: Infinity, + }); + if (!id) return null; + + const close = () => + setParams( + (p) => { + p.delete("brought"); + return p; + }, + { replace: true }, + ); + return ( + + {answer.isPending ? ( + + ) : answer.isError ? ( +
    + {answer.error.message} +
    + +
    +
    + ) : ( + + )} +
    + ); +} + function Brought({ result, picked, onClose }: { result: MirrorBring; picked: Picked; onClose: () => void }) { const failed = new Set(result.failed.map((f) => `${f.section}/${f.key}`)); const done = result.pieces.filter((p) => (p.state === "new" || p.state === "adds" || p.state === "install") && !failed.has(`${p.section}/${p.key}`)); diff --git a/docs/admin-ui.md b/docs/admin-ui.md index 33ba6a4e..59811c42 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -10,7 +10,7 @@ The sign-in page offers email and password, Microsoft, an [OpenID Connect provid ## Work that takes long -Four things can take long, and carry on in the background once they take more than three seconds: Bring from prod's list and bringing what was picked, a bulk retry and working out what it will run, and exporting exchange files. You can go on to other pages meanwhile. The top bar shows what is running, for how long and, for a bulk retry or an export, how far it has got, with **Cancel**. A bulk retry takes up to 500,000 exchanges and goes at the pipeline's pace; an export takes up to 50,000, and downloads straight to disk when it's ready. When one finishes while you're elsewhere, a notice says so with **Open**, which goes back to where it was asked from - Bring from prod's window reopens - or, for an export, **Download**. Every other page loads as it always has. See [Work that takes long](api-reference.md#work-that-takes-long). +Four things can take long, and carry on in the background once they take more than three seconds: Bring from prod's list and bringing what was picked, a bulk retry and working out what it will run, and exporting exchange files. You can go on to other pages meanwhile. The top bar shows what is running, for how long and, for a bulk retry or an export, how far it has got, with **Cancel**. A bulk retry takes up to 500,000 exchanges and goes at the pipeline's pace; an export takes up to 50,000, and downloads straight to disk when it's ready. When one finishes while you're elsewhere, or after you closed its window, a notice says so with **Open**, which goes back to where it was asked from - Bring from prod's window reopens, and for bringing it shows what was brought - or, for an export, **Download**. Every other page loads as it always has. See [Work that takes long](api-reference.md#work-that-takes-long). ## Operate From 3d9e54894891dfc3022f65aea699aa749e163ec6 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 1 Nov 2026 16:23:30 +0300 Subject: [PATCH 06/24] fix: dashboard failures and retry summary timing out on big databases --- .../Resources/Dashboard/FailuresToActOn.cs | 47 + .../Resources/Dashboard/RetrySummary.cs | 36 +- .../Tests/RetryChainTests.cs | 67 + SW.Bitween.PgSql/BitweenDbContext.cs | 13 + ...0261101132211_DashboardIndexes.Designer.cs | 4209 +++++++++++++++++ .../20261101132211_DashboardIndexes.cs | 33 + .../BitweenDbContextModelSnapshot.cs | 12 + .../ClientApp/e2e/dashboard.spec.ts | 2 + SW.Bitween.Web/ClientApp/src/api/client.ts | 12 + .../ClientApp/src/api/http/dashboard.ts | 68 +- SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 4 + SW.Bitween.Web/ClientApp/src/api/types.ts | 19 +- .../src/pages/dashboard/DashboardPage.tsx | 61 +- .../__tests__/DashboardPage.test.tsx | 41 +- docs/api-reference.md | 3 +- docs/operations.md | 2 +- 16 files changed, 4568 insertions(+), 61 deletions(-) create mode 100644 SW.Bitween.Api/Resources/Dashboard/FailuresToActOn.cs create mode 100644 SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.cs diff --git a/SW.Bitween.Api/Resources/Dashboard/FailuresToActOn.cs b/SW.Bitween.Api/Resources/Dashboard/FailuresToActOn.cs new file mode 100644 index 00000000..0a63988a --- /dev/null +++ b/SW.Bitween.Api/Resources/Dashboard/FailuresToActOn.cs @@ -0,0 +1,47 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Resources.Xchanges; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Dashboard; + +/// +/// How many failures nothing has been retried from yet: the ends of chains, the problems still to +/// deal with. The number the exchange list's failed + newest-attempt view counts, counted here from +/// the failures rather than from the exchanges. +/// +/// +/// The dashboard used to ask the list for it, and the list fetches its rows newest first: on a +/// database where nearly every failure gets retried, that walked back through millions of exchanges +/// before reaching one, past the 30s limit - for a row the dashboard never showed. Here the failures +/// come from their own index (ix_xchange_result_failed on Postgres) and the retry check from +/// RetryFor's, so no exchange row is read. Capped like the list's count, so the client shows +/// "10,000+" for either. +/// +[HandlerName("failurestoacton")] +public class FailuresToActOn : IQueryHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public FailuresToActOn(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle() + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Dashboard.View); + + var total = await _dbContext.Set().AsNoTracking() + .Where(result => !result.Success + && !_dbContext.Set().Any(child => child.RetryFor == result.Id)) + .Take(Search.CountCap + 1) + .CountAsync(); + + return new { Total = total }; + } +} diff --git a/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs b/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs index d59ad8b9..3e9c56b8 100644 --- a/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs +++ b/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs @@ -67,32 +67,42 @@ group result by result.Success && !result.ResponseBad into worked // Chains that keep failing: a failure nothing has been retried from - so it is where that // chain has got to - which is itself a retry, so the chain is at least two attempts long. + // + // Over the same seven days as the number above, and asked so that indexes answer all of it: + // the retries from their own (ix_xchange_retries_started_on on Postgres), whether each + // failed from the failures' and whether it was retried from RetryFor's. With no time bound + // it walked back through every retry ever made until it found five hundred failures - on a + // database where every failure gets retried, millions of exchanges, past the 30s limit. var candidates = await ( from xchange in _dbContext.Set() - join result in _dbContext.Set() on xchange.Id equals result.Id where xchange.RetryFor != null - && !result.Success + && xchange.StartedOn >= since + && _dbContext.Set().Any(result => result.Id == xchange.Id && !result.Success) && !_dbContext.Set().Any(child => child.RetryFor == xchange.Id) orderby xchange.StartedOn descending - select new - { - xchange.Id, - xchange.RetryFor, - xchange.SubscriptionId, - xchange.DocumentId, - xchange.StartedOn, - result.Exception - }).AsNoTracking().Take(Candidates).ToListAsync(); + select new { xchange.Id, xchange.RetryFor, xchange.StartedOn }) + .AsNoTracking().Take(Candidates).ToListAsync(); var attempts = await CountAttempts(candidates.ToDictionary(c => c.Id, c => c.RetryFor)); - var worst = candidates + var worstIds = candidates .OrderByDescending(c => attempts[c.Id]) .ThenByDescending(c => c.StartedOn) .Take(Listed) + .Select(c => c.Id) + .ToList(); + + // What is shown of them, and the names below, for the few that are actually listed rather + // than for all five hundred. + var worst = (await ( + from xchange in _dbContext.Set() + join result in _dbContext.Set() on xchange.Id equals result.Id + where worstIds.Contains(xchange.Id) + select new { xchange.Id, xchange.SubscriptionId, xchange.DocumentId, xchange.StartedOn, result.Exception }) + .AsNoTracking().ToListAsync()) + .OrderBy(w => worstIds.IndexOf(w.Id)) .ToList(); - // Names for the few that are actually listed, rather than for all five hundred. var subscriptionIds = worst.Where(w => w.SubscriptionId != null).Select(w => w.SubscriptionId!.Value).ToList(); var subscriptionNames = await _dbContext.Set().AsNoTracking() .Where(s => subscriptionIds.Contains(s.Id)) diff --git a/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs index ef64f8f0..d1b012db 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs @@ -530,6 +530,73 @@ public async Task RetrySummary_ranks_the_chains_that_keep_failing_and_says_wheth Assert.True(retries.GetProperty("Finished").GetInt32() >= retries.GetProperty("Succeeded").GetInt32()); } + /// + /// The panel looks at the same seven days as its retry numbers: a chain nothing has retried for + /// longer is still a failure to act on, but no longer one that keeps failing. + /// + [Fact] + public async Task RetrySummary_leaves_out_chains_nothing_has_retried_for_a_week() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); + + var (_, first) = await FailedXchange(db, xs, "Summary Old Doc"); + var second = await RetryOnce(db, xs, ctx, first.Id); + var third = await RetryOnce(db, xs, ctx, second.Id); + var chain = new[] { first.Id, second.Id, third.Id }; + await db.Set().Where(x => chain.Contains(x.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.StartedOn, x => x.StartedOn.AddDays(-8))); + + var summary = await new Resources.Dashboard.RetrySummary(db, ctx).Handle(); + var json = System.Text.Json.JsonSerializer.SerializeToElement(summary); + + Assert.DoesNotContain(json.GetProperty("FailingChains").EnumerateArray(), + c => c.GetProperty("Id").GetString() == third.Id); + } + + /// + /// The dashboard's failures-to-act-on tile counts from the failures rather than through the + /// exchange list, and has to come to the number the list shows for the view the tile opens. + /// + [Fact] + public async Task FailuresToActOn_counts_what_the_list_of_failures_to_act_on_counts() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); + + var tile = new Resources.Dashboard.FailuresToActOn(db, ctx); + async Task Total() => + System.Text.Json.JsonSerializer.SerializeToElement(await tile.Handle()).GetProperty("Total").GetInt32(); + var before = await Total(); + + // A chain of three failed attempts is one failure to act on: the attempt at its end. + var (sub, first) = await FailedXchange(db, xs, "To Act On Doc"); + var second = await RetryOnce(db, xs, ctx, first.Id); + await RetryOnce(db, xs, ctx, second.Id); + + // A failure whose retry worked leaves nothing to act on. + var healed = await xs.CreateXchange(sub, new XchangeFile("{}")); + await db.SaveChangesAsync(); + db.Set().Add(new XchangeResult(healed.Id, null, null, exception: "boom")); + await db.SaveChangesAsync(); + await new Resources.Xchanges.Retry(db, ctx, xs).Handle(healed.Id, new XchangeRetry()); + await db.SaveChangesAsync(); + var healedRetry = await db.Set().FirstAsync(x => x.RetryFor == healed.Id); + db.Set().Add(new XchangeResult(healedRetry.Id, null, null)); + await db.SaveChangesAsync(); + + var after = await Total(); + Assert.Equal(before + 1, after); + + var listed = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, ctx) + .Handle(new SearchyRequest("filter=StatusFilter:1:3&filter=LatestOnly:1:true") { PageSize = 1 }); + Assert.Equal(listed.TotalCount, after); + } + // ─── Reading the chain back ─────────────────────────────────────────────── [Fact] diff --git a/SW.Bitween.PgSql/BitweenDbContext.cs b/SW.Bitween.PgSql/BitweenDbContext.cs index 77fdc107..eba1655e 100644 --- a/SW.Bitween.PgSql/BitweenDbContext.cs +++ b/SW.Bitween.PgSql/BitweenDbContext.cs @@ -407,6 +407,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(i => i.SubscriptionId); b.HasIndex(i => i.StartedOn); b.HasIndex(i => i.RetryFor); + // Retries newest first, for the dashboard's retry panel: a few percent of the + // exchanges, read without touching the rows. + b.HasIndex(i => i.StartedOn, "ix_xchange_retries_started_on") + .HasDatabaseName("ix_xchange_retries_started_on") + .HasFilter("retry_for IS NOT NULL") + .IncludeProperties(i => new { i.Id, i.RetryFor }) + .IsCreatedConcurrently(); }); modelBuilder.Entity(b => @@ -438,6 +445,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.HandlerVersion).HasMaxLength(50); // A subscription's latest result: what auto-pause counts from. b.HasIndex(p => new { p.SubscriptionId, p.FinishedOn }).IsCreatedConcurrently(); + // The failures alone, a few percent of the results: what the dashboard counts the + // failures still to act on from, and how it asks whether a retry failed. + b.HasIndex(p => p.Id, "ix_xchange_result_failed") + .HasDatabaseName("ix_xchange_result_failed") + .HasFilter("NOT success") + .IsCreatedConcurrently(); b.HasOne().WithOne().HasForeignKey(p => p.Id).OnDelete(DeleteBehavior.Cascade); }); diff --git a/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.Designer.cs b/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.Designer.cs new file mode 100644 index 00000000..fccc0ee9 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.Designer.cs @@ -0,0 +1,4209 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SW.Bitween.Model; +using SW.Bitween.PgSql; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20261101132211_DashboardIndexes")] + partial class DashboardIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasColumnName("failed_login_count"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("LastSignInOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in_on"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("MicrosoftIdentity") + .HasColumnType("text") + .HasColumnName("microsoft_identity"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("OidcIdentity") + .HasColumnType("text") + .HasColumnName("oidc_identity"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("email_error"); + + b.Property("EmailedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("emailed_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("Purpose") + .HasColumnType("smallint") + .HasColumnName("purpose"); + + b.HasKey("Id") + .HasName("pk_account_links"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_links_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_account_links_hash"); + + b.ToTable("AccountLinks", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.Property("SupersededOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_on"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("last_used_from"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("revoked_by"); + + b.Property("RevokedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_on"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("shown"); + + b.HasKey("Id") + .HasName("pk_service_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_service_tokens_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_service_tokens_hash"); + + b.ToTable("ServiceTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("base_version"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("files_hash"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("files_json"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("language"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_adapter_draft"); + + b.HasIndex("AdapterId") + .HasDatabaseName("ix_adapter_draft_adapter_id"); + + b.ToTable("adapter_draft", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("action"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("DraftId") + .HasColumnType("integer") + .HasColumnName("draft_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_release"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_release_adapter_id_occurred_on"); + + b.ToTable("adapter_release", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("path"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_source_access"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_source_access_adapter_id_occurred_on"); + + b.ToTable("adapter_source_access", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key_name"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.HasKey("PartnerId", "KeyName") + .HasName("pk_api_key_use"); + + b.ToTable("api_key_use", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Changes") + .HasColumnType("text") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("character varying(36)") + .HasColumnName("correlation_id"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_key"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_name"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Sequence") + .HasColumnType("integer") + .HasColumnName("sequence"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("character varying(10)") + .HasColumnName("state"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_audit_entries"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_entries_correlation_id"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_audit_entries_occurred_on"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn") + .HasDatabaseName("ix_audit_entries_entity_name_entity_key_occurred_on"); + + b.ToTable("AuditEntries", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Done") + .HasColumnType("integer") + .HasColumnName("done"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("Node") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("node"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("owner"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("path"); + + b.Property("Result") + .HasColumnType("bytea") + .HasColumnName("result"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("result_content_type"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_disposition"); + + b.Property("ResultKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_key"); + + b.Property("ResultStatus") + .HasColumnType("integer") + .HasColumnName("result_status"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("summary"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Id") + .HasName("pk_background_request"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_background_request_started_on"); + + b.HasIndex("Owner", "StartedOn") + .HasDatabaseName("ix_background_request_owner_started_on"); + + b.ToTable("background_request", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("AcquiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_on"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("owner_node"); + + b.Property("Term") + .HasColumnType("bigint") + .HasColumnName("term"); + + b.HasKey("Id") + .HasName("pk_cluster_lease"); + + b.ToTable("cluster_lease", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("DataSources") + .HasColumnType("boolean") + .HasColumnName("data_sources"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("host"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("integer") + .HasColumnName("outbox_published_last_hour"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("resident_health"); + + b.Property("Runtimes") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtimes"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_cluster_node"); + + b.HasIndex("LastSeenOn") + .HasDatabaseName("ix_cluster_node_last_seen_on"); + + b.ToTable("cluster_node", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("access_token_expires_on"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("authorize_parameters"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("authorize_url"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("authorized_by"); + + b.Property("AuthorizedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("authorized_on"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Grant") + .HasColumnType("integer") + .HasColumnName("grant"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property("RenewingUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("renewing_until"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("scopes"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("token_auth"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("token_url"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_connections"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_connections_name"); + + b.ToTable("Connections", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("instance_key"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("value"); + + b.HasKey("AdapterId", "InstanceKey", "Name") + .HasName("pk_adapter_state"); + + b.ToTable("adapter_state", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .HasColumnType("text") + .HasColumnName("adapter_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CpuLimitSamples") + .HasColumnType("integer") + .HasColumnName("cpu_limit_samples"); + + b.Property("CpuPercentLimit") + .HasColumnType("double precision") + .HasColumnName("cpu_percent_limit"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("integer") + .HasColumnName("deduplication_window_days"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("hard_memory_limit_mb"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("LastHeartbeatOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_heartbeat_on"); + + b.Property("LastKnownState") + .HasColumnType("text") + .HasColumnName("last_known_state"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnedByNode") + .HasColumnType("text") + .HasColumnName("owned_by_node"); + + b.Property("Placement") + .HasColumnType("integer") + .HasColumnName("placement"); + + b.Property>("Properties") + .HasColumnType("hstore") + .HasColumnName("properties"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("text[]") + .HasColumnName("secret_properties"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("soft_memory_limit_mb"); + + b.HasKey("Id") + .HasName("pk_data_source"); + + b.ToTable("data_source", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("CursorColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("cursor_column"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key_column"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sql"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_data_source_statement"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_data_source_statement_work_group_id"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique() + .HasDatabaseName("ix_data_source_statement_data_source_id_name"); + + b.ToTable("data_source_statement", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("id"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("SeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("seen_on"); + + b.Property("XchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_inbound_message"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_inbound_message_data_source_id"); + + b.HasIndex("SeenOn") + .HasDatabaseName("ix_inbound_message_seen_on"); + + b.ToTable("inbound_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.Property("RunFailures") + .HasColumnType("integer") + .HasColumnName("run_failures"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DropDuplicates") + .HasColumnType("boolean") + .HasColumnName("drop_duplicates"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("json") + .HasColumnName("promoted_properties"); + + b.Property("RetiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_on"); + + b.Property("ValidationSchema") + .HasColumnType("text") + .HasColumnName("validation_schema"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Dropped") + .HasColumnType("boolean") + .HasColumnName("dropped"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("original_xchange_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.HasKey("Id") + .HasName("pk_duplicate_arrival"); + + b.HasIndex("DocumentId", "OccurredOn") + .HasDatabaseName("ix_duplicate_arrival_document_id_occurred_on"); + + b.ToTable("duplicate_arrival", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("integer") + .HasColumnName("auth_method"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FailureAnswer") + .HasColumnType("integer") + .HasColumnName("failure_answer"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_audience"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_issuer"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("jwt_partner_claim"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("partner_key_header"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Endpoint") + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property>("EndpointProperties") + .HasColumnType("hstore") + .HasColumnName("endpoint_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_bus_gateway_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.Property("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_idempotency_key"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_idempotency_key_created_on"); + + b.HasIndex("Scope", "Key") + .IsUnique() + .HasDatabaseName("ix_idempotency_key_scope_key"); + + b.ToTable("idempotency_key", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("Role") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Runtime") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("runtime"); + + b.Property("Calls") + .HasColumnType("integer") + .HasColumnName("calls"); + + b.Property("Crashes") + .HasColumnType("integer") + .HasColumnName("crashes"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failures") + .HasColumnType("integer") + .HasColumnName("failures"); + + b.Property("LimitStops") + .HasColumnType("integer") + .HasColumnName("limit_stops"); + + b.Property("Timeouts") + .HasColumnType("integer") + .HasColumnName("timeouts"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime") + .HasName("pk_adapter_stats"); + + b.ToTable("adapter_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("Scope") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("scope"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope_key"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("fingerprint"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("sample"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint") + .HasName("pk_error_stats"); + + b.ToTable("error_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("BadResponse") + .HasColumnType("integer") + .HasColumnName("bad_response"); + + b.Property("BytesIn") + .HasColumnType("bigint") + .HasColumnName("bytes_in"); + + b.Property("BytesOut") + .HasColumnType("bigint") + .HasColumnName("bytes_out"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DbCommands") + .HasColumnType("bigint") + .HasColumnName("db_commands"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failed") + .HasColumnType("integer") + .HasColumnName("failed"); + + b.Property("FailedAtHandler") + .HasColumnType("integer") + .HasColumnName("failed_at_handler"); + + b.Property("FailedAtMapper") + .HasColumnType("integer") + .HasColumnName("failed_at_mapper"); + + b.Property("ManualRetries") + .HasColumnType("integer") + .HasColumnName("manual_retries"); + + b.Property("PayloadParses") + .HasColumnType("bigint") + .HasColumnName("payload_parses"); + + b.Property("Retries") + .HasColumnType("integer") + .HasColumnName("retries"); + + b.Property("Succeeded") + .HasColumnType("integer") + .HasColumnName("succeeded"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId") + .HasName("pk_exchange_stats"); + + b.ToTable("exchange_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("timestamp with time zone") + .HasColumnName("at"); + + b.Property("AckRate") + .HasColumnType("double precision") + .HasColumnName("ack_rate"); + + b.Property("Consumers") + .HasColumnType("integer") + .HasColumnName("consumers"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint") + .HasColumnName("dead_letter_backlog"); + + b.Property("IncomingRate") + .HasColumnType("double precision") + .HasColumnName("incoming_rate"); + + b.Property("QueueDepth") + .HasColumnType("bigint") + .HasColumnName("queue_depth"); + + b.Property("RetryBacklog") + .HasColumnType("bigint") + .HasColumnName("retry_backlog"); + + b.Property("UnhealthyConsumers") + .HasColumnType("integer") + .HasColumnName("unhealthy_consumers"); + + b.HasKey("At") + .HasName("pk_queue_snapshot"); + + b.ToTable("queue_snapshot", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_notification_channel"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_channel_name"); + + b.ToTable("notification_channel", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.PrimitiveCollection("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("detail"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("target"); + + b.HasKey("Id") + .HasName("pk_operator_action"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_operator_action_occurred_on"); + + b.ToTable("operator_action", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ClaimedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_until"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_on"); + + b.HasKey("Id") + .HasName("pk_outbox_message"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_outbox_message_created_on") + .HasFilter("published_on IS NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("outbox_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("login_identity"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.HasIndex("LoginIdentity") + .IsUnique() + .HasDatabaseName("ix_partner_login_identity"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.PrimitiveCollection("ExchangeIds") + .HasColumnType("text[]") + .HasColumnName("exchange_ids"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_receive_attempt"); + + b.HasIndex("SubscriptionId", "StartedOn") + .HasDatabaseName("ix_receive_attempt_subscription_id_started_on"); + + b.ToTable("receive_attempt", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("AlertMode") + .HasColumnType("smallint") + .HasColumnName("alert_mode"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_alert_override"); + + b.ToTable("retry_alert_override", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AttemptsUsed") + .HasColumnType("integer") + .HasColumnName("attempts_used"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("exhausted_notified_on"); + + b.Property("LastAttemptOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_on"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_group_usage"); + + b.ToTable("retry_group_usage", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_settings"); + + b.ToTable("Settings", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("AutoPauseAfterFailures") + .HasColumnType("integer") + .HasColumnName("auto_pause_after_failures"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ExpectTraffic") + .HasColumnType("text") + .HasColumnName("expect_traffic"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Notifications") + .HasColumnType("text") + .HasColumnName("notifications"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedAutomatically") + .HasColumnType("boolean") + .HasColumnName("paused_automatically"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("QuietSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("quiet_since"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ReceiverVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("receiver_version"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("RunOnBadResponses") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_responses"); + + b.Property("RunningSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("running_since"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("ValidatorVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("validator_version"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_subscription_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("saved_by"); + + b.Property("SavedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("saved_on"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("text") + .HasColumnName("snapshot"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_revision"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique() + .HasDatabaseName("ix_subscription_revision_subscription_id_number"); + + b.ToTable("subscription_revision", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("files_prefix"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on") + .HasDatabaseName("ix_xchange_retries_started_on") + .HasFilter("retry_for IS NOT NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on"), new[] { "Id", "RetryFor" }); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer") + .HasColumnName("channel_id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Suppressed") + .HasColumnType("boolean") + .HasColumnName("suppressed"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.HasIndex("XchangeId") + .HasDatabaseName("ix_xchange_notification_xchange_id"); + + b.HasIndex("ChannelId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_channel_id_finished_on"); + + b.HasIndex("NotifierId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_notifier_id_finished_on"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.PrimitiveCollection("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("NodeName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node_name"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("retry_blocked_reason"); + + b.Property("RetryGroupId") + .HasColumnType("uuid") + .HasColumnName("retry_group_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("warning"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.HasIndex("RetryGroupId") + .HasDatabaseName("ix_xchange_result_retry_group_id"); + + b.HasIndex("SubscriptionId", "FinishedOn") + .HasDatabaseName("ix_xchange_result_subscription_id_finished_on") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex(new[] { "Id" }, "ix_xchange_result_failed") + .HasDatabaseName("ix_xchange_result_failed") + .HasFilter("NOT success") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("ClaimedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_on"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_retry_claim"); + + b.ToTable("xchange_retry_claim", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_links_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_service_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_data_source_statement_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_data_source_statement_work_group_work_group_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inbound_message_data_source_data_source_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .HasConstraintName("fk_bus_gateway_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("key_prefix"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "ResponseSubscription") + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_revision_subscription_subscription_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_retry_claim_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.cs b/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.cs new file mode 100644 index 00000000..fe5b41ca --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + /// The failed results and the retries, each in an index of its own, so the dashboard counts the + /// failures still to act on and finds the chains that keep failing without walking every + /// exchange. Both are a few percent of their table. Built without stopping writes to it. + /// + public partial class DashboardIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Outside the transaction, on what can be the largest tables there are: see ConcurrentIndex. + ConcurrentIndex.Create(migrationBuilder, "xchange_result", "ix_xchange_result_failed", + "(id) WHERE NOT success"); + + ConcurrentIndex.Create(migrationBuilder, "xchange", "ix_xchange_retries_started_on", + "(started_on) INCLUDE (id, retry_for) WHERE retry_for IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ConcurrentIndex.Drop(migrationBuilder, "ix_xchange_result_failed"); + + ConcurrentIndex.Drop(migrationBuilder, "ix_xchange_retries_started_on"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index f04b40d5..d074251a 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -2929,6 +2929,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SubscriptionId") .HasDatabaseName("ix_xchange_subscription_id"); + b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on") + .HasDatabaseName("ix_xchange_retries_started_on") + .HasFilter("retry_for IS NOT NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on"), new[] { "Id", "RetryFor" }); + b.ToTable("xchange", "infolink"); }); @@ -3165,6 +3172,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDatabaseName("ix_xchange_result_subscription_id_finished_on") .HasAnnotation("Npgsql:CreatedConcurrently", true); + b.HasIndex(new[] { "Id" }, "ix_xchange_result_failed") + .HasDatabaseName("ix_xchange_result_failed") + .HasFilter("NOT success") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + b.ToTable("xchange_result", "infolink"); }); diff --git a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts index f192974e..823bf477 100644 --- a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts @@ -19,6 +19,8 @@ test("dashboard loads with real aggregated data", async ({ page }) => { // which an unanchored match picks up as well. const tile = page.getByRole("link").filter({ hasText: /^Failures to act on/ }); await expect(tile).toBeVisible(); + // Counted apart from the rest of the page, so it can fill in after it. + await expect(tile).toContainText(/\d/, { timeout: 15000 }); const count = (await tile.innerText()).match(/([\d,]+)/)?.[1]; expect(count).toBeTruthy(); diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index c54c3605..e16c9404 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -39,6 +39,7 @@ import type { DataSourceTelemetry, DataSourceTestResult, DashboardData, + RetrySummary, ExchangeBreakdown, ExchangeQuery, ExchangeRow, @@ -825,6 +826,17 @@ export interface ApiClient { // - dashboard - getDashboard(): Promise; + // The counts over the exchange tables, which grow with traffic: asked for apart, so a slow one + // fills in on its own tile rather than holding up the page. + /** + * Failures that are the newest attempt of their chain. Counts problems rather than attempts: a + * chain retried nine times is one of these, not nine. Capped like the exchange search, so a value + * above 10,000 means "more than that". + */ + getFailuresToActOn(): Promise; + /** Exchanges started in the last 24 hours and not finished yet. */ + getInFlight(): Promise; + getRetrySummary(): Promise; // Metrics, read from the hourly rows the server keeps. getMetricsOverview(): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts index 9f7be4a0..11e9d835 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts @@ -1,5 +1,5 @@ import type { ApiClient } from "../client"; -import type { DashboardData, ExchangeStatus } from "../types"; +import type { DashboardData, ExchangeStatus, RetrySummary } from "../types"; import { subscriptionMethods } from "./subscriptions"; import { get } from "./request"; @@ -38,41 +38,35 @@ const toStatus = (raw: Pick): const LATEST = 6; +const startedSince = (ms: number) => encodeURIComponent(`StartedOn:6:${new Date(Date.now() - ms).toISOString()}`); + export const dashboardMethods = { /** * What the dashboard shows beside the metrics (which come from /metrics/overview): the latest - * failures, what is in flight, retries, queue alerts and subscriptions needing attention. + * failures, pending retries, queue alerts and subscriptions needing attention. * * Traffic, rates and the busiest used to be counted here, in the browser, from up to 1000 * exchanges fetched every minute: wrong at any volume above that, and a heavy query for every * open dashboard. They come from the server's hourly metrics now; what is left here is small, - * bounded queries over the last two weeks. + * bounded queries over the last two weeks. The counts over the exchange tables are asked for + * apart, below. */ async getDashboard(): Promise { - const now = Date.now(); - const since = (ms: number) => encodeURIComponent(`StartedOn:6:${new Date(now - ms).toISOString()}`); - const twoWeeks = since(14 * 86_400_000); + const twoWeeks = startedSince(14 * 86_400_000); const latest = (status: number) => get>( `/xchanges?filter=${twoWeeks}&filter=${encodeURIComponent(`StatusFilter:1:${status}`)}&sort=StartedOn:2&size=${LATEST}`, ); - const [failed, badResponses, inFlight, delayedRes, failuresRes, retrySummary, alertsRaw, subscriptionRows] = - await Promise.all([ - latest(3), - latest(2), - get>(`/xchanges?filter=${since(86_400_000)}&filter=${encodeURIComponent("StatusFilter:1:0")}&size=1`), - get>("/delayedretries?size=1"), - // Only the total is wanted: the problems still to deal with, which the tile's list opens. - get>( - `/xchanges?filter=${encodeURIComponent("StatusFilter:1:3")}&filter=${encodeURIComponent("LatestOnly:1:true")}&size=1`, - ), - get("/dashboard/retrysummary"), - // Depends on RabbitMQ management being configured on the backend - don't let it take the - // rest of the dashboard down when it isn't; the "Queue alerts" tile flags it instead. - get("/ops/alerts").catch(() => null), - subscriptionMethods.listSubscriptionRows(), - ]); + const [failed, badResponses, delayedRes, alertsRaw, subscriptionRows] = await Promise.all([ + latest(3), + latest(2), + get>("/delayedretries?size=1"), + // Depends on RabbitMQ management being configured on the backend - don't let it take the + // rest of the dashboard down when it isn't; the "Queue alerts" tile flags it instead. + get("/ops/alerts").catch(() => null), + subscriptionMethods.listSubscriptionRows(), + ]); const subscriptionNameById = new Map(subscriptionRows.map((i) => [i.id, i.name])); const latestFailures = [...failed.result, ...badResponses.result] @@ -89,10 +83,6 @@ export const dashboardMethods = { })); return { - inFlight: inFlight.totalCount, - failuresToActOn: failuresRes.totalCount, - failingChains: retrySummary.failingChains ?? [], - retriesLast7Days: retrySummary.retriesLast7Days ?? { finished: 0, succeeded: 0 }, pendingRetries: delayedRes.totalCount, queueAlerts: alertsRaw === null ? null : alertsRaw.filter((a) => a.severity !== "Info").length, latestFailures, @@ -104,4 +94,30 @@ export const dashboardMethods = { }, }; }, + + /** + * The problems still to deal with, which the tile's list opens. Counted by the server from the + * failures: the list fetches rows newest first, which for this filter can mean reading back + * through millions of exchanges first. + */ + async getFailuresToActOn(): Promise { + return (await get<{ total: number }>("/dashboard/failurestoacton")).total; + }, + + async getInFlight(): Promise { + const lastDay = startedSince(86_400_000); + return ( + await get>( + `/xchanges?filter=${lastDay}&filter=${encodeURIComponent("StatusFilter:1:0")}&size=1`, + ) + ).totalCount; + }, + + async getRetrySummary(): Promise { + const raw = await get("/dashboard/retrysummary"); + return { + failingChains: raw.failingChains ?? [], + retriesLast7Days: raw.retriesLast7Days ?? { finished: 0, succeeded: 0 }, + }; + }, } satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index 004830ba..8467396f 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -201,6 +201,10 @@ export const keys = { categories: ["subscription-categories"] as const, background: ["queue-health", "background"] as const, dashboard: ["dashboard"] as const, + // Under the dashboard's own key, so whatever refreshes the dashboard refreshes these too. + dashboardFailuresToActOn: ["dashboard", "failures-to-act-on"] as const, + dashboardInFlight: ["dashboard", "in-flight"] as const, + dashboardRetrySummary: ["dashboard", "retry-summary"] as const, metrics: { all: ["metrics"] as const, overview: ["metrics", "overview"] as const, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 04528063..ca6077b7 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1837,8 +1837,12 @@ export interface FailingChain { exception: string | null; } -export interface DashboardData { - /** Worst first. Empty when nothing has been retried more than once and left failing. */ +/** The dashboard's retry panel. */ +export interface RetrySummary { + /** + * Worst first, among chains whose newest attempt started in the last 7 days. Empty when nothing + * has been retried more than once and left failing. + */ failingChains: FailingChain[]; /** * Retries started in the last 7 days: how many have finished, and how many of those worked. @@ -1846,14 +1850,9 @@ export interface DashboardData { * luck or a configuration problem. */ retriesLast7Days: { finished: number; succeeded: number }; - /** - * Failures that are the newest attempt of their chain. Counts problems rather than attempts: a - * chain retried nine times is one of these, not nine. Subject to the search's count cap, so a - * value above it means "at least this many". - */ - failuresToActOn: number; - /** Exchanges started in the last 24 hours and not finished yet. */ - inFlight: number; +} + +export interface DashboardData { pendingRetries: number; /** Null when the RabbitMQ management API is unavailable - never counts as zero. */ queueAlerts: number | null; diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index dc7055e6..dc7d8739 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -116,6 +116,27 @@ export function DashboardPage() { placeholderData: keepPreviousData, }); const insights = useQuery({ queryKey: keys.metrics.insights, queryFn: () => api.getInsights() }); + // Counted over the exchange tables, so asked for apart: a slow one fills in on its own tile + // rather than holding up the page. Not retried - the next minute's refresh asks again, rather than + // three retries piling onto a database already struggling with it. + const failuresToActOn = useQuery({ + queryKey: keys.dashboardFailuresToActOn, + queryFn: () => api.getFailuresToActOn(), + refetchInterval: 60_000, + retry: false, + }); + const inFlight = useQuery({ + queryKey: keys.dashboardInFlight, + queryFn: () => api.getInFlight(), + refetchInterval: 60_000, + retry: false, + }); + const retrySummary = useQuery({ + queryKey: keys.dashboardRetrySummary, + queryFn: () => api.getRetrySummary(), + refetchInterval: 60_000, + retry: false, + }); const data = dashboard.data; const o = metrics.data; @@ -172,7 +193,7 @@ export function DashboardPage() { <> {delta >= 0 ? "+" : ""} {compact(delta)} vs the day before - {data.inFlight > 0 && <> · {compact(data.inFlight)} in flight} + {inFlight.data !== undefined && inFlight.data > 0 && <> · {compact(inFlight.data)} in flight} } to="/exchanges" @@ -205,11 +226,25 @@ export function DashboardPage() { is the current state of anything. This counts the ends of chains: the problems. */} 10_000 ? "10,000+" : whole(data.failuresToActOn)} - sub={data.failuresToActOn > 0 ? "newest attempt of each chain" : "every failure was retried"} + // The count stops past its cap and reports the cap + 1. + value={ + failuresToActOn.data === undefined + ? "-" + : failuresToActOn.data > 10_000 + ? "10,000+" + : whole(failuresToActOn.data) + } + sub={ + failuresToActOn.data === undefined + ? failuresToActOn.isError + ? "unavailable right now" + : "counting…" + : failuresToActOn.data > 0 + ? "newest attempt of each chain" + : "every failure was retried" + } to="/exchanges?status=failed&latest=1" - accent={data.failuresToActOn > 0 ? "danger" : undefined} + accent={failuresToActOn.data !== undefined && failuresToActOn.data > 0 ? "danger" : undefined} title="Failures that nothing has been retried from yet. A chain of nine failed attempts counts once - as the attempt at the end of it, which is the one still to deal with." /> - {data.failingChains.length === 0 ? ( + {!retrySummary.data ? ( + retrySummary.isError ? ( + + The retry chains couldn't be read in time. The dashboard tries again every minute. + + ) : ( + + ) + ) : retrySummary.data.failingChains.length === 0 ? ( Nothing has been retried more than once and left failing. ) : (
      - {data.failingChains.map((c) => ( + {retrySummary.data.failingChains.map((c) => (
    • [], metrics = m http.get(apiPath("/dashboard/retrysummary"), () => HttpResponse.json({ retriesLast7Days: { finished: 0, succeeded: 0 }, failingChains: [] }), ), + http.get(apiPath("/dashboard/failurestoacton"), () => HttpResponse.json({ total: 0 })), http.get(apiPath("/ops/alerts"), () => HttpResponse.json([])), http.get(apiPath("/subscriptions"), () => HttpResponse.json({ result: subscriptions, totalCount: subscriptions.length }), @@ -123,6 +124,44 @@ describe("the dashboard", () => { expect(screen.queryByRole("heading", { name: "Getting started" })).not.toBeInTheDocument(); }); + it("shows a count that couldn't be read in time on its own tile, and the rest of the dashboard anyway", async () => { + renderApp("/dashboard", { + handlers: [ + // A database timeout reaches the browser as a 500. + http.get(apiPath("/dashboard/failurestoacton"), () => new HttpResponse(null, { status: 500 })), + http.get(apiPath("/dashboard/retrysummary"), () => new HttpResponse(null, { status: 500 })), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const tile = (await screen.findByText("Failures to act on")).closest("a")!; + expect(within(tile).getByText("-")).toBeVisible(); + expect(within(tile).getByText("unavailable right now")).toBeVisible(); + + const chains = within(screen.getByRole("heading", { name: "Chains that keep failing" }).closest("section")!); + expect(chains.getByText("Unavailable right now")).toBeVisible(); + + const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); + expect(health.getByText("Health page 1", { exact: true })).toBeVisible(); + }); + + it("doesn't wait for a slow count before showing the rest", async () => { + renderApp("/dashboard", { + handlers: [ + http.get(apiPath("/dashboard/failurestoacton"), async () => { + await delay("infinite"); + return HttpResponse.json({ total: 0 }); + }), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const health = within((await screen.findByRole("heading", { name: "Subscription health" })).closest("section")!); + expect(health.getByText("Health page 1", { exact: true })).toBeVisible(); + const tile = screen.getByText("Failures to act on").closest("a")!; + expect(within(tile).getByText("counting…")).toBeVisible(); + }); + it("reads its numbers from the metrics, and names subscriptions that have gone quiet", async () => { const metrics = overview({ since: "2026-09-01T00:00:00Z", diff --git a/docs/api-reference.md b/docs/api-reference.md index 054dc612..2a788078 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -389,5 +389,6 @@ See [Data sources](data-sources.md) and [Databases](databases.md). | `GET /api/dashboard/MainInfo` | `dashboard.view` | | `GET /api/dashboard/ChartsDataPoints` | `dashboard.view` | | `GET /api/dashboard/XChangesAndSubscriptionsInfo` | `dashboard.view` | -| `GET /api/dashboard/retrysummary` | `dashboard.view`. Retries finished in the last 7 days, and the five failing chains with the most attempts. | +| `GET /api/dashboard/retrysummary` | `dashboard.view`. Retries finished in the last 7 days, and the five failing chains with the most attempts among those retried in the same 7 days. | +| `GET /api/dashboard/failurestoacton` | `dashboard.view`. `{ total }`: failed exchanges nothing has been retried from, the count the exchange search gives for `StatusFilter:1:3` with `LatestOnly:1:true`. Capped like the search: past 10,000 it reports 10,001. | | `GET /health` | none | diff --git a/docs/operations.md b/docs/operations.md index d3c683a5..26cb7032 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -71,7 +71,7 @@ See [Scheduling](scheduling.md). The dashboard, opened from the logo, shows today's exchanges, the 7-day success rate, today's failures, waiting retries, queue alerts, a 14-day chart, the latest failures, unhealthy subscriptions and the busiest subscriptions. The browser computes it from up to 1,000 recent exchanges, so figures on a busy instance are approximate. -Two parts come from the server instead. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts, with the success rate of retries over the last 7 days. +Two parts come from the server instead. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. A part that can't be read in time shows as unavailable while the rest of the dashboard loads. ## Audit trail From 9a0d91f6006023c8a463cba3618c8bdcc3f87eb3 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 1 Nov 2026 16:48:12 +0300 Subject: [PATCH 07/24] fix: dashboard timeouts review findings --- SW.Bitween.PgSql/ConcurrentIndex.cs | 14 +++++++++++--- .../src/pages/dashboard/DashboardPage.tsx | 1 + .../dashboard/__tests__/DashboardPage.test.tsx | 13 +++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/SW.Bitween.PgSql/ConcurrentIndex.cs b/SW.Bitween.PgSql/ConcurrentIndex.cs index 2ac6eab3..e46995a7 100644 --- a/SW.Bitween.PgSql/ConcurrentIndex.cs +++ b/SW.Bitween.PgSql/ConcurrentIndex.cs @@ -10,14 +10,20 @@ namespace SW.Bitween.PgSql; /// table of tens of millions of rows, inside the startup migration. CONCURRENTLY doesn't, but it /// cannot run in a transaction, and a build that fails part-way leaves the index behind marked /// invalid: the IF NOT EXISTS would then skip the rebuild and the migration would pass with an -/// index Postgres never uses. So an invalid leftover is dropped first, as -/// PromotedPropertiesTrigramIndex does. +/// index Postgres never uses. So an invalid leftover goes first. +/// +/// Not with a plain DROP INDEX, which locks the whole table: queued behind whatever is reading it, +/// it holds up every read and insert that comes after. The leftover is renamed out of the way +/// instead - a lock reads and writes don't wait for - and dropped concurrently, which a DO block +/// can't do. A valid index of the name is left as it is. /// internal static class ConcurrentIndex { /// What follows ON infolink.table: the columns, and any WHERE. public static void Create(MigrationBuilder migrationBuilder, string table, string name, string definition) { + var leftover = name + "_invalid"; + migrationBuilder.Sql( $@"DO $$ BEGIN @@ -28,10 +34,12 @@ FROM pg_index i JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '{BitweenDbContext.Schema}' AND c.relname = '{name}' AND NOT i.indisvalid ) THEN - DROP INDEX {BitweenDbContext.Schema}.{name}; + ALTER INDEX {BitweenDbContext.Schema}.{name} RENAME TO {leftover}; END IF; END $$;"); + Drop(migrationBuilder, leftover); + migrationBuilder.Sql( $"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} ON {BitweenDbContext.Schema}.{table} {definition};", suppressTransaction: true); diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index dc7d8739..5c098773 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -194,6 +194,7 @@ export function DashboardPage() { {delta >= 0 ? "+" : ""} {compact(delta)} vs the day before {inFlight.data !== undefined && inFlight.data > 0 && <> · {compact(inFlight.data)} in flight} + {inFlight.isError && inFlight.data === undefined && <> · in flight unavailable} } to="/exchanges" diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx index 63b84093..e2cff861 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx @@ -130,16 +130,25 @@ describe("the dashboard", () => { // A database timeout reaches the browser as a 500. http.get(apiPath("/dashboard/failurestoacton"), () => new HttpResponse(null, { status: 500 })), http.get(apiPath("/dashboard/retrysummary"), () => new HttpResponse(null, { status: 500 })), + http.get(apiPath("/xchanges"), ({ request }) => + new URL(request.url).searchParams.getAll("filter").includes("StatusFilter:1:0") + ? new HttpResponse(null, { status: 500 }) + : HttpResponse.json(none), + ), ...dashboard([subscription(0, { failures: 2 })]), ], }); const tile = (await screen.findByText("Failures to act on")).closest("a")!; + expect(await within(tile).findByText("unavailable right now")).toBeVisible(); expect(within(tile).getByText("-")).toBeVisible(); - expect(within(tile).getByText("unavailable right now")).toBeVisible(); + + // Not left looking like nothing is in flight. + const exchanges = screen.getByText("Exchanges, last 24 hours").closest("a")!; + expect(await within(exchanges).findByText(/in flight unavailable/)).toBeVisible(); const chains = within(screen.getByRole("heading", { name: "Chains that keep failing" }).closest("section")!); - expect(chains.getByText("Unavailable right now")).toBeVisible(); + expect(await chains.findByText("Unavailable right now")).toBeVisible(); const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); expect(health.getByText("Health page 1", { exact: true })).toBeVisible(); From 029b0dfd37670351fe08285e32610917ff0cc3bc Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 11:51:31 +0300 Subject: [PATCH 08/24] fix: dashboard opens at once and fills in part by part --- .../ClientApp/e2e/dashboard.spec.ts | 2 + SW.Bitween.Web/ClientApp/src/api/client.ts | 13 +- .../ClientApp/src/api/http/dashboard.ts | 60 ++-- SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 3 + SW.Bitween.Web/ClientApp/src/api/types.ts | 25 +- .../src/pages/dashboard/DashboardPage.tsx | 292 +++++++++++------- .../__tests__/DashboardPage.test.tsx | 52 +++- docs/operations.md | 4 +- 8 files changed, 262 insertions(+), 189 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts index 823bf477..c9f8731a 100644 --- a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts @@ -10,6 +10,8 @@ test("dashboard loads with real aggregated data", async ({ page }) => { await expect(page.getByText("Success rate, 7 days")).toBeVisible(); await expect(page.getByText("Traffic, last 48 hours")).toBeVisible(); await expect(page.getByText("Housekeeping")).toBeVisible(); + // The page opens before its numbers arrive; check them once they have. + await expect(page.getByText("loading…")).toHaveCount(0, { timeout: 15000 }); await expect(page.getByText("undefined")).toHaveCount(0); await expect(page.getByText("NaN")).toHaveCount(0); diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index e16c9404..4788cf76 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -38,7 +38,7 @@ import type { DataSourceStatementUsage, DataSourceTelemetry, DataSourceTestResult, - DashboardData, + LatestFailure, RetrySummary, ExchangeBreakdown, ExchangeQuery, @@ -825,9 +825,14 @@ export interface ApiClient { requeueDeadLetters(queueName: string): Promise; // - dashboard - - getDashboard(): Promise; - // The counts over the exchange tables, which grow with traffic: asked for apart, so a slow one - // fills in on its own tile rather than holding up the page. + // One call per part, so a slow one fills in on its own tile rather than holding up the page. + /** The newest failed or badly answered exchanges of the last two weeks, at most six, newest first. */ + getLatestFailures(): Promise; + /** Automatic retries waiting for their slot. */ + getPendingRetries(): Promise; + /** Queue alerts worth acting on. Null when the RabbitMQ management API is unavailable - never counts as zero. */ + getQueueAlerts(): Promise; + // The counts over the exchange tables, which grow with traffic. /** * Failures that are the newest attempt of their chain. Counts problems rather than attempts: a * chain retried nine times is one of these, not nine. Capped like the exchange search, so a value diff --git a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts index 11e9d835..2698bab4 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts @@ -1,6 +1,5 @@ import type { ApiClient } from "../client"; -import type { DashboardData, ExchangeStatus, RetrySummary } from "../types"; -import { subscriptionMethods } from "./subscriptions"; +import type { ExchangeStatus, LatestFailure, RetrySummary } from "../types"; import { get } from "./request"; // --- backend shapes (camelCase over the wire) --- @@ -40,59 +39,46 @@ const LATEST = 6; const startedSince = (ms: number) => encodeURIComponent(`StartedOn:6:${new Date(Date.now() - ms).toISOString()}`); +/** + * What the dashboard shows beside the metrics (which come from /metrics/overview) and the + * subscription rows, one method per part so each can fill in on its own. + * + * Traffic, rates and the busiest used to be counted here, in the browser, from up to 1000 + * exchanges fetched every minute: wrong at any volume above that, and a heavy query for every + * open dashboard. They come from the server's hourly metrics now. + */ export const dashboardMethods = { - /** - * What the dashboard shows beside the metrics (which come from /metrics/overview): the latest - * failures, pending retries, queue alerts and subscriptions needing attention. - * - * Traffic, rates and the busiest used to be counted here, in the browser, from up to 1000 - * exchanges fetched every minute: wrong at any volume above that, and a heavy query for every - * open dashboard. They come from the server's hourly metrics now; what is left here is small, - * bounded queries over the last two weeks. The counts over the exchange tables are asked for - * apart, below. - */ - async getDashboard(): Promise { + /** Two searches over the exchanges of the last two weeks: on a busy instance, seconds. */ + async getLatestFailures(): Promise { const twoWeeks = startedSince(14 * 86_400_000); const latest = (status: number) => get>( `/xchanges?filter=${twoWeeks}&filter=${encodeURIComponent(`StatusFilter:1:${status}`)}&sort=StartedOn:2&size=${LATEST}`, ); - const [failed, badResponses, delayedRes, alertsRaw, subscriptionRows] = await Promise.all([ - latest(3), - latest(2), - get>("/delayedretries?size=1"), - // Depends on RabbitMQ management being configured on the backend - don't let it take the - // rest of the dashboard down when it isn't; the "Queue alerts" tile flags it instead. - get("/ops/alerts").catch(() => null), - subscriptionMethods.listSubscriptionRows(), - ]); - - const subscriptionNameById = new Map(subscriptionRows.map((i) => [i.id, i.name])); - const latestFailures = [...failed.result, ...badResponses.result] + const [failed, badResponses] = await Promise.all([latest(3), latest(2)]); + return [...failed.result, ...badResponses.result] .sort((a, b) => b.startedOn.localeCompare(a.startedOn)) .slice(0, LATEST) .map((r) => ({ id: r.id, status: toStatus(r), subscriptionId: r.subscriptionId, - subscriptionName: r.subscriptionId !== null ? (subscriptionNameById.get(r.subscriptionId) ?? null) : null, informationTypeCode: r.documentName, on: r.startedOn, exception: r.exception, })); + }, - return { - pendingRetries: delayedRes.totalCount, - queueAlerts: alertsRaw === null ? null : alertsRaw.filter((a) => a.severity !== "Info").length, - latestFailures, - attention: { - failingSubscriptions: subscriptionRows - .filter((i) => i.consecutiveFailures > 0) - .map((i) => ({ id: i.id, name: i.name, consecutiveFailures: i.consecutiveFailures })), - pausedSubscriptions: subscriptionRows.filter((i) => i.paused).map((i) => ({ id: i.id, name: i.name })), - }, - }; + async getPendingRetries(): Promise { + return (await get>("/delayedretries?size=1")).totalCount; + }, + + async getQueueAlerts(): Promise { + // Depends on RabbitMQ management being configured on the backend; the "Queue alerts" tile + // flags it when it isn't. + const alerts = await get("/ops/alerts").catch(() => null); + return alerts === null ? null : alerts.filter((a) => a.severity !== "Info").length; }, /** diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index 8467396f..13bf4b30 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -202,6 +202,9 @@ export const keys = { background: ["queue-health", "background"] as const, dashboard: ["dashboard"] as const, // Under the dashboard's own key, so whatever refreshes the dashboard refreshes these too. + dashboardLatestFailures: ["dashboard", "latest-failures"] as const, + dashboardPendingRetries: ["dashboard", "pending-retries"] as const, + dashboardQueueAlerts: ["dashboard", "queue-alerts"] as const, dashboardFailuresToActOn: ["dashboard", "failures-to-act-on"] as const, dashboardInFlight: ["dashboard", "in-flight"] as const, dashboardRetrySummary: ["dashboard", "retry-summary"] as const, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index ca6077b7..c1acc69d 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1852,23 +1852,14 @@ export interface RetrySummary { retriesLast7Days: { finished: number; succeeded: number }; } -export interface DashboardData { - pendingRetries: number; - /** Null when the RabbitMQ management API is unavailable - never counts as zero. */ - queueAlerts: number | null; - latestFailures: { - id: string; - status: ExchangeStatus; - subscriptionId: number | null; - subscriptionName: string | null; - informationTypeCode: string; - on: string; - exception: string | null; - }[]; - attention: { - failingSubscriptions: { id: number; name: string; consecutiveFailures: number }[]; - pausedSubscriptions: { id: number; name: string }[]; - }; +/** One of the dashboard's latest failures. */ +export interface LatestFailure { + id: string; + status: ExchangeStatus; + subscriptionId: number | null; + informationTypeCode: string; + on: string; + exception: string | null; } /** diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index 5c098773..046aba7a 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -47,15 +47,23 @@ function StatTile({ to, accent, title, + failed, }: { label: string; + /** Undefined until its numbers arrive. */ value: ReactNode; sub?: ReactNode; to: string; accent?: "danger" | "warn"; /** For a number whose meaning its label cannot carry on its own. */ title?: string; + /** Its numbers couldn't be read: says so instead of loading forever. */ + failed?: boolean; }) { + if (value === undefined) { + value = failed ? "-" : ; + sub = failed ? "unavailable right now" : "loading…"; + } return ( + This couldn't be read in time. The dashboard tries again every minute. + + ) : ( + + ); +} + /** Every hour in the window, the empty ones as zero: a gap in the chart is an hour with nothing. */ function hours(overview: MetricsOverview) { const byHour = new Map(overview.hourly.map((h) => [Date.parse(h.hour), h])); @@ -103,12 +122,8 @@ export function DashboardPage() { const rabbitMqConfigured = useRabbitMqManagementConfigured(); const [healthOffset, setHealthOffset] = useState(0); const canSeeAdapters = useSessionCan("subscriptions.view"); - const dashboard = useQuery({ - queryKey: keys.dashboard, - queryFn: () => api.getDashboard(), - refetchInterval: 60_000, - placeholderData: keepPreviousData, - }); + // Each part is asked for on its own and fills in when it arrives: the page opens at once, and a + // slow part keeps only its own tile waiting. const metrics = useQuery({ queryKey: keys.metrics.overview, queryFn: () => api.getMetricsOverview(), @@ -116,9 +131,30 @@ export function DashboardPage() { placeholderData: keepPreviousData, }); const insights = useQuery({ queryKey: keys.metrics.insights, queryFn: () => api.getInsights() }); - // Counted over the exchange tables, so asked for apart: a slow one fills in on its own tile - // rather than holding up the page. Not retried - the next minute's refresh asks again, rather than - // three retries piling onto a database already struggling with it. + const subscriptions = useQuery({ + queryKey: keys.subscriptions.rows, + queryFn: () => api.listSubscriptionRows(), + refetchInterval: 60_000, + }); + const pendingRetries = useQuery({ + queryKey: keys.dashboardPendingRetries, + queryFn: () => api.getPendingRetries(), + refetchInterval: 60_000, + }); + const queueAlerts = useQuery({ + queryKey: keys.dashboardQueueAlerts, + queryFn: () => api.getQueueAlerts(), + refetchInterval: 60_000, + }); + // Read from the exchange tables, which grow with traffic: the parts most likely to be slow. Not + // retried - the next minute's refresh asks again, rather than three retries piling onto a + // database already struggling with it. + const latestFailures = useQuery({ + queryKey: keys.dashboardLatestFailures, + queryFn: () => api.getLatestFailures(), + refetchInterval: 60_000, + retry: false, + }); const failuresToActOn = useQuery({ queryKey: keys.dashboardFailuresToActOn, queryFn: () => api.getFailuresToActOn(), @@ -138,39 +174,41 @@ export function DashboardPage() { retry: false, }); - const data = dashboard.data; + // Each undefined until its numbers arrive. const o = metrics.data; - if ((dashboard.isError && !data) || (metrics.isError && !o)) - return ( - - Something went wrong fetching dashboard data. It'll keep retrying in the background - try refreshing the page. - - ); - if (!data || !o) return ; + const delta = o && o.last24h.total - o.previous24h.total; + const failed24 = o && o.last24h.failed + o.last24h.badResponse; + const perHour = o && hours(o); + const days = o?.daily.map((d) => ({ + at: d.day, + values: { succeeded: d.succeeded, badResponse: d.badResponse, failed: d.failed }, + })); + const moved30 = o?.daily.reduce((n, d) => n + d.bytes, 0); + const since = o?.since && Date.now() - Date.parse(o.since) < 30 * 86_400_000 ? o.since : null; + const waitingForMetrics = ; - const queueAlerts = rabbitMqConfigured ? data.queueAlerts : null; + // Null when it can't be counted, which never shows as zero. + const alerts = rabbitMqConfigured ? queueAlerts.data : null; const queueAlertsSub = !rabbitMqConfigured ? "needs RabbitMQ management configured" - : queueAlerts === null + : alerts === null ? "unavailable right now" : "live consumer health"; - const delta = o.last24h.total - o.previous24h.total; - const failed24 = o.last24h.failed + o.last24h.badResponse; - const perHour = hours(o); - const days = o.daily.map((d) => ({ - at: d.day, - values: { succeeded: d.succeeded, badResponse: d.badResponse, failed: d.failed }, - })); - const moved30 = o.daily.reduce((n, d) => n + d.bytes, 0); - const sinceRecent = o.since && Date.now() - Date.parse(o.since) < 30 * 86_400_000; + const pending = pendingRetries.data; + const toActOn = failuresToActOn.data; - const unhealthy = [ - ...data.attention.failingSubscriptions.map((s) => ({ ...s, paused: false })), - ...data.attention.pausedSubscriptions.map((s) => ({ ...s, consecutiveFailures: 0, paused: true })), + const subscriptionNames = new Map(subscriptions.data?.map((s) => [s.id, s.name])); + const latest = latestFailures.data?.map((f) => ({ + ...f, + subscriptionName: f.subscriptionId === null ? null : (subscriptionNames.get(f.subscriptionId) ?? null), + })); + const unhealthy = subscriptions.data && [ + ...subscriptions.data.filter((s) => s.consecutiveFailures > 0).map((s) => ({ ...s, paused: false })), + ...subscriptions.data.filter((s) => s.paused).map((s) => ({ ...s, consecutiveFailures: 0, paused: true })), ]; // The list can shrink on a refetch; don't leave the page pointing past its end. Reset the stored // offset too, or a later refetch that grows the list again jumps back to the old page. - const healthStart = healthOffset < unhealthy.length ? healthOffset : 0; + const healthStart = unhealthy && healthOffset < unhealthy.length ? healthOffset : 0; if (healthStart !== healthOffset) setHealthOffset(0); return ( @@ -178,116 +216,122 @@ export function DashboardPage() { - d.total > 0) || o.last24h.total > 0} /> + {o && d.total > 0) || o.last24h.total > 0} />}
      - {delta >= 0 ? "+" : ""} - {compact(delta)} vs the day before - {inFlight.data !== undefined && inFlight.data > 0 && <> · {compact(inFlight.data)} in flight} - {inFlight.isError && inFlight.data === undefined && <> · in flight unavailable} - + delta !== undefined && ( + <> + {delta >= 0 ? "+" : ""} + {compact(delta)} vs the day before + {inFlight.data !== undefined && inFlight.data > 0 && <> · {compact(inFlight.data)} in flight} + {inFlight.isError && inFlight.data === undefined && <> · in flight unavailable} + + ) } to="/exchanges" + failed={metrics.isError} /> 0 ? `${compact(o.last24h.badResponse)} bad responses` : "nothing failed"} + value={failed24 === undefined ? undefined : compact(failed24)} + sub={o && (failed24 ? `${compact(o.last24h.badResponse)} bad responses` : "nothing failed")} to="/exchanges?status=failed" - accent={failed24 > 0 ? "danger" : undefined} + accent={failed24 ? "danger" : undefined} + failed={metrics.isError} /> 0 ? `of ${compact(o.last7d.total)} exchanges` : "nothing finished yet"} + value={o && percent(o.last7d.successRate)} + sub={o && (o.last7d.total > 0 ? `of ${compact(o.last7d.total)} exchanges` : "nothing finished yet")} to="/exchanges?status=success" + failed={metrics.isError} /> {/* "Failed" counts attempts, and a chain retried nine times is nine of them - none of which is the current state of anything. This counts the ends of chains: the problems. */} 10_000 - ? "10,000+" - : whole(failuresToActOn.data) - } - sub={ - failuresToActOn.data === undefined - ? failuresToActOn.isError - ? "unavailable right now" - : "counting…" - : failuresToActOn.data > 0 - ? "newest attempt of each chain" - : "every failure was retried" - } + value={toActOn === undefined ? undefined : toActOn > 10_000 ? "10,000+" : whole(toActOn)} + sub={toActOn ? "newest attempt of each chain" : "every failure was retried"} to="/exchanges?status=failed&latest=1" - accent={failuresToActOn.data !== undefined && failuresToActOn.data > 0 ? "danger" : undefined} + accent={toActOn ? "danger" : undefined} title="Failures that nothing has been retried from yet. A chain of nine failed attempts counts once - as the attempt at the end of it, which is the one still to deal with." + failed={failuresToActOn.isError} /> 0 ? "waiting for their slot" : "queue is clear"} + value={pending === undefined ? undefined : whole(pending)} + sub={pending ? "waiting for their slot" : "queue is clear"} to="/scheduled-retries" - accent={data.pendingRetries > 0 ? "warn" : undefined} + accent={pending ? "warn" : undefined} + failed={pendingRetries.isError} /> 0 ? "warn" : undefined} + accent={alerts ? "warn" : undefined} /> 0 ? "usually busy, now silent" : "every busy subscription is busy"} + value={o?.silent.length} + sub={o && (o.silent.length > 0 ? "usually busy, now silent" : "every busy subscription is busy")} to="/subscriptions?health=silent" - accent={o.silent.length > 0 ? "warn" : undefined} + accent={o?.silent.length ? "warn" : undefined} title="Subscriptions that normally have traffic and have had none for well over their usual gap. Nothing fails when a partner stops sending, so nothing else would say so." + failed={metrics.isError} />
      - p.weekAgo) }} - label={(at) => hourLabel.format(new Date(at))} - tick={(at) => hourTick.format(new Date(at))} - every={8} - /> + {!perHour ? ( + waitingForMetrics + ) : ( + p.weekAgo) }} + label={(at) => hourLabel.format(new Date(at))} + tick={(at) => hourTick.format(new Date(at))} + every={8} + /> + )} - {days.length === 0 ? ( + {!days ? ( + waitingForMetrics + ) : days.length === 0 ? ( Once exchanges flow, each day's volume shows here. ) : ( - {o.silent.length === 0 ? ( + {!o ? ( + waitingForMetrics + ) : o.silent.length === 0 ? ( } title="Nothing has gone quiet"> Every subscription with regular traffic still has it. @@ -331,7 +377,9 @@ export function DashboardPage() { - {unhealthy.length === 0 ? ( + {!unhealthy ? ( + + ) : unhealthy.length === 0 ? ( No failures piling up, nothing paused. ) : ( <> @@ -386,11 +434,13 @@ export function DashboardPage() {
      - {data.latestFailures.length === 0 ? ( + {!latest ? ( + + ) : latest.length === 0 ? ( When an exchange fails, it shows up here first. ) : (
        - {data.latestFailures.map((f) => ( + {latest.map((f) => (
      • @@ -429,13 +479,7 @@ export function DashboardPage() { } > {!retrySummary.data ? ( - retrySummary.isError ? ( - - The retry chains couldn't be read in time. The dashboard tries again every minute. - - ) : ( - - ) + ) : retrySummary.data.failingChains.length === 0 ? ( Nothing has been retried more than once and left failing. ) : ( @@ -482,32 +526,42 @@ export function DashboardPage() {
        - ({ - key: s.subscriptionId, - // Counted while it existed; deleted since, so there is nothing to open. - name: s.name ?? `Deleted subscription #${s.subscriptionId}`, - to: s.name ? subscriptionPath(s.subscriptionId) : null, - ok: s.total - s.failed - s.badResponse, - bad: s.failed + s.badResponse, - }))} - empty="Once exchanges flow, the busiest pipelines rank here." - /> + {!o ? ( + waitingForMetrics + ) : ( + ({ + key: s.subscriptionId, + // Counted while it existed; deleted since, so there is nothing to open. + name: s.name ?? `Deleted subscription #${s.subscriptionId}`, + to: s.name ? subscriptionPath(s.subscriptionId) : null, + ok: s.total - s.failed - s.badResponse, + bad: s.failed + s.badResponse, + }))} + empty="Once exchanges flow, the busiest pipelines rank here." + /> + )} - ({ - key: p.partnerId, - name: p.name ?? `Deleted partner #${p.partnerId}`, - to: p.name ? `/partners/${p.partnerId}` : null, - ok: p.total - p.failed, - bad: p.failed, - }))} - empty="No partner has sent or received anything today." - /> + {!o ? ( + waitingForMetrics + ) : ( + ({ + key: p.partnerId, + name: p.name ?? `Deleted partner #${p.partnerId}`, + to: p.name ? `/partners/${p.partnerId}` : null, + ok: p.total - p.failed, + bad: p.failed, + }))} + empty="No partner has sent or received anything today." + /> + )} - {o.slowestAdapters.length === 0 ? ( + {!o ? ( + waitingForMetrics + ) : o.slowestAdapters.length === 0 ? ( Adapters with five or more calls in a day rank here. ) : (
          @@ -534,7 +588,7 @@ export function DashboardPage() {
        - {o.backlog.length > 1 && ( + {o && o.backlog.length > 1 && ( { ...dashboard([subscription(0)]), ], }); - expect(await screen.findByRole("heading", { name: "Subscription health" })).toBeVisible(); + expect(await screen.findByText("All subscriptions healthy")).toBeVisible(); + // The card waits for the metrics, which say whether anything has moved yet. + expect(await screen.findByText(/moved in and out over the month/)).toBeVisible(); expect(screen.queryByRole("heading", { name: "Getting started" })).not.toBeInTheDocument(); }); @@ -151,24 +153,52 @@ describe("the dashboard", () => { expect(await chains.findByText("Unavailable right now")).toBeVisible(); const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); - expect(health.getByText("Health page 1", { exact: true })).toBeVisible(); + expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); }); - it("doesn't wait for a slow count before showing the rest", async () => { + it("keeps the rest of the page when the metrics can't be read", async () => { renderApp("/dashboard", { handlers: [ - http.get(apiPath("/dashboard/failurestoacton"), async () => { - await delay("infinite"); - return HttpResponse.json({ total: 0 }); - }), + http.get(apiPath("/metrics/overview"), () => new HttpResponse(null, { status: 500 })), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const exchanges = (await screen.findByText("Exchanges, last 24 hours")).closest("a")!; + expect(await within(exchanges).findByText("unavailable right now")).toBeVisible(); + const traffic = within(screen.getByRole("heading", { name: "Traffic, last 48 hours" }).closest("section")!); + expect(traffic.getByText("Unavailable right now")).toBeVisible(); + + const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); + expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); + }); + + it("opens at once, and each part fills in when it arrives", async () => { + const never = async () => { + await delay("infinite"); + return HttpResponse.json({}); + }; + renderApp("/dashboard", { + handlers: [ + http.get(apiPath("/metrics/overview"), never), + http.get(apiPath("/dashboard/failurestoacton"), never), + // The latest failures; the in-flight count answers. + http.get(apiPath("/xchanges"), ({ request }) => + new URL(request.url).searchParams.getAll("filter").includes("StatusFilter:1:0") ? HttpResponse.json(none) : never(), + ), ...dashboard([subscription(0, { failures: 2 })]), ], }); const health = within((await screen.findByRole("heading", { name: "Subscription health" })).closest("section")!); - expect(health.getByText("Health page 1", { exact: true })).toBeVisible(); - const tile = screen.getByText("Failures to act on").closest("a")!; - expect(within(tile).getByText("counting…")).toBeVisible(); + expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); + const retries = screen.getByText("Pending auto-retries").closest("a")!; + expect(await within(retries).findByText("queue is clear")).toBeVisible(); + + for (const label of ["Failures to act on", "Exchanges, last 24 hours", "Gone quiet"]) + expect(within(screen.getAllByText(label)[0].closest("a")!).getByText("loading…")).toBeVisible(); + const latest = within(screen.getByRole("heading", { name: "Latest failures" }).closest("section")!); + expect(latest.getByText("Finding the latest failures…")).toBeVisible(); }); it("reads its numbers from the metrics, and names subscriptions that have gone quiet", async () => { @@ -208,7 +238,7 @@ describe("the dashboard", () => { }); const exchanges = (await screen.findByText("Exchanges, last 24 hours")).closest("a")!; - expect(within(exchanges).getByText("12K")).toBeVisible(); + expect(await within(exchanges).findByText("12K")).toBeVisible(); expect(within(exchanges).getByText(/\+1.4K vs the day before/)).toBeVisible(); expect(within(screen.getByText("Success rate, 7 days").closest("a")!).getByText("98.5%")).toBeVisible(); expect(within(screen.getByText("End-to-end time, last 24 hours").closest("a")!).getByText("2.4s")).toBeVisible(); diff --git a/docs/operations.md b/docs/operations.md index 26cb7032..fa19bee4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -71,7 +71,9 @@ See [Scheduling](scheduling.md). The dashboard, opened from the logo, shows today's exchanges, the 7-day success rate, today's failures, waiting retries, queue alerts, a 14-day chart, the latest failures, unhealthy subscriptions and the busiest subscriptions. The browser computes it from up to 1,000 recent exchanges, so figures on a busy instance are approximate. -Two parts come from the server instead. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. A part that can't be read in time shows as unavailable while the rest of the dashboard loads. +Two parts come from the server instead. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. + +The page opens at once, and each tile and panel fills in when its own numbers arrive, so a slow one doesn't hold up the rest. One that can't be read in time shows as unavailable, and the dashboard asks again every minute. ## Audit trail From 2589031b897cf9742895910e0735fbad8c92795a Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 11:52:00 +0300 Subject: [PATCH 09/24] docs: bring the dashboard description up to date --- docs/caveats.md | 1 - docs/operations.md | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/caveats.md b/docs/caveats.md index 1ed5c02f..de7d8c2d 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -26,7 +26,6 @@ These behaviours were found while documenting Bitween from its source code, and | Mappers other than the rules-based mapper overwrite payload keys named `__partner__` or `__globals__`. | `XchangeService.RunMapper` | | An API gateway attached to an inactive subscription returns 404. | `Controllers/GatewayController.cs` | | Bulk retry includes exchanges that have no result yet, so work still in flight can run twice. Deliberate: after a broker outage those are the exchanges a bulk retry is for. | `Resources/Xchanges/BulkRetryPlanner.cs` | -| The dashboard's *Failures to act on* tile says it covers 14 days, but counts failures of any age. | `ClientApp/src/pages/dashboard/DashboardPage.tsx` | | An exchange's delivered time is never recorded. | `Domain/XchangeDelivery.cs` | | Exchanges, results and notifications are kept for ever by default (`ExchangeRetentionDays` 0), while their files expire from storage. The retention panel on the Settings page warns when exchanges outlive their files. | `Services/Retention/ExchangeRetentionJob.cs` | | Cached configuration can stay stale on other instances for up to 10 minutes if the revoke broadcast fails. Adapter descriptions and catalog entries are cached per node; publishing, promoting or withdrawing through Bitween clears them everywhere over the bus, but a package published straight to storage is seen with its old properties for up to a minute, or five for one without a manifest, which is described by starting it. That answer is kept until the adapter's package changes, so it is started once a build rather than every five minutes. | `Services/Caching/InMemoryInfolinkCache.cs`, `Services/ServerlessAdapterDescriber.cs` | diff --git a/docs/operations.md b/docs/operations.md index fa19bee4..e87dd0b4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -69,9 +69,9 @@ See [Scheduling](scheduling.md). ## Dashboard -The dashboard, opened from the logo, shows today's exchanges, the 7-day success rate, today's failures, waiting retries, queue alerts, a 14-day chart, the latest failures, unhealthy subscriptions and the busiest subscriptions. The browser computes it from up to 1,000 recent exchanges, so figures on a busy instance are approximate. +The dashboard, opened from the logo, shows the last 24 hours of exchanges, failures and end-to-end time, the 7-day success rate, waiting retries, queue alerts and subscriptions that have gone quiet. Below come traffic for the last 48 hours against the same hours a week before, volume for the last 30 days, unhealthy subscriptions, the latest failures, the busiest subscriptions and partners, the slowest adapters, and configuration worth tidying up. The traffic figures come from hourly metrics the server keeps, so they count every exchange however busy the instance is. -Two parts come from the server instead. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. +Two parts are read from the exchanges themselves. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. The page opens at once, and each tile and panel fills in when its own numbers arrive, so a slow one doesn't hold up the rest. One that can't be read in time shows as unavailable, and the dashboard asks again every minute. From 925d0aa935b35348c98ed77ccd4f090ea777f8e1 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 12:14:22 +0300 Subject: [PATCH 10/24] fix: dashboard review findings --- .../ClientApp/e2e/dashboard.spec.ts | 6 +++ .../src/pages/dashboard/DashboardPage.tsx | 8 +++- .../__tests__/DashboardPage.test.tsx | 44 +++++++++++++++++-- docs/operations.md | 2 +- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts index c9f8731a..05624c59 100644 --- a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts @@ -12,6 +12,12 @@ test("dashboard loads with real aggregated data", async ({ page }) => { await expect(page.getByText("Housekeeping")).toBeVisible(); // The page opens before its numbers arrive; check them once they have. await expect(page.getByText("loading…")).toHaveCount(0, { timeout: 15000 }); + await expect(page.getByText("Finding the latest failures…")).toHaveCount(0, { timeout: 15000 }); + await expect(page.getByText("Reading the retry chains…")).toHaveCount(0, { timeout: 15000 }); + // A part that couldn't be read stops loading too, and says so. + await expect(page.getByRole("link").filter({ hasText: /^Exchanges, last 24 hours/ })).not.toContainText( + "unavailable right now", + ); await expect(page.getByText("undefined")).toHaveCount(0); await expect(page.getByText("NaN")).toHaveCount(0); diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index 046aba7a..4cf3302b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -130,7 +130,11 @@ export function DashboardPage() { refetchInterval: 60_000, placeholderData: keepPreviousData, }); - const insights = useQuery({ queryKey: keys.metrics.insights, queryFn: () => api.getInsights() }); + const insights = useQuery({ + queryKey: keys.metrics.insights, + queryFn: () => api.getInsights(), + refetchInterval: 60_000, + }); const subscriptions = useQuery({ queryKey: keys.subscriptions.rows, queryFn: () => api.listSubscriptionRows(), @@ -608,7 +612,7 @@ export function DashboardPage() { description="Configuration that is switched off, unused or out of date - worth a look when tidying up." > {!insights.data ? ( - + ) : insights.data.insights.length === 0 ? ( Everything configured is in use. ) : ( diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx index 81bfe741..677fcfc8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx @@ -6,6 +6,12 @@ import { metricsHandlers, overview, summary } from "../../../__tests__/support/m const none = { result: [], totalCount: 0 }; +/** An answer that never comes, for a part still loading. */ +const never = async () => { + await delay("infinite"); + return HttpResponse.json({}); +}; + /** A subscription row as GET /subscriptions sends it: `RawSubscription` in src/api/http/subscriptions.ts. */ const subscription = (i: number, { failures = 0, paused = false } = {}) => ({ id: 900000 + i, @@ -160,6 +166,7 @@ describe("the dashboard", () => { renderApp("/dashboard", { handlers: [ http.get(apiPath("/metrics/overview"), () => new HttpResponse(null, { status: 500 })), + http.get(apiPath("/metrics/insights"), () => new HttpResponse(null, { status: 500 })), ...dashboard([subscription(0, { failures: 2 })]), ], }); @@ -168,16 +175,14 @@ describe("the dashboard", () => { expect(await within(exchanges).findByText("unavailable right now")).toBeVisible(); const traffic = within(screen.getByRole("heading", { name: "Traffic, last 48 hours" }).closest("section")!); expect(traffic.getByText("Unavailable right now")).toBeVisible(); + const housekeeping = within(screen.getByRole("heading", { name: "Housekeeping" }).closest("section")!); + expect(await housekeeping.findByText("Unavailable right now")).toBeVisible(); const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); }); it("opens at once, and each part fills in when it arrives", async () => { - const never = async () => { - await delay("infinite"); - return HttpResponse.json({}); - }; renderApp("/dashboard", { handlers: [ http.get(apiPath("/metrics/overview"), never), @@ -201,6 +206,37 @@ describe("the dashboard", () => { expect(latest.getByText("Finding the latest failures…")).toBeVisible(); }); + it("shows the latest failures without waiting for the metrics", async () => { + const failure = { + id: "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + subscriptionId: 900000, + documentName: "ORDER", + status: false, + responseBad: null, + exception: "Request failed with status NotFound", + startedOn: "2026-10-01T10:00:00Z", + }; + renderApp("/dashboard", { + handlers: [ + http.get(apiPath("/metrics/overview"), never), + http.get(apiPath("/xchanges"), ({ request }) => + HttpResponse.json( + new URL(request.url).searchParams.getAll("filter").includes("StatusFilter:1:3") + ? { result: [failure], totalCount: 1 } + : none, + ), + ), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const latest = within((await screen.findByRole("heading", { name: "Latest failures" })).closest("section")!); + // Named from the subscription rows, which answer apart from it. + expect(await latest.findByRole("link", { name: "Health page 1" })).toBeVisible(); + expect(latest.getByText("Request failed with status NotFound")).toBeVisible(); + expect(within(screen.getByText("Exchanges, last 24 hours").closest("a")!).getByText("loading…")).toBeVisible(); + }); + it("reads its numbers from the metrics, and names subscriptions that have gone quiet", async () => { const metrics = overview({ since: "2026-09-01T00:00:00Z", diff --git a/docs/operations.md b/docs/operations.md index e87dd0b4..667c97d0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -71,7 +71,7 @@ See [Scheduling](scheduling.md). The dashboard, opened from the logo, shows the last 24 hours of exchanges, failures and end-to-end time, the 7-day success rate, waiting retries, queue alerts and subscriptions that have gone quiet. Below come traffic for the last 48 hours against the same hours a week before, volume for the last 30 days, unhealthy subscriptions, the latest failures, the busiest subscriptions and partners, the slowest adapters, and configuration worth tidying up. The traffic figures come from hourly metrics the server keeps, so they count every exchange however busy the instance is. -Two parts are read from the exchanges themselves. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. +Some parts are read from the exchanges themselves, so on a busy instance they are the slowest: the latest failures, the count in flight, and the two below. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. The page opens at once, and each tile and panel fills in when its own numbers arrive, so a slow one doesn't hold up the rest. One that can't be read in time shows as unavailable, and the dashboard asks again every minute. From 08217d72a192ed102971ce159080cb08a7238b50 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 17:41:32 +0300 Subject: [PATCH 11/24] fix: Settings retention panel no longer reads every exchange --- .../Services/Retention/RetentionPlanner.cs | 39 ++++++------------- .../Tests/ExchangeRetentionTests.cs | 32 ++++++--------- SW.Bitween.Sdk/Model/Retention.cs | 7 +--- SW.Bitween.Web/ClientApp/src/api/types.ts | 2 +- .../src/pages/settings/RetentionPanel.tsx | 7 ++-- .../settings/__tests__/SettingsPage.test.tsx | 3 +- docs/operations.md | 2 +- 7 files changed, 31 insertions(+), 61 deletions(-) diff --git a/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs b/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs index b4918127..6492507a 100644 --- a/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs +++ b/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs @@ -13,7 +13,7 @@ namespace SW.Bitween; /// /// Works out what the retention settings do - as they stand, or as someone is about to change them - so /// the settings page can say it before anything is saved: how long files and exchanges are kept, what -/// the next run removes, and which exchanges are left without their files. +/// the next run removes, and whether exchanges are kept longer than their files. /// public class RetentionPlanner(BitweenDbContext dbContext, BitweenOptions options, StorageRetention storageRetention, StorageAccess storageAccess) @@ -35,8 +35,8 @@ public async Task Plan(RetentionProposal proposal = null, bool var now = DateTime.UtcNow; var files = Retention(prefix, rules); - // Exchanges already written keep the prefix they were written under, so how many are left - // without files depends on the prefix in force now, not on a proposed one. + // Exchanges already written keep the prefix they were written under, so whether they outlive + // their files depends on the prefix in force now, not on a proposed one. var currentFiles = prefix == options.DocumentPrefix ? files : Retention(options.DocumentPrefix, rules); var legacyPrefix = options.LegacyDocumentPrefix; var legacyFiles = !string.IsNullOrEmpty(legacyPrefix) && legacyPrefix != prefix ? Retention(legacyPrefix, rules) : null; @@ -51,29 +51,10 @@ public async Task Plan(RetentionProposal proposal = null, bool ? await ExchangeRetention.DueBefore(dbContext, now.AddDays(-days)).Take(CountCap).CountAsync() : null; - // As things stand now, whatever the proposal: those the next run removes are still listed until then. - // Each exchange's files follow the rule of the prefix they were written under, which a later change - // of prefix doesn't move; only those older than the shortest rule can have lost them. - int? withoutFiles = currentFiles.Days == null ? null : 0; - if (rules.Problem == null && - rules.Lifecycle?.Rules.Where(r => r.Enabled).Select(r => (int?)r.Days).Min() is { } shortest) - { - var unrecordedPrefix = options.LegacyDocumentPrefix ?? options.DocumentPrefix; - var writtenUnder = await dbContext.Set() - .Where(x => x.StartedOn < now.AddDays(-shortest)) - .Select(x => x.FilesPrefix).Distinct().ToListAsync(); - foreach (var recorded in writtenUnder) - { - if (Retention(recorded ?? unrecordedPrefix, rules).Days is not { } filesDays) continue; - var filesGone = now.AddDays(-filesDays); - var counted = withoutFiles ?? 0; - withoutFiles = counted + await dbContext.Set() - .Where(x => x.FilesPrefix == recorded && x.StartedOn < filesGone) - .Take(CountCap - counted).CountAsync(); - if (withoutFiles >= CountCap) break; - } - } - + // How many exchanges have lost their files isn't counted: which rule applies depends on the + // prefix each was written under, and finding those meant reading every exchange older than the + // bucket's shortest rule - a day, with the standard temp1/ rule - which took over 30s on 5.8M + // exchanges and failed the page. Their age says enough: see OUTLIVE_FILES. var oldest = await dbContext.Set().OrderBy(x => x.StartedOn) .Select(x => (DateTime?)x.StartedOn).FirstOrDefaultAsync(); @@ -106,7 +87,6 @@ public async Task Plan(RetentionProposal proposal = null, bool Cron = cron, NextRun = nextRun, DueNow = dueNow, - WithoutFiles = withoutFiles, CountCap = CountCap, Oldest = oldest } @@ -145,7 +125,10 @@ private List Notices(RetentionStatus status, PrefixRetention cu if (currentFiles.Days is { } filesDays && (days == 0 || days > filesDays)) warnings.Add(Warning("OUTLIVE_FILES", $"Exchanges older than {filesDays} days are listed without their files: they can't be opened " + - $"or retried. {(exchanges.WithoutFiles == 1 ? "There is 1" : $"There are {Count(exchanges.WithoutFiles, exchanges.CountCap)}")} right now." + + "or retried." + + (exchanges.Oldest is { } oldest && oldest < DateTime.UtcNow.AddDays(-filesDays) + ? $" Some already are: the oldest started on {oldest.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}." + : "") + (newOutliveFiles ? $" New exchanges lose theirs after {newFilesDays} days." : ""))); else if (newOutliveFiles) warnings.Add(Warning("OUTLIVE_FILES", diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs index 1387b566..a0be35c2 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs @@ -255,9 +255,6 @@ public async Task The_preview_says_when_exchanges_outlive_their_files_and_archiv DocumentPrefix = "temp30/docs", ExchangeRetentionDays = 20, ArchiveExchanges = true }); Assert.DoesNotContain(shorter.Notices, n => n.Code is "OUTLIVE_FILES" or "ARCHIVE_WITHOUT_FILES"); - // How many are without files is as things stand now, whatever the proposal. - Assert.NotNull(shorter.Exchanges.WithoutFiles); - Assert.Equal(longer.Exchanges.WithoutFiles, shorter.Exchanges.WithoutFiles); var forever = await planner.Plan(new RetentionProposal { DocumentPrefix = "docs/kept", ExchangeRetentionDays = 0 }); Assert.Null(forever.Files.Days); @@ -265,28 +262,23 @@ public async Task The_preview_says_when_exchanges_outlive_their_files_and_archiv } [Fact] - public async Task Exchanges_without_files_are_counted_by_the_prefix_each_was_written_under() + public async Task Exchanges_already_older_than_their_files_are_pointed_out_by_the_oldest() { var (_, subscription) = await Setup(); - var shortPrefix = $"temp30/{Guid.NewGuid():N}"; - var longPrefix = $"temp365/{Guid.NewGuid():N}"; - _options.DocumentPrefix = longPrefix; + _options.DocumentPrefix = $"temp30/{Guid.NewGuid():N}"; + var old = await CreateExchange(subscription, "{}", new(), succeeded: true); + await Backdate(old.Id, 45); + await using var scope = fixture.CreateScope(); var planner = ActivatorUtilities.CreateInstance(scope.ServiceProvider, ExchangeFilesTests.RetentionOver( - new CloudFilesLifecycleRule { Id = "temp30", Prefix = "temp30/", Days = 30, Enabled = true }, - new CloudFilesLifecycleRule { Id = "temp365", Prefix = "temp365/", Days = 365, Enabled = true })); - var before = (await planner.Plan()).Exchanges.WithoutFiles; - - _options.DocumentPrefix = shortPrefix; - var underShort = await CreateExchange(subscription, "{}", new(), succeeded: true); - _options.DocumentPrefix = longPrefix; - var underLong = await CreateExchange(subscription, "{}", new(), succeeded: true); - await Backdate(underShort.Id, 45); - await Backdate(underLong.Id, 45); - - // Today's prefix keeps files a year, but the one written under the 30-day prefix has lost its own. - Assert.Equal(before + 1, (await planner.Plan()).Exchanges.WithoutFiles); + new CloudFilesLifecycleRule { Id = "temp30", Prefix = "temp30/", Days = 30, Enabled = true })); + + // Kept for ever, files for 30 days, and an exchange 45 days old: some are listed without their files. + var status = await planner.Plan(new RetentionProposal { ExchangeRetentionDays = 0 }); + Assert.True(status.Exchanges.Oldest <= DateTime.UtcNow.AddDays(-45)); + var outlive = Assert.Single(status.Notices, n => n.Code == "OUTLIVE_FILES"); + Assert.Contains($"Some already are: the oldest started on {status.Exchanges.Oldest:yyyy-MM-dd}.", outlive.Message); } [Fact] diff --git a/SW.Bitween.Sdk/Model/Retention.cs b/SW.Bitween.Sdk/Model/Retention.cs index 67ccbf41..51c4f0cd 100644 --- a/SW.Bitween.Sdk/Model/Retention.cs +++ b/SW.Bitween.Sdk/Model/Retention.cs @@ -85,15 +85,10 @@ public class ExchangeRetentionInfo /// Exchanges the next run would remove; null while retention is off. public int? DueNow { get; set; } - /// - /// Exchanges still kept but older than their files: listed without them. Null when the bucket keeps - /// files for ever or its rules are unknown. - /// - public int? WithoutFiles { get; set; } - /// Counts stop at this many; a count equal to it means "at least". public int CountCap { get; set; } + /// When the oldest exchange still kept started; null when there are none. public DateTime? Oldest { get; set; } } diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index c1acc69d..9e486947 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1408,9 +1408,9 @@ export interface RetentionStatus { cron: string; nextRun: string | null; dueNow: number | null; - withoutFiles: number | null; /** Counts stop here; a count equal to it means "at least". */ countCap: number; + /** When the oldest exchange still kept started. */ oldest: string | null; }; notices: RetentionNotice[]; diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx index be0bc358..c0b8800d 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx @@ -73,7 +73,6 @@ export function RetentionPanel({ rows, draft }: { rows: SettingRow[]; draft: Set const filesRulePrefix = status.files.rulePrefix; const ex = status.exchanges; - const count = (n: number | null) => (n === null ? "-" : n >= ex.countCap ? `${ex.countCap.toLocaleString()}+` : n.toLocaleString()); return (
        @@ -94,9 +93,9 @@ export function RetentionPanel({ rows, draft }: { rows: SettingRow[]; draft: Set title={`When the retention job next runs (${ex.cron}), in your time zone. It does nothing while exchanges are kept for ever.`} />
        diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx index cbdff7cc..cd592362 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx @@ -163,7 +163,6 @@ const retention = (overrides: Partial = {}): RetentionStatus => cron: "0 0 4 * * ?", nextRun: "2026-10-01T04:00:00Z", dueNow: null, - withoutFiles: 12, countCap: 100000, oldest: "2026-01-01T00:00:00Z", }, @@ -300,6 +299,8 @@ describe("the settings page", () => { // The saved state, and the bucket's own rules with the one exchange files fall under marked. expect(await screen.findByText("What these settings do")).toBeVisible(); expect(screen.getByText(/nothing removes them from the database/)).toBeVisible(); + const oldest = screen.getByText("Oldest exchange").parentElement!; + expect(within(oldest).getByText(new Date("2026-01-01T00:00:00Z").toLocaleDateString(undefined, { dateStyle: "medium" }))).toBeVisible(); const rules = within(screen.getByRole("table")); expect(rules.getByText("temp30/")).toBeVisible(); expect(rules.getByText("Exchange files")).toBeVisible(); diff --git a/docs/operations.md b/docs/operations.md index 667c97d0..52501789 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -152,7 +152,7 @@ When a partner says a gateway refuses them, the gateway's page lists **Calls tur **Settings › About this instance** shows what this Bitween is and how it is set up, as the node that answered sees it: its version, the node's name and whether it runs data sources, the database and RabbitMQ checks `/health/ready` runs (run when the page opens), storage and queue prefix, which adapter runtimes (.NET, Python, Node.js) and pip/npm are on the node, the adapter editor's limits, rate limits, and the network and housekeeping settings that live in configuration. In a cluster, open it on each node to compare. -The **Documents & storage** section of the Settings page shows the bucket's rules and what the settings do: how long files and exchanges are kept, when the job next runs, how many exchanges are listed without their files, and warnings such as exchanges that outlive their files, archives that come too late to hold them, aggregations that run less often than exchanges are kept, or a bucket that lets anyone read Bitween's files without credentials. Saving a change to these settings shows the same list and asks first. +The **Documents & storage** section of the Settings page shows the bucket's rules and what the settings do: how long files and exchanges are kept, when the job next runs, when the oldest exchange started, and warnings such as exchanges that outlive their files, archives that come too late to hold them, aggregations that run less often than exchanges are kept, or a bucket that lets anyone read Bitween's files without credentials. Saving a change to these settings shows the same list and asks first. Exchange rows can outlive their files. Opening such a file says it was deleted by the retention policy, and so does retrying an exchange whose input has gone. A waiting retry for one is dropped with a reason. From 173aadc631e80bb86c43af95ef8dfd3877a1c635 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 17:41:32 +0300 Subject: [PATCH 12/24] fix: Exchanges page shows its rows at once and finishes slow counts on the server --- SW.Bitween.Api/Resources/Xchanges/Search.cs | 8 +- SW.Bitween.Api/Resources/Xchanges/Summary.cs | 156 +- SW.Bitween.Api/Services/SlowCounts.cs | 127 + .../Fixtures/BitweenFixture.cs | 1 + .../Tests/ExchangeFilterOperatorTests.cs | 5 +- .../Tests/ExchangeSummaryTests.cs | 99 +- ...0261102124651_BadResponseIndex.Designer.cs | 4209 +++++++++++++++++ .../20261102124651_BadResponseIndex.cs | 28 + SW.Bitween.Sdk/Model/Xchange.cs | 22 +- SW.Bitween.Web/ClientApp/src/api/client.ts | 2 +- .../ClientApp/src/api/http/exchanges.ts | 17 +- SW.Bitween.Web/ClientApp/src/api/types.ts | 9 +- .../src/pages/exchanges/ExchangesPage.tsx | 57 +- .../src/pages/exchanges/StatusTabs.tsx | 9 +- .../__tests__/ExchangesList.test.tsx | 68 +- .../src/pages/exchanges/exchangeFilters.ts | 11 + SW.Bitween.Web/Startup.cs | 1 + docs/api-reference.md | 3 +- docs/operations.md | 2 + 19 files changed, 4769 insertions(+), 65 deletions(-) create mode 100644 SW.Bitween.Api/Services/SlowCounts.cs create mode 100644 SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs diff --git a/SW.Bitween.Api/Resources/Xchanges/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs index 3dc89dcc..24666c01 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Search.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs @@ -156,8 +156,12 @@ from delayedRetry in drGroup.DefaultIfEmpty() // as "10,000+". 36ms drops to 2.3ms unfiltered, 264ms to 25ms on Success. Anything // filtered narrowly enough to act on still gets an exact number; only views far too // broad to page through lose it, and 10,000 rows is 400 pages of Next. - TotalCount = await query.AsNoTracking().Search(searchyRequest.Conditions) - .Take(CountCap + 1).CountAsync() + // + // Not at all with count=false, as Searchy's own search does: the Exchanges page asks + // for its rows that way so they show at once, and has the total from the summary. + TotalCount = searchyRequest.CountRows + ? await query.AsNoTracking().Search(searchyRequest.Conditions).Take(CountCap + 1).CountAsync() + : 0 }; return searchyResponse; diff --git a/SW.Bitween.Api/Resources/Xchanges/Summary.cs b/SW.Bitween.Api/Resources/Xchanges/Summary.cs index 10a807cf..ce5d2d2a 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Summary.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Summary.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Data; +using System.Globalization; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Model; @@ -27,11 +29,16 @@ namespace SW.Bitween.Resources.Xchanges; /// Every special filter the search understands is applied the same way, so a count is never of a /// different set than the list it sits over. /// +/// +/// A count or a histogram that takes longer than is finished in the background +/// by , which keeps it for the next ask: until then it is missing from the +/// answer, and says so. +/// /// [HandlerName("summary")] -public class Summary(BitweenDbContext dbContext, RequestContext requestContext) : ISearchyHandler +public class Summary(BitweenDbContext dbContext, RequestContext requestContext, SlowCounts slowCounts) : ISearchyHandler { - /// The status numbers reads, in the order they are counted. + /// The status numbers reads. private const string Processing = "0", Success = "1", BadResponse = "2", Failed = "3"; public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) @@ -47,6 +54,11 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa foreach (var statusFilter in condition.Filters.Where(f => f.Field == "StatusFilter").ToList()) condition.Filters.Remove(statusFilter); + // Taken before FilteredRowsAsync takes the special filters out of the request: a count finished + // in the background filters again from this, with a database of its own. + var asked = (SearchyRequest)searchyRequest.Clone(); + var view = ViewKey(searchyRequest, DateTime.UtcNow); + var rows = await XchangeFilters.FilteredRowsAsync(dbContext, searchyRequest); // Read after FilteredRowsAsync, which is what moves the request's dates to UTC. The @@ -58,15 +70,24 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa // One capped count per status rather than a single grouped count: each can stop at the // cap, as the search's total does, where a grouping has to visit every matching row. Four // short queries over a broad filter cost far less than one exhaustive one. - var counts = new XchangeStatusCounts + // + // All four share one budget, so the answer never waits longer than it, however many are slow. + // The ones with an index of their own go first; still running, which can't have one, last. + var counts = new XchangeStatusCounts(); + using (var deadline = new CancellationTokenSource(Budget)) { - Processing = await CountCapped(rows, Processing), - Success = await CountCapped(rows, Success), - BadResponse = await CountCapped(rows, BadResponse), - Failed = await CountCapped(rows, Failed), - }; + counts.BadResponse = await Count(BadResponse, deadline.Token); + counts.Failed = await Count(Failed, deadline.Token); + counts.Success = await Count(Success, deadline.Token); + counts.Processing = await Count(Processing, deadline.Token); + } - var summary = new XchangeSummary { Counts = counts }; + var summary = new XchangeSummary + { + Counts = counts, + Counting = counts.Processing == null || counts.Success == null || counts.BadResponse == null || + counts.Failed == null + }; if (rangeStart is not { } start) return summary; @@ -75,14 +96,115 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa return summary; var bucketMinutes = XchangeHistogram.ChooseBucketMinutes(start, end); + using var histogramDeadline = new CancellationTokenSource(Budget); + var started = await Answer($"histogram:{bucketMinutes}|{view}", histogramDeadline.Token, + ct => StartedCounts(rows, bucketMinutes, ct), + async (db, ct) => await StartedCounts(await XchangeFilters.FilteredRowsAsync(db, Copy(asked)), bucketMinutes, ct)); + if (started == null) + { + summary.Counting = true; + return summary; + } + summary.BucketMinutes = bucketMinutes; - summary.Buckets = XchangeHistogram.Fold(start, end, bucketMinutes, - await StartedCounts(rows, bucketMinutes)); + summary.Buckets = XchangeHistogram.Fold(start, end, bucketMinutes, started); return summary; + + Task Count(string status, CancellationToken deadline) => + Answer($"count:{status}|{view}", deadline, + ct => CountCapped(rows, status, dbContext, ct), + async (db, ct) => await CountCapped(await XchangeFilters.FilteredRowsAsync(db, Copy(asked)), status, db, ct)); + } + + /// + /// The answer keeps for , when it is one that was too + /// slow before. Otherwise worked out within the budget, and when that runs + /// out, left to to work out : null until then. + /// + private async Task Answer(string key, CancellationToken deadline, Func> here, + Func> later) + { + if (slowCounts.TryGet(key, later, out var kept)) + return kept; + + var answer = await WithinBudget(dbContext, deadline, here); + if (answer == null) + slowCounts.Start(key, later); + return answer; + } + + private static SearchyRequest Copy(SearchyRequest request) => (SearchyRequest)request.Clone(); + + /// + /// The filters as text, naming what was counted for . A start with no end - + /// what "last 24 hours" sends, a little later on every ask - is named by how far back it reaches, + /// to five minutes, so the asks of one page find the one count rather than each starting another. + /// + internal static string ViewKey(SearchyRequest request, DateTime now) => + string.Join("|", request.Conditions.Select(condition => + { + var openEnded = !condition.Filters.Any(f => + f.Field == nameof(XchangeRow.StartedOn) && f.Rule == SearchyRule.LessThanOrEquals); + return string.Join("&", condition.Filters + .Select(f => + openEnded && f.Field == nameof(XchangeRow.StartedOn) && f.Rule == SearchyRule.GreaterThanOrEquals && + DateTime.TryParse(f.Value?.ToString(), CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var since) + ? $"StartedOn:since:{Math.Round((now - since).TotalMinutes / 5)}" + : f.ToString()) + .OrderBy(f => f, StringComparer.Ordinal)); + })); + + /// How long the counts together, and then the histogram, may take before the rest is left to the background. + /// + /// A status that is rare across a large table can only be counted by visiting every exchange: + /// with no result row to look up, "processing" can't be found any other way. On millions of + /// exchanges that ran past the database's own time limit and failed the whole request, taking + /// every other tab's count with it. + /// + internal static readonly TimeSpan Budget = TimeSpan.FromSeconds(5); + + /// + /// What returns, or the default - null - when + /// passes first. Runs inside the summary's transaction, under a savepoint: a statement cancelled + /// part-way leaves Postgres refusing every later one in the transaction until it is rolled back to + /// before it. + /// + internal static async Task WithinBudget(BitweenDbContext dbContext, CancellationToken deadline, + Func> query) + { + const string savepoint = "within_budget"; + var transaction = dbContext.Database.CurrentTransaction!; + await transaction.CreateSavepointAsync(savepoint); + + try + { + return await query(deadline); + } + // Whatever the provider throws for a cancelled command: Npgsql an OperationCanceledException, + // SqlClient a SqlException. Only once the deadline has passed, so a real error still surfaces. + catch (Exception) when (deadline.IsCancellationRequested) + { + await transaction.RollbackToSavepointAsync(savepoint); + return default; + } } - private Task CountCapped(IQueryable rows, string status) => - rows.WhereStatus(status, dbContext).Take(Search.CountCap + 1).CountAsync(); + private static async Task CountCapped(IQueryable rows, string status, BitweenDbContext dbContext, + CancellationToken cancellationToken) + { + var matching = rows.WhereStatus(status, dbContext); + + // Nearly every exchange succeeds, so the newest 10,001 successes are among about the newest + // 10,001 exchanges. Unordered, Postgres matched the two tables by id instead, and ids aren't in + // the order exchanges were written, so nearly every row was read from a different place on + // disk: 12s on 5.8M exchanges, against under a second this way. The rarer statuses stay + // unordered, so they keep the plan that reads their own small index. + if (status == Success) + matching = matching.OrderByDescending(r => r.StartedOn); + + return await matching.Take(Search.CountCap + 1).CountAsync(cancellationToken); + } /// /// The matching exchanges grouped by when they started, to the precision the bucket size @@ -105,7 +227,7 @@ private Task CountCapped(IQueryable rows, string status) => /// /// private static async Task> StartedCounts(IQueryable rows, - int bucketMinutes) + int bucketMinutes, CancellationToken cancellationToken) { if (bucketMinutes >= 1440) return await rows @@ -119,7 +241,7 @@ private static async Task> StartedCounts(IQueryable= 60) return await rows @@ -138,7 +260,7 @@ private static async Task> StartedCounts(IQueryable new @@ -157,7 +279,7 @@ private static async Task> StartedCounts(IQueryable diff --git a/SW.Bitween.Api/Services/SlowCounts.cs b/SW.Bitween.Api/Services/SlowCounts.cs new file mode 100644 index 00000000..fd01dcb4 --- /dev/null +++ b/SW.Bitween.Api/Services/SlowCounts.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SW.Bitween; + +/// +/// Counts too slow to answer within a request, finished on the server instead and kept for the page +/// that asks again: the Exchanges page's status counts, its chart, and with them the list's total. +/// "Processing" across millions of exchanges, or a busy day's chart, takes longer than a request's 30 +/// seconds. The page shows what it has and asks again until the rest is there. +/// +/// +/// Kept in memory, on the node that counted. In a cluster each node counts a slow view once for itself, +/// which is still once per node rather than once per refresh. +/// +public sealed class SlowCounts(IServiceScopeFactory scopeFactory, IHostApplicationLifetime lifetime, + ILogger logger) +{ + /// + /// How long an answer stands before it is counted again. It is still given out while the new one is + /// worked out. A failed count is tried again after as long. + /// + internal static readonly TimeSpan KeepFor = TimeSpan.FromMinutes(5); + + /// An answer nobody has asked for in this long is dropped. + private static readonly TimeSpan ForgetAfter = TimeSpan.FromMinutes(30); + + /// The time one count gets on the database, where a request's commands get 30 seconds. + private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(10); + + /// + /// How many count at once on a node. Each is a long read of the largest tables, and a burst of slow + /// views mustn't take the database from the exchanges themselves; the rest wait their turn. + /// + private const int AtOnce = 2; + + private readonly ConcurrentDictionary entries = new(); + private readonly SemaphoreSlim places = new(AtOnce, AtOnce); + + /// + /// Whether is one counted here, given out as - the + /// default until the first count of it is in. Counted again in the background once its answer is + /// older than . + /// + public bool TryGet(string key, Func> count, out T answer) + { + answer = default; + if (!entries.TryGetValue(key, out var entry)) + return false; + + if (entry.Last is { } last) + answer = (T)last.Value; + Start(key, count); + return true; + } + + /// + /// Counts in the background, for a later to find. Does + /// nothing while it is already being counted, or was within . + /// + public void Start(string key, Func> count) + { + var now = DateTime.UtcNow; + var entry = entries.GetOrAdd(key, _ => new Entry()); + entry.AskedOn = now; + Forget(now); + + if (now - entry.TriedOn < KeepFor || Interlocked.Exchange(ref entry.Counting, 1) == 1) + return; + + _ = Task.Run(async () => + { + var stopping = lifetime.ApplicationStopping; + try + { + await places.WaitAsync(stopping); + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.SetCommandTimeout(CommandTimeout); + entry.Last = new Kept(await count(dbContext, stopping)); + } + finally + { + places.Release(); + } + } + catch (OperationCanceledException) when (stopping.IsCancellationRequested) + { + } + catch (Exception ex) + { + logger.LogWarning(ex, "A count left to finish in the background failed. It is tried again in {Minutes} minutes.", + KeepFor.TotalMinutes); + } + finally + { + entry.TriedOn = DateTime.UtcNow; + Interlocked.Exchange(ref entry.Counting, 0); + } + }); + } + + private void Forget(DateTime now) + { + foreach (var (key, entry) in entries) + if (entry.Counting == 0 && now - entry.AskedOn > ForgetAfter) + entries.TryRemove(key, out _); + } + + private sealed class Entry + { + public volatile Kept Last; + public int Counting; + public DateTime TriedOn; + public DateTime AskedOn; + } + + private sealed record Kept(object Value); +} diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index 84737102..6d711d93 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -366,6 +366,7 @@ await Task.WhenAll(_postgres.StartAsync(), _rabbitMq.StartAsync(), _mailHog.Star services.AddSingleton(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs index 9383b41d..93dc5dfc 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs @@ -260,7 +260,7 @@ private static async Task Search(AsyncServiceScope scope, params strin { var (db, xs) = Services(scope); var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser()) - .Handle(new SearchyRequest(filters) { PageSize = 50 }); + .Handle(new SearchyRequest(filters) { PageSize = 50, CountRows = true }); Assert.Equal(response.Result.Count(), response.TotalCount); return response.Result.Select(r => r.Id).Order().ToArray(); } @@ -276,6 +276,7 @@ private static async Task Select(AsyncServiceScope scope, params strin private static async Task Summarise(AsyncServiceScope scope, params string[] filters) { var db = scope.ServiceProvider.GetRequiredService(); - return (XchangeSummary)await new Summary(db, scope.Superuser()).Handle(new SearchyRequest(filters)); + return (XchangeSummary)await new Summary(db, scope.Superuser(), scope.ServiceProvider.GetRequiredService()) + .Handle(new SearchyRequest(filters)); } } diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs index bcaecc87..4eebb683 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -63,7 +65,8 @@ await db.Set().Where(x => x.Id == id) private static async Task Summarise(AsyncServiceScope scope, params string[] filters) { var db = scope.ServiceProvider.GetRequiredService(); - return (XchangeSummary)await new Summary(db, scope.Superuser()).Handle(new SearchyRequest(filters)); + return (XchangeSummary)await new Summary(db, scope.Superuser(), scope.ServiceProvider.GetRequiredService()) + .Handle(new SearchyRequest(filters)); } private static async Task> Search(AsyncServiceScope scope, params string[] filters) @@ -71,7 +74,7 @@ private static async Task> Search(AsyncServiceScope scope, params s var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser()) - .Handle(new SearchyRequest(filters) { PageSize = 50 }); + .Handle(new SearchyRequest(filters) { PageSize = 50, CountRows = true }); Assert.Equal(response.Result.Count(), response.TotalCount); return response.Result.Select(r => r.Id).ToList(); } @@ -176,6 +179,7 @@ public async Task Summary_counts_every_status_and_ignores_a_status_filter() Assert.Equal(1, summary.Counts.Success); Assert.Equal(1, summary.Counts.BadResponse); Assert.Equal(2, summary.Counts.Failed); + Assert.False(summary.Counting); // No date range, so nothing to divide into buckets. Assert.Empty(summary.Buckets); @@ -187,6 +191,97 @@ public async Task Summary_counts_every_status_and_ignores_a_status_filter() Assert.Equal(1, narrowed.Counts.Failed); } + [Fact] + public async Task A_query_past_its_budget_is_cut_off_and_the_summary_carries_on() + { + await using var scope = fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await db.Database.BeginTransactionAsync(); + + // Stopped on the database when the budget runs out, rather than left to finish. + var watch = Stopwatch.StartNew(); + using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + var slow = await Summary.WithinBudget(db, deadline.Token, + async ct => (int?)await db.Database.SqlQueryRaw("SELECT 1 AS \"Value\" FROM pg_sleep(10)").SingleAsync(ct)); + Assert.Null(slow); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(5), $"Cut off after {watch.Elapsed}."); + + // Whatever is still to count under the same budget isn't started. + Assert.Null(await Summary.WithinBudget(db, deadline.Token, + async ct => (int?)await db.Set().CountAsync(ct))); + + // The transaction still answers: without going back to before the cancelled statement, + // Postgres refuses everything after it. + Assert.NotNull(await Summary.WithinBudget(db, CancellationToken.None, + async ct => (int?)await db.Set().CountAsync(ct))); + } + + [Fact] + public async Task A_count_left_to_the_background_is_given_out_once_it_is_in() + { + var slowCounts = fixture.App.Services.GetRequiredService(); + var key = $"test:{Guid.NewGuid():N}"; + TimeSpan? commandTimeout = null; + async Task Count(BitweenDbContext db, CancellationToken ct) + { + commandTimeout = db.Database.GetCommandTimeout() is { } seconds ? TimeSpan.FromSeconds(seconds) : null; + return await db.Database.SqlQueryRaw("SELECT 42 AS \"Value\" FROM pg_sleep(1)").SingleAsync(ct); + } + + // Never too slow before, so the caller works it out itself. + Assert.False(slowCounts.TryGet(key, Count, out _)); + + // Too slow now: on its way, and nothing to give out yet. + slowCounts.Start(key, Count); + Assert.True(slowCounts.TryGet(key, Count, out var meanwhile)); + Assert.Null(meanwhile); + + var answer = (int?)null; + for (var waited = Stopwatch.StartNew(); answer == null && waited.Elapsed < TimeSpan.FromSeconds(20);) + { + await Task.Delay(100); + slowCounts.TryGet(key, Count, out answer); + } + Assert.Equal(42, answer); + // Not held to a request's 30 seconds. + Assert.Equal(TimeSpan.FromMinutes(10), commandTimeout); + } + + [Fact] + public void A_view_since_a_moment_is_the_same_view_a_little_later_and_a_fixed_range_is_kept_exact() + { + var now = new DateTime(2026, 11, 2, 12, 0, 0, DateTimeKind.Utc); + string Since(DateTime from) => $"StartedOn:6:{from:O}"; + + // "Last 24 hours" asked twice, 15 seconds apart. + Assert.Equal( + Summary.ViewKey(new SearchyRequest([Since(now.AddDays(-1)), "DocumentId:1:5"]), now), + Summary.ViewKey(new SearchyRequest(["DocumentId:1:5", Since(now.AddDays(-1).AddSeconds(15))]), now.AddSeconds(15))); + Assert.NotEqual( + Summary.ViewKey(new SearchyRequest([Since(now.AddDays(-1))]), now), + Summary.ViewKey(new SearchyRequest([Since(now.AddHours(-1))]), now)); + + // With an end, the range doesn't move, so its dates are the view. + var fixedRange = new SearchyRequest([Since(now.AddDays(-2)), $"StartedOn:8:{now.AddDays(-1):O}"]); + Assert.Equal(Summary.ViewKey(fixedRange, now), Summary.ViewKey(fixedRange, now.AddHours(1))); + Assert.DoesNotContain("since", Summary.ViewKey(fixedRange, now)); + } + + [Fact] + public async Task Search_with_count_false_leaves_the_total_out() + { + await using var scope = fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + var seeded = await OneOfEach(db, xs, "Search Without Count Doc"); + + var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser()) + .Handle(new SearchyRequest($"filter=DocumentId:1:{seeded.DocumentId}&count=false") { PageSize = 50 }); + + Assert.Equal(5, response.Result.Count()); + Assert.Equal(0, response.TotalCount); + } + [Fact] public async Task Summary_buckets_a_range_by_status_zero_filled_and_aligned_to_the_clock() { diff --git a/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs new file mode 100644 index 00000000..ca6f132d --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs @@ -0,0 +1,4209 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SW.Bitween.Model; +using SW.Bitween.PgSql; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20261102124651_BadResponseIndex")] + partial class BadResponseIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasColumnName("failed_login_count"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("LastSignInOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in_on"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("MicrosoftIdentity") + .HasColumnType("text") + .HasColumnName("microsoft_identity"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("OidcIdentity") + .HasColumnType("text") + .HasColumnName("oidc_identity"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("email_error"); + + b.Property("EmailedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("emailed_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("Purpose") + .HasColumnType("smallint") + .HasColumnName("purpose"); + + b.HasKey("Id") + .HasName("pk_account_links"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_links_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_account_links_hash"); + + b.ToTable("AccountLinks", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.Property("SupersededOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_on"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("last_used_from"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("revoked_by"); + + b.Property("RevokedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_on"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("shown"); + + b.HasKey("Id") + .HasName("pk_service_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_service_tokens_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_service_tokens_hash"); + + b.ToTable("ServiceTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("base_version"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("files_hash"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("files_json"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("language"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_adapter_draft"); + + b.HasIndex("AdapterId") + .HasDatabaseName("ix_adapter_draft_adapter_id"); + + b.ToTable("adapter_draft", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("action"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("DraftId") + .HasColumnType("integer") + .HasColumnName("draft_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_release"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_release_adapter_id_occurred_on"); + + b.ToTable("adapter_release", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("path"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_source_access"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_source_access_adapter_id_occurred_on"); + + b.ToTable("adapter_source_access", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key_name"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.HasKey("PartnerId", "KeyName") + .HasName("pk_api_key_use"); + + b.ToTable("api_key_use", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Changes") + .HasColumnType("text") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("character varying(36)") + .HasColumnName("correlation_id"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_key"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_name"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Sequence") + .HasColumnType("integer") + .HasColumnName("sequence"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("character varying(10)") + .HasColumnName("state"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_audit_entries"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_entries_correlation_id"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_audit_entries_occurred_on"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn") + .HasDatabaseName("ix_audit_entries_entity_name_entity_key_occurred_on"); + + b.ToTable("AuditEntries", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Done") + .HasColumnType("integer") + .HasColumnName("done"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("Node") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("node"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("owner"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("path"); + + b.Property("Result") + .HasColumnType("bytea") + .HasColumnName("result"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("result_content_type"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_disposition"); + + b.Property("ResultKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_key"); + + b.Property("ResultStatus") + .HasColumnType("integer") + .HasColumnName("result_status"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("summary"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Id") + .HasName("pk_background_request"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_background_request_started_on"); + + b.HasIndex("Owner", "StartedOn") + .HasDatabaseName("ix_background_request_owner_started_on"); + + b.ToTable("background_request", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("AcquiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_on"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("owner_node"); + + b.Property("Term") + .HasColumnType("bigint") + .HasColumnName("term"); + + b.HasKey("Id") + .HasName("pk_cluster_lease"); + + b.ToTable("cluster_lease", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("DataSources") + .HasColumnType("boolean") + .HasColumnName("data_sources"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("host"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("integer") + .HasColumnName("outbox_published_last_hour"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("resident_health"); + + b.Property("Runtimes") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtimes"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_cluster_node"); + + b.HasIndex("LastSeenOn") + .HasDatabaseName("ix_cluster_node_last_seen_on"); + + b.ToTable("cluster_node", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("access_token_expires_on"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("authorize_parameters"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("authorize_url"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("authorized_by"); + + b.Property("AuthorizedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("authorized_on"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Grant") + .HasColumnType("integer") + .HasColumnName("grant"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property("RenewingUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("renewing_until"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("scopes"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("token_auth"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("token_url"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_connections"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_connections_name"); + + b.ToTable("Connections", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("instance_key"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("value"); + + b.HasKey("AdapterId", "InstanceKey", "Name") + .HasName("pk_adapter_state"); + + b.ToTable("adapter_state", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .HasColumnType("text") + .HasColumnName("adapter_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CpuLimitSamples") + .HasColumnType("integer") + .HasColumnName("cpu_limit_samples"); + + b.Property("CpuPercentLimit") + .HasColumnType("double precision") + .HasColumnName("cpu_percent_limit"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("integer") + .HasColumnName("deduplication_window_days"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("hard_memory_limit_mb"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("LastHeartbeatOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_heartbeat_on"); + + b.Property("LastKnownState") + .HasColumnType("text") + .HasColumnName("last_known_state"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnedByNode") + .HasColumnType("text") + .HasColumnName("owned_by_node"); + + b.Property("Placement") + .HasColumnType("integer") + .HasColumnName("placement"); + + b.Property>("Properties") + .HasColumnType("hstore") + .HasColumnName("properties"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("text[]") + .HasColumnName("secret_properties"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("soft_memory_limit_mb"); + + b.HasKey("Id") + .HasName("pk_data_source"); + + b.ToTable("data_source", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("CursorColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("cursor_column"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key_column"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sql"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_data_source_statement"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_data_source_statement_work_group_id"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique() + .HasDatabaseName("ix_data_source_statement_data_source_id_name"); + + b.ToTable("data_source_statement", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("id"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("SeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("seen_on"); + + b.Property("XchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_inbound_message"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_inbound_message_data_source_id"); + + b.HasIndex("SeenOn") + .HasDatabaseName("ix_inbound_message_seen_on"); + + b.ToTable("inbound_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.Property("RunFailures") + .HasColumnType("integer") + .HasColumnName("run_failures"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DropDuplicates") + .HasColumnType("boolean") + .HasColumnName("drop_duplicates"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("json") + .HasColumnName("promoted_properties"); + + b.Property("RetiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_on"); + + b.Property("ValidationSchema") + .HasColumnType("text") + .HasColumnName("validation_schema"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Dropped") + .HasColumnType("boolean") + .HasColumnName("dropped"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("original_xchange_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.HasKey("Id") + .HasName("pk_duplicate_arrival"); + + b.HasIndex("DocumentId", "OccurredOn") + .HasDatabaseName("ix_duplicate_arrival_document_id_occurred_on"); + + b.ToTable("duplicate_arrival", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("integer") + .HasColumnName("auth_method"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FailureAnswer") + .HasColumnType("integer") + .HasColumnName("failure_answer"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_audience"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_issuer"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("jwt_partner_claim"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("partner_key_header"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Endpoint") + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property>("EndpointProperties") + .HasColumnType("hstore") + .HasColumnName("endpoint_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_bus_gateway_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.Property("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_idempotency_key"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_idempotency_key_created_on"); + + b.HasIndex("Scope", "Key") + .IsUnique() + .HasDatabaseName("ix_idempotency_key_scope_key"); + + b.ToTable("idempotency_key", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("Role") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Runtime") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("runtime"); + + b.Property("Calls") + .HasColumnType("integer") + .HasColumnName("calls"); + + b.Property("Crashes") + .HasColumnType("integer") + .HasColumnName("crashes"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failures") + .HasColumnType("integer") + .HasColumnName("failures"); + + b.Property("LimitStops") + .HasColumnType("integer") + .HasColumnName("limit_stops"); + + b.Property("Timeouts") + .HasColumnType("integer") + .HasColumnName("timeouts"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime") + .HasName("pk_adapter_stats"); + + b.ToTable("adapter_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("Scope") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("scope"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope_key"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("fingerprint"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("sample"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint") + .HasName("pk_error_stats"); + + b.ToTable("error_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("BadResponse") + .HasColumnType("integer") + .HasColumnName("bad_response"); + + b.Property("BytesIn") + .HasColumnType("bigint") + .HasColumnName("bytes_in"); + + b.Property("BytesOut") + .HasColumnType("bigint") + .HasColumnName("bytes_out"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DbCommands") + .HasColumnType("bigint") + .HasColumnName("db_commands"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failed") + .HasColumnType("integer") + .HasColumnName("failed"); + + b.Property("FailedAtHandler") + .HasColumnType("integer") + .HasColumnName("failed_at_handler"); + + b.Property("FailedAtMapper") + .HasColumnType("integer") + .HasColumnName("failed_at_mapper"); + + b.Property("ManualRetries") + .HasColumnType("integer") + .HasColumnName("manual_retries"); + + b.Property("PayloadParses") + .HasColumnType("bigint") + .HasColumnName("payload_parses"); + + b.Property("Retries") + .HasColumnType("integer") + .HasColumnName("retries"); + + b.Property("Succeeded") + .HasColumnType("integer") + .HasColumnName("succeeded"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId") + .HasName("pk_exchange_stats"); + + b.ToTable("exchange_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("timestamp with time zone") + .HasColumnName("at"); + + b.Property("AckRate") + .HasColumnType("double precision") + .HasColumnName("ack_rate"); + + b.Property("Consumers") + .HasColumnType("integer") + .HasColumnName("consumers"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint") + .HasColumnName("dead_letter_backlog"); + + b.Property("IncomingRate") + .HasColumnType("double precision") + .HasColumnName("incoming_rate"); + + b.Property("QueueDepth") + .HasColumnType("bigint") + .HasColumnName("queue_depth"); + + b.Property("RetryBacklog") + .HasColumnType("bigint") + .HasColumnName("retry_backlog"); + + b.Property("UnhealthyConsumers") + .HasColumnType("integer") + .HasColumnName("unhealthy_consumers"); + + b.HasKey("At") + .HasName("pk_queue_snapshot"); + + b.ToTable("queue_snapshot", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_notification_channel"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_channel_name"); + + b.ToTable("notification_channel", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.PrimitiveCollection("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("detail"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("target"); + + b.HasKey("Id") + .HasName("pk_operator_action"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_operator_action_occurred_on"); + + b.ToTable("operator_action", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ClaimedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_until"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_on"); + + b.HasKey("Id") + .HasName("pk_outbox_message"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_outbox_message_created_on") + .HasFilter("published_on IS NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("outbox_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("login_identity"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.HasIndex("LoginIdentity") + .IsUnique() + .HasDatabaseName("ix_partner_login_identity"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.PrimitiveCollection("ExchangeIds") + .HasColumnType("text[]") + .HasColumnName("exchange_ids"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_receive_attempt"); + + b.HasIndex("SubscriptionId", "StartedOn") + .HasDatabaseName("ix_receive_attempt_subscription_id_started_on"); + + b.ToTable("receive_attempt", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("AlertMode") + .HasColumnType("smallint") + .HasColumnName("alert_mode"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_alert_override"); + + b.ToTable("retry_alert_override", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AttemptsUsed") + .HasColumnType("integer") + .HasColumnName("attempts_used"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("exhausted_notified_on"); + + b.Property("LastAttemptOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_on"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_group_usage"); + + b.ToTable("retry_group_usage", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_settings"); + + b.ToTable("Settings", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("AutoPauseAfterFailures") + .HasColumnType("integer") + .HasColumnName("auto_pause_after_failures"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ExpectTraffic") + .HasColumnType("text") + .HasColumnName("expect_traffic"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Notifications") + .HasColumnType("text") + .HasColumnName("notifications"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedAutomatically") + .HasColumnType("boolean") + .HasColumnName("paused_automatically"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("QuietSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("quiet_since"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ReceiverVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("receiver_version"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("RunOnBadResponses") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_responses"); + + b.Property("RunningSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("running_since"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("ValidatorVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("validator_version"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_subscription_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("saved_by"); + + b.Property("SavedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("saved_on"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("text") + .HasColumnName("snapshot"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_revision"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique() + .HasDatabaseName("ix_subscription_revision_subscription_id_number"); + + b.ToTable("subscription_revision", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("files_prefix"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on") + .HasDatabaseName("ix_xchange_retries_started_on") + .HasFilter("retry_for IS NOT NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on"), new[] { "Id", "RetryFor" }); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer") + .HasColumnName("channel_id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Suppressed") + .HasColumnType("boolean") + .HasColumnName("suppressed"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.HasIndex("XchangeId") + .HasDatabaseName("ix_xchange_notification_xchange_id"); + + b.HasIndex("ChannelId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_channel_id_finished_on"); + + b.HasIndex("NotifierId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_notifier_id_finished_on"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.PrimitiveCollection("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("NodeName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node_name"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("retry_blocked_reason"); + + b.Property("RetryGroupId") + .HasColumnType("uuid") + .HasColumnName("retry_group_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("warning"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.HasIndex("RetryGroupId") + .HasDatabaseName("ix_xchange_result_retry_group_id"); + + b.HasIndex("SubscriptionId", "FinishedOn") + .HasDatabaseName("ix_xchange_result_subscription_id_finished_on") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex(new[] { "Id" }, "ix_xchange_result_failed") + .HasDatabaseName("ix_xchange_result_failed") + .HasFilter("NOT success") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("ClaimedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_on"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_retry_claim"); + + b.ToTable("xchange_retry_claim", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_links_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_service_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_data_source_statement_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_data_source_statement_work_group_work_group_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inbound_message_data_source_data_source_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .HasConstraintName("fk_bus_gateway_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("key_prefix"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "ResponseSubscription") + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_revision_subscription_subscription_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_retry_claim_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs new file mode 100644 index 00000000..6ac3e4f3 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + /// The results with a bad response, in an index of their own, so the Bad response tab counts them + /// and the dashboard finds the latest without reading every result. They are a small part of the + /// table, often none of it. Built without stopping writes to it. + /// + public partial class BadResponseIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Outside the transaction, on what can be the largest tables there are: see ConcurrentIndex. + ConcurrentIndex.Create(migrationBuilder, "xchange_result", "ix_xchange_result_bad_response", + "(id) WHERE response_bad"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ConcurrentIndex.Drop(migrationBuilder, "ix_xchange_result_bad_response"); + } + } +} diff --git a/SW.Bitween.Sdk/Model/Xchange.cs b/SW.Bitween.Sdk/Model/Xchange.cs index 7b9bcfb6..e4c1c4ec 100644 --- a/SW.Bitween.Sdk/Model/Xchange.cs +++ b/SW.Bitween.Sdk/Model/Xchange.cs @@ -356,30 +356,40 @@ public class XchangeSummary /// When the matching exchanges started, by status, in fixed buckets of /// each - contiguous and zero-filled from the start of the /// filter's date range to its end. Empty when the filter has no start date: with no range - /// there is nothing to divide into buckets but the whole history. + /// there is nothing to divide into buckets but the whole history. Also empty while they are + /// still being worked out: see . /// public List Buckets { get; set; } = new List(); /// How long each bucket is, in minutes; 0 when there are no buckets. public int BucketMinutes { get; set; } + + /// + /// True while part of this is still being worked out on the server, which takes over from a + /// request for anything slower than a few seconds: a count that is null, or the buckets of a + /// date range, are on their way. Asking again in a little while brings them. + /// + public bool Counting { get; set; } } /// /// Exchanges by status, defined exactly as the list's status filter defines them. A count of - /// 10001 means "more than 10,000": counting stops there, as the search's total does. + /// 10001 means "more than 10,000": counting stops there, as the search's total does. Null + /// means still being counted on the server, which a status rare across a large table can take + /// minutes over: see . /// public class XchangeStatusCounts { /// Still running: no result yet. - public int Processing { get; set; } + public int? Processing { get; set; } /// Finished, and the response (if any) was not marked bad. - public int Success { get; set; } + public int? Success { get; set; } /// Finished, but the response was marked bad. - public int BadResponse { get; set; } + public int? BadResponse { get; set; } - public int Failed { get; set; } + public int? Failed { get; set; } } /// One bucket of : exchanges that started in it, by status. diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 4788cf76..13a0444c 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -755,7 +755,7 @@ export interface ApiClient { ): Promise>; // - exchanges - - searchExchanges(query: ExchangeQuery): Promise>; + searchExchanges(query: ExchangeQuery, opts?: { count?: boolean }): Promise>; getExchangeBreakdown(query: ExchangeQuery): Promise; // - audit trail - diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 8343f127..30199254 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -312,9 +312,13 @@ export const exchangeMethods = { return res.data; }, - async searchExchanges(query: ExchangeQuery): Promise> { + /** + * A page of exchanges and how many match. With `count: false` the total is left out, as 0, so the + * rows come back without waiting for it: the Exchanges page has its total from the status counts. + */ + async searchExchanges(query: ExchangeQuery, { count = true }: { count?: boolean } = {}): Promise> { const [res, partnerNameById] = await Promise.all([ - get>(`/xchanges?${buildExchangeQuery(query)}`), + get>(`/xchanges?${buildExchangeQuery(query)}${count ? "" : "&count=false"}`), partnerNameMap(), ]); return { result: res.result.map((r) => toExchangeRow(r, partnerNameById)), total: res.totalCount }; @@ -330,13 +334,14 @@ export const exchangeMethods = { const res = await get(`/xchanges/summary${params.toString() ? `?${searchyQueryString(params)}` : ""}`); return { counts: { - processing: res.counts?.processing ?? 0, - success: res.counts?.success ?? 0, - badResponse: res.counts?.badResponse ?? 0, - failed: res.counts?.failed ?? 0, + processing: res.counts?.processing ?? null, + success: res.counts?.success ?? null, + badResponse: res.counts?.badResponse ?? null, + failed: res.counts?.failed ?? null, }, buckets: res.buckets ?? [], bucketMinutes: res.bucketMinutes ?? 0, + counting: res.counting ?? false, }; }, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 9e486947..7fae7d9a 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1643,11 +1643,16 @@ export interface ExchangeVolumeBucket { * and, when the filter has a start time, how they spread over that range. */ export interface ExchangeBreakdown { - /** Each capped like the list total: a value past the cap means "more than". */ - counts: Record; + /** + * Each capped like the list total: a value past the cap means "more than". Null while it is still + * being counted on the server, which a status rare across a large table can take minutes over. + */ + counts: Record; buckets: ExchangeVolumeBucket[]; /** The width of one bucket; 0 when there are none. */ bucketMinutes: number; + /** Part of it is still being worked out on the server: a later ask brings what is missing. */ + counting: boolean; } export interface Paged { diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index aaf5326b..f2155bc0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -30,6 +30,7 @@ import { COUNT_CAP, PAGE_SIZE, activeFilterCount, + allCount, filterSignature, hasTimeFilter, idsIn, @@ -47,6 +48,9 @@ import { */ const EXPORT_LIMIT = 50_000; +/** How often the counts and the chart are asked for again while the server is still working them out. */ +const COUNTING_MS = 15_000; + const exchangesCount = (n: number) => (n === 1 ? "1 exchange" : `${n.toLocaleString()} exchanges`); /** Why some of an export's exchanges come without files; said before it downloads. */ @@ -103,9 +107,11 @@ export function ExchangesPage() { // A relative range ("last hour") is worked out each time the list is asked for, so a live list // keeps showing the last hour rather than the hour before the page was opened. + // The rows alone, so they show as soon as they're found: how many match comes with the status + // counts below, which can take far longer to work out on a busy instance. const { data, isLoading, isFetching, isPlaceholderData, dataUpdatedAt, refetch } = useQuery({ queryKey: keys.exchanges.search(viewSearch(searchParams) + `&offset=${query.offset}`), - queryFn: () => api.searchExchanges(readQuery(searchParams)), + queryFn: () => api.searchExchanges(readQuery(searchParams), { count: false }), refetchInterval: refreshMs || false, placeholderData: keepPreviousData, }); @@ -118,12 +124,17 @@ export function ExchangesPage() { // With a time filter, one request answers both. Without one the list covers all time, which // the counts follow, while the chart shows the last 24 hours and says so - a histogram of // "all time" is one bar. + // + // What takes too long to count is finished on the server, so while any of it is still coming + // these ask again every 15 seconds, live refresh or not. const timed = hasTimeFilter(searchParams); const breakdownKey = viewSearch(withParams(searchParams, { status: null })); + const askAgain = (q: { state: { data?: { counting: boolean } } }) => + refreshMs || (q.state.data?.counting ? COUNTING_MS : false); const breakdown = useQuery({ queryKey: keys.exchanges.breakdown(breakdownKey), queryFn: () => api.getExchangeBreakdown(readQuery(searchParams)), - refetchInterval: refreshMs || false, + refetchInterval: askAgain, placeholderData: keepPreviousData, retry: false, }); @@ -132,7 +143,7 @@ export function ExchangesPage() { queryKey: keys.exchanges.breakdown(chartParams ? viewSearch(chartParams) : ""), queryFn: () => api.getExchangeBreakdown(readQuery(chartParams!)), enabled: !!chartParams, - refetchInterval: refreshMs || false, + refetchInterval: askAgain, placeholderData: keepPreviousData, retry: false, }); @@ -204,8 +215,11 @@ export function ExchangesPage() { const filterCount = activeFilterCount(searchParams); const rows = data?.result ?? []; - const total = data?.total ?? 0; - const totalIsCapped = total > COUNT_CAP; + // How many match: the selected status's count, or every status's together - none while they're + // still being counted, or still the last filter's. + const counts = breakdown.isPlaceholderData ? undefined : breakdown.data?.counts; + const total = counts && (query.status ? (counts[query.status] ?? undefined) : allCount(counts)); + const totalIsCapped = total !== undefined && total > COUNT_CAP; // --- the open exchange --- // In the URL, so a link can open one. A link to a single exchange (`?ids=`, as the @@ -251,7 +265,7 @@ export function ExchangesPage() { }; const isSelected = (id: string) => (allMatching ? !excluded.has(id) : selected.has(id)); const allOnPageSelected = rows.length > 0 && rows.every((r) => isSelected(r.id)); - const selectedCount = allMatching ? Math.max(0, total - excluded.size) : selected.size; + const selectedCount = allMatching ? Math.max(0, (total ?? 0) - excluded.size) : selected.size; /** What the retry acts on: the ticked rows, or the filter itself minus the unticked ones. */ const selection: BulkRetrySelection = allMatching ? { matching: query, excludeIds: [...excluded] } : { ids: [...selected] }; @@ -434,7 +448,7 @@ export function ExchangesPage() { {asked && asked.search === viewSearch(searchParams) && ( - setAsked(null)} /> + setAsked(null)} /> )} {volume && volume.buckets.length > 0 && ( @@ -540,13 +554,24 @@ export function ExchangesPage() { {/* - paging - */}
        - Showing {query.offset + 1}-{query.offset + rows.length} of{" "} - {totalIsCapped ? ( - - {COUNT_CAP.toLocaleString()}+ + Showing {query.offset + 1}-{query.offset + rows.length} + {total === undefined ? ( + + {" "} + of … ) : ( - total.toLocaleString() + <> + {" "} + of{" "} + {totalIsCapped ? ( + + {COUNT_CAP.toLocaleString()}+ + + ) : ( + total.toLocaleString() + )} + )} @@ -560,9 +585,9 @@ export function ExchangesPage() { - diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx index 45e64760..381af84f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { afterEach, describe, expect, it, vi } from "vitest"; import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; @@ -176,6 +176,36 @@ describe("the exchanges list", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + it("stays up while the next page loads, and what acts on it waits for the number", async () => { + let pageIn!: () => void; + const nextPage = new Promise((resolve) => (pageIn = resolve)); + const { user } = renderApp("/exchanges?status=failed", { + handlers: [ + // Holds the second page, then leaves it to the list's own handler. + http.get(/\/api\/xchanges(\?|$)/, async ({ request }) => { + if (new URL(request.url).searchParams.get("page") === "1") await nextPage; + }), + exchanges({ total: 60 }).handler, + ...filterOptions, + ], + }); + + await selectAllMatching(user); + const selection = () => within(screen.getByText(/everything this filter matches/).parentElement!); + expect(selection().getByText("60")).toBeVisible(); + + // The first page's rows are still up: the footer's total and the selection's wait with them. + await user.click(screen.getByRole("button", { name: "Next" })); + expect(await selection().findByTitle("Still counting how many match")).toBeVisible(); + expect(screen.getAllByTitle("Still counting how many match")).toHaveLength(2); + expect(screen.getByRole("button", { name: "Retry selected…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Export files" })).toBeDisabled(); + + pageIn(); + expect(await selection().findByText("60")).toBeVisible(); + expect(screen.getByRole("button", { name: "Retry selected…" })).toBeEnabled(); + }); + it.each([ { answer: "past what rounds can take, it refuses", @@ -351,6 +381,37 @@ describe("the exchanges list", () => { expect((await tabs()).getByRole("button", { name: "All" })).toBeVisible(); }); + it("asks again while a status is still being counted, with live refresh paused too", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + let counted = false; + renderApp("/exchanges", { + handlers: [ + http.get(apiPath("/xchanges/summary"), () => + HttpResponse.json({ + counts: { processing: counted ? 7 : null, success: 0, badResponse: 0, failed: 3 }, + buckets: [], + bucketMinutes: 0, + counting: !counted, + }), + ), + exchanges({ total: 3 }).handler, + ...filterOptions, + ], + }); + const tabs = within(await screen.findByRole("group", { name: "Status" })); + expect(await tabs.findByRole("button", { name: "Failed 3" })).toBeVisible(); + + // Paused, nothing else asks again: only that the count isn't in yet. + fireEvent.change(screen.getByRole("combobox", { name: "Refresh interval" }), { target: { value: "0" } }); + counted = true; + await act(() => vi.advanceTimersByTimeAsync(15_000)); + expect(await tabs.findByRole("button", { name: "Processing 7" })).toBeVisible(); + } finally { + vi.useRealTimers(); + } + }); + it("can show only the newest attempt of each chain", async () => { const latest = (q: URLSearchParams) => q.getAll("filter").includes("LatestOnly:1:true"); const list = exchanges({ total: (q) => (latest(q) ? 30 : 40) }); diff --git a/docs/api-reference.md b/docs/api-reference.md index 45ea14bc..5b1a6300 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -97,7 +97,7 @@ All need the global values permissions: `global-values.view` to read, `.create`, | Method and path | Permission | Description | |---|---|---| | `GET /api/xchanges` | `exchanges.view` or `dashboard.view`, or signed in with `lookup=true` | Searchy. See the filters below. `totalCount` stops at 10,001; `count=false` leaves it out, as 0, so the rows come back without waiting for it. A lookup returns at most 100 exchange ids, newest first, each with its information type's name. | -| `GET /api/xchanges/summary` | `exchanges.view` or `dashboard.view` | The search's filters, any status filter ignored, as `counts` per status (`processing`, `success`, `badResponse`, `failed`), each capped at 10,001 like the search's total. With a start date, also `buckets` of when they started and `bucketMinutes`. What isn't worked out within 5 seconds is finished in the background, for up to 10 minutes and at most 2 at a time per node, and kept 5 minutes: until then its count is `null` or the buckets are empty, and `counting` is `true`. Asking again brings it. | +| `GET /api/xchanges/summary` | `exchanges.view` or `dashboard.view` | The search's filters, any status filter ignored, as `counts` per status (`processing`, `success`, `badResponse`, `failed`), each capped at 10,001 like the search's total. With a start date, also `buckets` of when they started and `bucketMinutes`. What isn't worked out within 5 seconds is finished in the background, at most 2 at a time per node and each with up to 10 minutes on the database, and kept 5 minutes: until then its count is `null` or the buckets are empty, and `counting` is `true`. Asking again brings it. One nobody has asked about for 2 minutes when its turn comes is dropped until asked again. | | `POST /api/xchangequeries` | `exchanges.view` | Turn a question into an Exchanges page filter with AI: `{ question, timeZone }` returns `{ query, answer, notes, model, sent }`. See [Asking your exchanges](configuration.md#asking-your-exchanges). | | `GET /api/xchanges/statuslist` | `exchanges.view` | Status values for filters | | `GET /api/xchanges/retrytree?id=` | `exchanges.view` | The retry chain any attempt belongs to | diff --git a/docs/operations.md b/docs/operations.md index 795a3415..02351be1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,7 +8,7 @@ The Exchanges page filters by status, subscription, partner, information type, exchange ids, correlation id, a promoted property value and a date range. Pick a promoted property to search inside its value only; any part of the value matches, ignoring case. Filters live in the URL, so a filtered view can be shared as a link. The list reloads itself every 15 seconds by default (adjustable, or paused); **Refresh**, beside the pause button, reloads the list and its counts right away. -Each status above the list shows how many exchanges it holds under the other filters, up to 10,000+. The list doesn't wait for them: its rows show at once, and how many match ("Showing 1-25 of ...") comes with the counts. A count that takes more than a few seconds, such as Processing across millions of exchanges, is finished on the server, for up to 10 minutes, and shows up once it's ready; until then that status shows no number, and the page asks again every 15 seconds. Such a count is kept for 5 minutes, then counted again. The chart works the same way. +Each status above the list shows how many exchanges it holds under the other filters, up to 10,000+. The list doesn't wait for them: its rows show at once, and how many match ("Showing 1-25 of ...") comes with the counts. A count that takes more than a few seconds, such as Processing across millions of exchanges, is finished on the server and shows up once it's ready; until then that status shows no number, and the page asks again every 15 seconds. The server runs 2 such counts at a time, each for up to 10 minutes, so one can wait its turn first; one whose page was closed before its turn is dropped. A finished count is kept for 5 minutes, then counted again. The chart works the same way. - Search by **promoted property** to find a business document, such as an order number. - Search by **correlation id** to see every exchange in one flow, including exchanges created from responses. From 4e9fac07238de2895d8c17f209c9e7242320ba40 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 3 Nov 2026 11:23:21 +0300 Subject: [PATCH 14/24] fix: Exchanges list says when it couldn't load, and timeouts aren't asked again --- .../Services/DatabaseTimeoutMiddleware.cs | 57 +++++++++++++++++++ .../Tests/DatabaseTimeoutTests.cs | 48 ++++++++++++++++ .../src/api/http/__tests__/errors.test.tsx | 3 +- SW.Bitween.Web/ClientApp/src/api/types.ts | 8 ++- .../src/pages/exchanges/ExchangesPage.tsx | 11 +++- .../__tests__/ExchangesList.test.tsx | 28 +++++++++ SW.Bitween.Web/Startup.cs | 4 ++ docs/api-reference.md | 1 + docs/operations.md | 2 +- 9 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs create mode 100644 SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs diff --git a/SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs b/SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs new file mode 100644 index 00000000..1f28a288 --- /dev/null +++ b/SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs @@ -0,0 +1,57 @@ +using System; +using System.Data.Common; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using SW.Bitween.Services.Background; + +namespace SW.Bitween.Services; + +/// +/// A request whose database command ran past its time limit, answered as that: 504 with +/// DATABASE_TIMED_OUT, where it used to be a bare 500 like any other failure. The admin UI +/// doesn't ask again for it - asked again, it takes as long again - and says what happened rather +/// than showing an empty page. +/// +public class DatabaseTimeoutMiddleware(RequestDelegate next, ILogger logger) +{ + public const string Code = "DATABASE_TIMED_OUT"; + + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) when (IsDatabaseTimeout(ex) && !context.Response.HasStarted) + { + logger.LogError(ex, "{Method} {Path} ran past the database's time limit.", context.Request.Method, + context.Request.Path); + context.Response.Clear(); + context.Response.StatusCode = StatusCodes.Status504GatewayTimeout; + context.Response.ContentType = "application/json"; + await context.Response.Body.WriteAsync(BackgroundRequests.Problem(Code, + "The database took too long to answer. Asking for less, such as a shorter time range, may help.")); + } + } + + /// + /// A command that ran out of time, as Npgsql throws it: its own exception around a + /// . Only inside a database exception, so a call elsewhere that + /// timed out isn't put down to the database. SQL Server and MySQL say it differently, and keep + /// the 500. + /// + internal static bool IsDatabaseTimeout(Exception ex) + { + var inDatabase = false; + for (var e = ex; e != null; e = e.InnerException) + { + if (e is DbException) + inDatabase = true; + else if (e is TimeoutException && inDatabase) + return true; + } + + return false; + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs b/SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs new file mode 100644 index 00000000..06def121 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Services; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// A request whose query runs past the database's time limit answers 504 with +/// DATABASE_TIMED_OUT: the admin UI doesn't ask again for it, and says what happened. +/// +[Collection("Bitween")] +public class DatabaseTimeoutTests(BitweenFixture fixture) +{ + [Fact] + public async Task A_query_past_the_database_time_limit_is_answered_as_a_timeout() + { + // What a real timeout throws, rather than an exception made up to look like one. + Exception timedOut; + await using (var scope = fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.SetCommandTimeout(1); + timedOut = await Record.ExceptionAsync(() => db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(5)")); + } + + var context = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + await Answer(context, timedOut); + + Assert.Equal(StatusCodes.Status504GatewayTimeout, context.Response.StatusCode); + context.Response.Body.Position = 0; + Assert.Contains(DatabaseTimeoutMiddleware.Code, await new StreamReader(context.Response.Body).ReadToEndAsync()); + + // Anything else fails as it did, a timeout outside the database included. + await Assert.ThrowsAsync(() => Answer(new DefaultHttpContext(), new InvalidOperationException())); + await Assert.ThrowsAsync(() => Answer(new DefaultHttpContext(), new TimeoutException())); + } + + private static Task Answer(HttpContext context, Exception thrown) => + new DatabaseTimeoutMiddleware(_ => throw thrown, NullLogger.Instance) + .InvokeAsync(context); +} diff --git a/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx index c8c71805..dd6f37c8 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx @@ -24,9 +24,10 @@ describe("errors from the API", () => { expect(refusal.errors).toEqual({ Name: ["A name is needed."], Port: ["The port must be a number."] }); }); - it("doesn't ask again for a refusal, but does for a server error or a dropped connection", () => { + it("doesn't ask again for a refusal or a timeout, but does for a server error or a dropped connection", () => { expect(shouldRetryQuery(0, new ApiRequestError("NOT_FOUND", "gone", 404))).toBe(false); expect(shouldRetryQuery(0, new ApiRequestError("HTTP_403", "no", 403))).toBe(false); + expect(shouldRetryQuery(0, new ApiRequestError("DATABASE_TIMED_OUT", "too long", 504))).toBe(false); expect(shouldRetryQuery(0, new ApiRequestError("ERROR", "boom", 500))).toBe(true); expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true); expect(shouldRetryQuery(3, new ApiRequestError("ERROR", "boom", 500))).toBe(false); diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 7fae7d9a..5fd6c969 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -155,11 +155,13 @@ export const isPermanentError = (error: unknown): boolean => /** * Whether a failed query is asked again. A refusal answers the same however often it is asked: - * retrying it only held "Loading…" on screen for the ~7s of backoff before the error showed. - * Server errors and dropped connections are retried, up to three times. + * retrying it only held "Loading…" on screen for the ~7s of backoff before the error showed. A + * timeout (504) takes as long again each time: three more of the database's 30 seconds held a list + * on "Loading…" for two minutes. Other server errors and dropped connections are retried, up to + * three times. */ export const shouldRetryQuery = (failureCount: number, error: unknown): boolean => - !isPermanentError(error) && failureCount < 3; + !isPermanentError(error) && !(error instanceof ApiRequestError && error.status === 504) && failureCount < 3; // --- Configuration entities (sub-phase 2) --- diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index 210f0375..3debcae3 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -12,7 +12,7 @@ import { Can } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; import { useBackgroundProgress } from "../../lib/useBackground"; import { followDownload } from "../../lib/saveFile"; -import { Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { Button, EmptyState, LoadError, LoadingBlock } from "../../components/ui/basics"; import { ConfirmDialog } from "../../components/ui/overlays"; import { openDialogs } from "../../components/ui/dialogStack"; import { useSubscriptionsCache } from "../../components/config/lookups"; @@ -109,10 +109,12 @@ export function ExchangesPage() { // keeps showing the last hour rather than the hour before the page was opened. // The rows alone, so they show as soon as they're found: how many match comes with the status // counts below, which can take far longer to work out on a busy instance. - const { data, isLoading, isFetching, isPlaceholderData, dataUpdatedAt, refetch } = useQuery({ + const { data, isLoading, isFetching, isPlaceholderData, isError, error, dataUpdatedAt, refetch } = useQuery({ queryKey: keys.exchanges.search(viewSearch(searchParams) + `&offset=${query.offset}`), queryFn: () => api.searchExchanges(readQuery(searchParams), { count: false }), - refetchInterval: refreshMs || false, + // A list that failed with nothing to show waits for Try again: asked every 15s, a timeout kept + // the database busy with the same query for as long as the page stayed open. + refetchInterval: (q) => (q.state.status === "error" && q.state.data === undefined ? false : refreshMs || false), placeholderData: keepPreviousData, }); // Loads someone is waiting on: a changed filter or page (the old rows stay up meanwhile), or the @@ -489,6 +491,9 @@ export function ExchangesPage() {
        {isLoading ? ( + ) : isError && !data ? ( + /* Without it, a list that couldn't be read said "No exchanges match", as if there were none. */ + void refetch()} /> ) : rows.length === 0 ? ( /* An empty page and an empty search are different problems. This branch replaces the table, paging footer and all, so a page past the end of the list would otherwise diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx index 381af84f..5bf1913d 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx @@ -412,6 +412,34 @@ describe("the exchanges list", () => { } }); + it("says when the list couldn't be read, rather than that nothing matches, and reads it again on Try again", async () => { + let timedOut = true; + const list = exchanges({ total: 3 }); + const { user } = renderApp("/exchanges?status=failed&latest=1", { + handlers: [ + http.get(/\/api\/xchanges(\?|$)/, () => + timedOut + ? HttpResponse.json( + { DATABASE_TIMED_OUT: ["The database took too long to answer. Asking for less, such as a shorter time range, may help."] }, + { status: 504 }, + ) + : undefined, + ), + list.handler, + ...filterOptions, + ], + }); + + const alert = await screen.findByRole("alert"); + expect(within(alert).getByText("Couldn't load the exchanges")).toBeVisible(); + expect(within(alert).getByText(/The database took too long to answer/)).toBeVisible(); + expect(screen.queryByText("No exchanges match")).not.toBeInTheDocument(); + + timedOut = false; + await user.click(within(alert).getByRole("button", { name: "Try again" })); + expect(await screen.findByText("Showing 1-3 of 3")).toBeVisible(); + }); + it("can show only the newest attempt of each chain", async () => { const latest = (q: URLSearchParams) => q.getAll("filter").includes("LatestOnly:1:true"); const list = exchanges({ total: (q) => (latest(q) ? 30 : 40) }); diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index c055fc65..a327f284 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -1088,6 +1088,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseHttpAsRequestContext(); SW.Logger.Console.IAppBuilderExtensions.UseRequestContextLogEnricher(app); + // Next to the endpoints, so it sees a timeout before anything turns it into a plain 500, + // and after the request context, so its log line carries the caller. + app.UseMiddleware(); + if (apiDocsExposed) app.UseSwaggerUI(c => { c.SwaggerEndpoint("/api/swagger.json", "Bitween Api"); }); diff --git a/docs/api-reference.md b/docs/api-reference.md index 5b1a6300..faf8d1b1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -16,6 +16,7 @@ | 401 | Not signed in, or the token has expired | | 403 | Signed in, but missing the permission | | 404 | Not found | +| 504 | `DATABASE_TIMED_OUT`: a database query ran past its time limit (30 seconds unless the connection string sets another). Asking again takes as long again, so ask for less, such as a shorter time range. PostgreSQL only; SQL Server and MySQL still answer 500. | ### Search queries diff --git a/docs/operations.md b/docs/operations.md index 02351be1..3cc07eef 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,7 +8,7 @@ The Exchanges page filters by status, subscription, partner, information type, exchange ids, correlation id, a promoted property value and a date range. Pick a promoted property to search inside its value only; any part of the value matches, ignoring case. Filters live in the URL, so a filtered view can be shared as a link. The list reloads itself every 15 seconds by default (adjustable, or paused); **Refresh**, beside the pause button, reloads the list and its counts right away. -Each status above the list shows how many exchanges it holds under the other filters, up to 10,000+. The list doesn't wait for them: its rows show at once, and how many match ("Showing 1-25 of ...") comes with the counts. A count that takes more than a few seconds, such as Processing across millions of exchanges, is finished on the server and shows up once it's ready; until then that status shows no number, and the page asks again every 15 seconds. The server runs 2 such counts at a time, each for up to 10 minutes, so one can wait its turn first; one whose page was closed before its turn is dropped. A finished count is kept for 5 minutes, then counted again. The chart works the same way. +Each status above the list shows how many exchanges it holds under the other filters, up to 10,000+. The list doesn't wait for them: its rows show at once, and how many match ("Showing 1-25 of ...") comes with the counts. A count that takes more than a few seconds, such as Processing across millions of exchanges, is finished on the server and shows up once it's ready; until then that status shows no number, and the page asks again every 15 seconds. The server runs 2 such counts at a time, each for up to 10 minutes, so one can wait its turn first; one whose page was closed before its turn is dropped. A finished count is kept for 5 minutes, then counted again. The chart works the same way. If the database takes too long to find the rows themselves, the list says so and offers **Try again**, rather than showing an empty list. - Search by **promoted property** to find a business document, such as an order number. - Search by **correlation id** to see every exchange in one flow, including exchanges created from responses. From 867d482e31df84cae2517eb79c66c844f57a6bb9 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 3 Nov 2026 11:23:50 +0300 Subject: [PATCH 15/24] fix: Exchanges counts stay on screen while another app copy is still counting --- .../src/pages/exchanges/ExchangesPage.tsx | 7 ++++ .../__tests__/ExchangesList.test.tsx | 33 +++++++++++++++++++ .../src/pages/exchanges/exchangeFilters.ts | 22 ++++++++++++- docs/operations.md | 2 +- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index 3debcae3..4a24e518 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -5,6 +5,7 @@ import { ArrowUp, Download, Pause, Play, Plus, RefreshCw, RotateCcw, TriangleAle import { api, type BulkRetrySelection, + type ExchangeBreakdown, type ExchangeStatus, type ExportFilesCheck, } from "../../api"; @@ -34,6 +35,7 @@ import { filterSignature, hasTimeFilter, idsIn, + keepCounted, readQuery, timeLabel, toLocalInput, @@ -133,11 +135,15 @@ export function ExchangesPage() { const breakdownKey = viewSearch(withParams(searchParams, { status: null })); const askAgain = (q: { state: { data?: { counting: boolean } } }) => refreshMs || (q.state.data?.counting ? COUNTING_MS : false); + // A number, once in, stays while a later answer is still counting it: see keepCounted. + const keepNumbers = (previous: unknown, next: unknown) => + keepCounted(previous as ExchangeBreakdown | undefined, next as ExchangeBreakdown); const breakdown = useQuery({ queryKey: keys.exchanges.breakdown(breakdownKey), queryFn: () => api.getExchangeBreakdown(readQuery(searchParams)), refetchInterval: askAgain, placeholderData: keepPreviousData, + structuralSharing: keepNumbers, retry: false, }); const chartParams = timed ? null : withParams(searchParams, { range: "24h", status: null }); @@ -147,6 +153,7 @@ export function ExchangesPage() { enabled: !!chartParams, refetchInterval: askAgain, placeholderData: keepPreviousData, + structuralSharing: keepNumbers, retry: false, }); const volume = timed ? breakdown.data : chartBreakdown.data; diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx index 5bf1913d..bbe51b7b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx @@ -412,6 +412,39 @@ describe("the exchanges list", () => { } }); + /** + * The server runs as more than one copy, each counting a slow view for itself: after a copy that + * has finished, one still counting can answer. + */ + it("keeps a number once it's in, while a later answer is still counting it", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + let asked = 0; + renderApp("/exchanges", { + handlers: [ + http.get(apiPath("/xchanges/summary"), () => + HttpResponse.json( + ++asked === 1 + ? { counts: { processing: 7, success: 0, badResponse: 0, failed: 3 }, buckets: [], bucketMinutes: 0, counting: false } + : { counts: { processing: null, success: 0, badResponse: 0, failed: 4 }, buckets: [], bucketMinutes: 0, counting: true }, + ), + ), + exchanges({ total: 3 }).handler, + ...filterOptions, + ], + }); + const tabs = within(await screen.findByRole("group", { name: "Status" })); + expect(await tabs.findByRole("button", { name: "Processing 7" })).toBeVisible(); + + // Live refresh asks again; the copy that answers has Failed but is still counting Processing. + await act(() => vi.advanceTimersByTimeAsync(15_000)); + expect(await tabs.findByRole("button", { name: "Failed 4" })).toBeVisible(); + expect(tabs.getByRole("button", { name: "Processing 7" })).toBeVisible(); + } finally { + vi.useRealTimers(); + } + }); + it("says when the list couldn't be read, rather than that nothing matches, and reads it again on Try again", async () => { let timedOut = true; const list = exchanges({ total: 3 }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/exchangeFilters.ts b/SW.Bitween.Web/ClientApp/src/pages/exchanges/exchangeFilters.ts index 101b593d..c1cd54dc 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/exchangeFilters.ts +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/exchangeFilters.ts @@ -1,4 +1,10 @@ -import type { ExchangePropertyFilter, ExchangeQuery, ExchangeStatus, NegatableExchangeFilter } from "../../api"; +import type { + ExchangeBreakdown, + ExchangePropertyFilter, + ExchangeQuery, + ExchangeStatus, + NegatableExchangeFilter, +} from "../../api"; /** Parameters to set, drop (null) or, for one that repeats, set to several values. */ export type ParamChanges = Record; @@ -149,6 +155,20 @@ export function allCount(counts: Record): number return counted.length === STATUSES.length || sum > COUNT_CAP ? sum : undefined; } +/** + * A later answer for the same view, with what it is still counting kept from the one before. The + * server runs as more than one copy, each counting a slow view for itself, so an answer from a copy + * still counting can follow one from a copy that has finished: the number on screen blinked out + * until the second caught up. + */ +export function keepCounted(previous: ExchangeBreakdown | undefined, next: ExchangeBreakdown): ExchangeBreakdown { + if (!previous) return next; + const counts = { ...next.counts }; + for (const s of STATUSES) counts[s] ??= previous.counts[s]; + const chart = next.counting && next.buckets.length === 0 ? previous : next; + return { ...next, counts, buckets: chart.buckets, bucketMinutes: chart.bucketMinutes }; +} + // --- time --- /** The quick picks in the time menu, in the order they are offered. */ diff --git a/docs/operations.md b/docs/operations.md index 3cc07eef..dca8c111 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,7 +8,7 @@ The Exchanges page filters by status, subscription, partner, information type, exchange ids, correlation id, a promoted property value and a date range. Pick a promoted property to search inside its value only; any part of the value matches, ignoring case. Filters live in the URL, so a filtered view can be shared as a link. The list reloads itself every 15 seconds by default (adjustable, or paused); **Refresh**, beside the pause button, reloads the list and its counts right away. -Each status above the list shows how many exchanges it holds under the other filters, up to 10,000+. The list doesn't wait for them: its rows show at once, and how many match ("Showing 1-25 of ...") comes with the counts. A count that takes more than a few seconds, such as Processing across millions of exchanges, is finished on the server and shows up once it's ready; until then that status shows no number, and the page asks again every 15 seconds. The server runs 2 such counts at a time, each for up to 10 minutes, so one can wait its turn first; one whose page was closed before its turn is dropped. A finished count is kept for 5 minutes, then counted again. The chart works the same way. If the database takes too long to find the rows themselves, the list says so and offers **Try again**, rather than showing an empty list. +Each status above the list shows how many exchanges it holds under the other filters, up to 10,000+. The list doesn't wait for them: its rows show at once, and how many match ("Showing 1-25 of ...") comes with the counts. A count that takes more than a few seconds, such as Processing across millions of exchanges, is finished on the server and shows up once it's ready; until then that status shows no number, and the page asks again every 15 seconds. The server runs 2 such counts at a time, each for up to 10 minutes, so one can wait its turn first; one whose page was closed before its turn is dropped. A finished count is kept for 5 minutes, then counted again, and a number stays on screen while it is. The chart works the same way. If the database takes too long to find the rows themselves, the list says so and offers **Try again**, rather than showing an empty list. - Search by **promoted property** to find a business document, such as an order number. - Search by **correlation id** to see every exchange in one flow, including exchanges created from responses. From 7c2053ffc62d552a4bf166f428d9d1f573006e0e Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 3 Nov 2026 11:40:30 +0300 Subject: [PATCH 16/24] feat: explain failures from the whole retry chain, on the error box --- .../Resources/XchangeExplanations/Explain.cs | 103 +++++++++++++----- SW.Bitween.Api/Services/Ai/FailurePrompt.cs | 5 + .../FailureExplanationTests.cs | 21 ++++ .../ClientApp/src/components/ui/Exception.tsx | 17 ++- .../src/pages/exchanges/ExchangeDrawer.tsx | 12 +- 5 files changed, 123 insertions(+), 35 deletions(-) diff --git a/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs b/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs index 306f72af..98e727fc 100644 --- a/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs +++ b/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs @@ -37,7 +37,8 @@ public class XchangeExplanationResponse /// /// What is sent, and nothing else: the error; which step failed; the subscription's kind and /// adapters, and the names - never the values - of their settings; the retry policy's shape and the -/// retry state; the last attempts' errors; and the documents with their values replaced by types. +/// retry state; the whole retry chain, each attempt's outcome and the first line of its error; and +/// the documents with their values replaced by types. /// Adapter settings are names only because the exchange holds them with partner and global values /// already filled in, and some of those are credentials. /// @@ -50,9 +51,6 @@ public class Explain(RequestContext requestContext, BitweenDbContext dbContext, AiModels models) : ICommandHandler { - /// Earlier attempts whose errors are worth showing beside this one. - const int EarlierAttempts = 3; - /// The largest document masked and sent; a few records are enough to explain a failure. const int MaxMaskedFileBytes = 1_000_000; @@ -97,8 +95,9 @@ public async Task Handle(XchangeExplanationRequest r sent.AppendLine("Handler settings (names only): " + Names(xchange.HandlerProperties)); sent.AppendLine("Document format: " + (document?.DocumentFormat.ToString() ?? "unknown")); if (result.ResponseBad) sent.AppendLine("The partner's response was marked as bad data."); - var earlier = await EarlierErrors(xchange); - sent.AppendLine($"Attempt {earlier.Chain + 1} of its retry chain"); + var chain = await Chain(xchange); + var position = chain.FindIndex(a => a.Id == xchange.Id) + 1; + sent.AppendLine($"This is attempt {position} of {chain.Count} in its retry chain" + (chain.Count >= MaxChain ? " (or more)" : "") + "."); if (!string.IsNullOrWhiteSpace(result.RetryBlockedReason)) sent.AppendLine("Why it won't be retried: " + masking.Error(result.RetryBlockedReason)); if (delayed is not null) sent.AppendLine($"A retry is scheduled for {delayed.On:u}."); @@ -109,14 +108,12 @@ public async Task Handle(XchangeExplanationRequest r sent.AppendLine(string.IsNullOrWhiteSpace(result.Exception) ? "(no error text; see the response)" : masking.Error(result.Exception)); sent.AppendLine(""); - // Only each earlier error's first line: the rest is a stack trace, nearly always the same one. - var thisError = FirstLine(result.Exception); - foreach (var (attempt, error) in earlier.Errors) + if (chain.Count > 1) { - var line = FirstLine(error); - sent.AppendLine(line == thisError - ? $"Earlier attempt {attempt}: the same error." - : $"Earlier attempt {attempt}: {masking.Error(line)}"); + sent.AppendLine(); + sent.AppendLine(""); + foreach (var line in ChainLines(chain, xchange.Id, masking)) sent.AppendLine(line); + sent.AppendLine(""); } foreach (var (name, text) in files) @@ -189,34 +186,80 @@ static string RetryPolicy(Subscription? subscription) (g.Budget is { } b ? $", up to {b.MaxAttemptsTotal} attempts" : "") + ")")); } - /// How many attempts came before this one, and the errors of the last few. + /// One attempt in a retry chain: null outcome fields while it is still running. + sealed record Attempt(string Id, System.DateTime StartedOn, bool? Success, bool ResponseBad, string? Error); + + /// + /// The whole retry chain this exchange is in, first attempt first: the ones before it and the + /// ones after, since an older attempt opened from a link still belongs to the chain's story. + /// /// - /// Counted along the chain rather than read from the result's attempt number, which counts only - /// the retries a policy made - five attempts by hand all say 1. + /// Walked along RetryFor rather than read from the result's attempt number, which counts only + /// the retries a policy made - five attempts by hand all say 1. An exchange is retried at most + /// once, so the chain has no branches. /// - async Task<(int Chain, List<(string Id, string Error)> Errors)> EarlierErrors(Xchange xchange) + async Task> Chain(Xchange xchange) { - var errors = new List<(string, string)>(); - var chain = 0; - var id = xchange.RetryFor; - while (id is not null && chain < MaxChain) + var ids = new List { xchange.Id }; + for (var id = xchange.RetryFor; id is not null && ids.Count < MaxChain;) { var previous = await dbContext.Set().AsNoTracking() .Where(x => x.Id == id).Select(x => new { x.Id, x.RetryFor }).FirstOrDefaultAsync(); if (previous is null) break; - chain++; - if (errors.Count < EarlierAttempts) - { - var error = await dbContext.Set().AsNoTracking() - .Where(r => r.Id == previous.Id).Select(r => r.Exception).FirstOrDefaultAsync(); - if (!string.IsNullOrWhiteSpace(error)) errors.Add((previous.Id, error)); - } + ids.Insert(0, previous.Id); id = previous.RetryFor; } - return (chain, errors); + for (var id = xchange.Id; ids.Count < MaxChain;) + { + var next = await dbContext.Set().AsNoTracking() + .Where(x => x.RetryFor == id).OrderBy(x => x.StartedOn).Select(x => x.Id).FirstOrDefaultAsync(); + if (next is null) break; + ids.Add(next); + id = next; + } + + // Only the start of each error: its first line is what is sent. + var started = await dbContext.Set().AsNoTracking() + .Where(x => ids.Contains(x.Id)).Select(x => new { x.Id, x.StartedOn }) + .ToDictionaryAsync(x => x.Id, x => x.StartedOn); + var results = await dbContext.Set().AsNoTracking() + .Where(r => ids.Contains(r.Id)) + .Select(r => new { r.Id, r.Success, r.ResponseBad, Error = r.Exception == null ? null : r.Exception.Substring(0, 1000) }) + .ToDictionaryAsync(r => r.Id); + return ids.Select(id => results.TryGetValue(id, out var r) + ? new Attempt(id, started.GetValueOrDefault(id), r.Success, r.ResponseBad, r.Error) + : new Attempt(id, started.GetValueOrDefault(id), null, false, null)) + .ToList(); + } + + /// + /// The chain as the model reads it: one line per attempt, its outcome and its error's first line, + /// with a run of the same outcome and error folded into one line. + /// + static IEnumerable ChainLines(List chain, string thisId, Masking masking) + { + string Describe(Attempt a) => + a.Success is null ? "still running" + : a.Success == true && !a.ResponseBad ? "succeeded" + : (a.ResponseBad ? "delivered, bad response" : "failed") + + (string.IsNullOrWhiteSpace(a.Error) ? "" : ": " + masking.Error(FirstLine(a.Error))); + + var i = 0; + while (i < chain.Count) + { + var what = Describe(chain[i]); + var j = i; + // The opened attempt keeps a line of its own, so the model knows which one it is explaining. + while (j + 1 < chain.Count && chain[j + 1].Id != thisId && chain[i].Id != thisId && Describe(chain[j + 1]) == what) j++; + var at = chain[i].StartedOn.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); + var which = i == j ? $"Attempt {i + 1}" : $"Attempts {i + 1}-{j + 1}"; + var mark = chain[i].Id == thisId ? " (this one)" : ""; + yield return $"{which}{mark}, from {at} UTC: {what}"; + i = j + 1; + } } - /// A chain is walked this far at most; a longer one says so as "50+" would, by stopping. + /// A chain is walked this far at most, both ways together. const int MaxChain = 50; static string FirstLine(string? text) => (text ?? "").Split('\n')[0].Trim(); diff --git a/SW.Bitween.Api/Services/Ai/FailurePrompt.cs b/SW.Bitween.Api/Services/Ai/FailurePrompt.cs index 297a24c7..a6554c17 100644 --- a/SW.Bitween.Api/Services/Ai/FailurePrompt.cs +++ b/SW.Bitween.Api/Services/Ai/FailurePrompt.cs @@ -20,6 +20,11 @@ public static class FailurePrompt over HTTP, and may get a response back. A failure is either in mapping or in delivery. Failed exchanges can be retried by a retry policy, or by hand. + When the exchange was retried, you also get its whole retry chain: every attempt in order, its + outcome and its error's first line. Use it: an error that repeats unchanged points at a lasting + problem, one that changed or passed points at a glitch or a fix that already happened. If a + later attempt succeeded, say so first. + Every value in the documents and the error has been masked before you see it: text is , numbers are , booleans are , credentials and tokens are , and long lists show their first entries. Keys, structure and the error's wording are real. Never guess at a masked value; reason from structure, diff --git a/SW.Bitween.HttpTests/FailureExplanationTests.cs b/SW.Bitween.HttpTests/FailureExplanationTests.cs index 19bcc5a3..1d854bde 100644 --- a/SW.Bitween.HttpTests/FailureExplanationTests.cs +++ b/SW.Bitween.HttpTests/FailureExplanationTests.cs @@ -97,6 +97,27 @@ public async Task A_failed_exchange_is_explained_from_masked_data_and_audited() Assert.True(audited, "the explanation wasn't recorded"); } + [Fact] + public async Task An_attempt_is_explained_with_its_whole_retry_chain_even_the_attempts_after_it() + { + var (admin, xchangeId) = await FailedExchangeAsync(); + var retried = await admin.PostAsJsonAsync($"/api/xchanges/{xchangeId}/retry", new { reason = "test", reset = false }); + Assert.True(retried.IsSuccessStatusCode, $"retry answered {retried.StatusCode}"); + await ConfigureAsync(admin); + var model = new FakeModel("""{ "summary": "S", "cause": "C", "fix": "F" }"""); + AiModels.ModelOverride = _ => model; + + // The first attempt, opened after its retry: the chain still shows the attempt after it. + await Api.Json(await admin.PostAsJsonAsync("/api/xchangeexplanations", new { xchangeId })); + + var sent = Assert.Single(model.Asked).Message; + Assert.Contains("This is attempt 1 of 2 in its retry chain.", sent); + Assert.Contains("", sent); + Assert.Contains("Attempt 1 (this one), from ", sent); + Assert.Contains("Attempt 2, from ", sent); + Assert.DoesNotContain("maybe", sent); + } + [Fact] public async Task Without_AI_set_up_nothing_is_read_or_sent() { diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/Exception.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/Exception.tsx index d9b85b89..4adf852a 100644 --- a/SW.Bitween.Web/ClientApp/src/components/ui/Exception.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/ui/Exception.tsx @@ -1,4 +1,5 @@ import { summarizeException } from "../../lib/exceptionSummary"; +import type { ReactNode } from "react"; import { TriangleAlert } from "lucide-react"; import { CopyButton } from "./CopyButton"; @@ -6,13 +7,25 @@ import { CopyButton } from "./CopyButton"; * A failure, as an operator needs it: the message in plain words first, and the .NET trace (with * the server's source paths) folded away behind "Technical details", to copy into a bug report. */ -export function ExceptionBlock({ text, title = "What went wrong" }: { text: string; title?: string }) { +export function ExceptionBlock({ + text, + title = "What went wrong", + action, +}: { + text: string; + title?: string; + /** A button for this failure, beside the copy button, where the eye already is. */ + action?: ReactNode; +}) { const { summary, kind, hasDetail } = summarizeException(text); return (

        {title}

        - +
        + {action} + +

        {summary}

        {kind &&

        {kind}

        } diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx index b8aa0777..e322a773 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx @@ -306,7 +306,13 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { )} {/* - failure - */} - {x.exception && } + {/* Explain sits on the error itself, where the eye already is; any viewer may ask, it changes nothing. */} + {x.exception && ( + } + /> + )} {/* - delivered, with a file that could not be stored - */} {x.warning && ( @@ -480,8 +486,8 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) {
        )} - {/* Outside the operate gate: an explanation changes nothing, so seeing the exchange is enough. */} - {(x.status === "failed" || x.status === "badResponse") && ( + {/* A bad response can come without error text: then Explain has no error box to sit on. */} + {!x.exception && (x.status === "failed" || x.status === "badResponse") && (
        From d690ea858e77cfd9cea6c137931832b76689a8ef Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 3 Nov 2026 11:43:56 +0300 Subject: [PATCH 17/24] fix: retry policy editor keeps Hold, and can set it --- .../ClientApp/src/api/http/retryPolicies.ts | 6 +++ SW.Bitween.Web/ClientApp/src/api/types.ts | 10 ++++- .../pages/retry-policies/RetryGroupPage.tsx | 36 ++++++++++++++++-- .../pages/retry-policies/RetryPolicyPage.tsx | 6 +++ .../__tests__/RetryGroupPage.test.tsx | 37 +++++++++++++++++-- docs/retries-and-alerts.md | 2 + 6 files changed, 89 insertions(+), 8 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts index 61d905b4..36b3f127 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts @@ -6,6 +6,7 @@ import { type RetryAlertLevel, type RetryAttempts, type RetryDelay, + type RetryExhaustedAction, type RetryGroup, type RetryPolicy, type RetryPolicyDetail, @@ -81,6 +82,8 @@ interface RawRetryBudget { maxAttemptsPerError: number; maxAttemptsTotal: number | null; delayStrategy: RawDelayStrategy; + /** Missing on policies saved before it existed: those fail, as they always did. */ + whenExhausted?: RetryExhaustedAction; } interface RawRetryGroup extends Omit { budget?: RawRetryBudget | null; @@ -139,6 +142,7 @@ const toGroup = ({ alertHandlerId: _retired, alertHandlerProperties: _retiredPro maxAttemptsPerError: g.budget.maxAttemptsPerError, maxAttemptsTotal: g.budget.maxAttemptsTotal, delay: toDelay(g.budget.delayStrategy), + whenExhausted: g.budget.whenExhausted ?? "Fail", } : undefined, // Named rather than left to the spread: a policy saved from an older client has no @@ -155,6 +159,8 @@ const toRawGroup = (g: RetryGroup): RawRetryGroup => ({ maxAttemptsPerError: g.budget.maxAttemptsPerError, maxAttemptsTotal: g.budget.maxAttemptsTotal, delayStrategy: toRawDelay(g.budget.delay), + // Sent back as read: leaving it out turned a held group into one that fails on every save. + whenExhausted: g.budget.whenExhausted, } : undefined, }); diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 7fae7d9a..bd1b8828 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -417,6 +417,8 @@ export interface RetryAlertConfig { alertChannelId: number | null; } +export type RetryExhaustedAction = "Fail" | "Hold"; + export interface RetryGroup { id: string; name: string; @@ -427,8 +429,12 @@ export interface RetryGroup { /** OR logic; empty = any failure of the applicable kind. */ matchers: RetryMatcher[]; action: "Allow" | "Block"; - /** `maxAttemptsTotal: null` is no group-wide limit - only the per-message cap applies. */ - budget?: { maxAttemptsPerError: number; maxAttemptsTotal: number | null; delay: RetryDelay }; + /** + * `maxAttemptsTotal: null` is no group-wide limit - only the per-message cap applies. + * `whenExhausted` is what happens to a failure once that total is spent: fail it for good, or + * hold it and retry it when the subscription next succeeds. + */ + budget?: { maxAttemptsPerError: number; maxAttemptsTotal: number | null; delay: RetryDelay; whenExhausted: RetryExhaustedAction }; notes?: string; /** Where this group's budget-exhausted alert goes, for every subscription using the policy. */ alertMode: RetryAlertMode; diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryGroupPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryGroupPage.tsx index 910859a2..4b691987 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryGroupPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryGroupPage.tsx @@ -2,7 +2,15 @@ import { useEffect, useRef, useState } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, Trash2 } from "lucide-react"; -import { api, type RetryAlertConfig, type RetryDelay, type RetryGroup, type RetryMatcher, type RetryResultType } from "../../api"; +import { + api, + type RetryAlertConfig, + type RetryDelay, + type RetryExhaustedAction, + type RetryGroup, + type RetryMatcher, + type RetryResultType, +} from "../../api"; import { keys } from "../../api/queryKeys"; import { useSessionCan } from "../../auth/useSessionCan"; import { useLeaveGuard } from "../../lib/useLeaveGuard"; @@ -178,7 +186,7 @@ interface GroupDraft { appliesTo: RetryResultType[]; action: RetryGroup["action"]; matchers: RetryMatcher[]; - budget: { maxAttemptsPerError: number; delay: RetryDelay; maxAttemptsTotal: number }; + budget: { maxAttemptsPerError: number; delay: RetryDelay; maxAttemptsTotal: number; whenExhausted: RetryExhaustedAction }; noTotal: boolean; notes: string; alert: RetryAlertConfig; @@ -192,7 +200,7 @@ const draftOf = (group: RetryGroup | undefined): GroupDraft => ({ action: group?.action ?? "Allow", matchers: group?.matchers ?? [], budget: { - ...(group?.budget ?? { maxAttemptsPerError: 3, delay: defaultDelayFor("exponential") }), + ...(group?.budget ?? { maxAttemptsPerError: 3, delay: defaultDelayFor("exponential"), whenExhausted: "Fail" as const }), // Kept while "No limit" is ticked, so unticking it brings back a number rather than a blank. maxAttemptsTotal: group?.budget?.maxAttemptsTotal ?? 10, }, @@ -534,6 +542,28 @@ export function RetryGroupPage({ isNew = false }: { isNew?: boolean }) {
        + {/* Only a total can run out, so without one there is nothing to choose. */} + {!draft.noTotal && ( + + + {" "} + · holds when spent + + )} ) : ( - diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryGroupPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryGroupPage.test.tsx index b96b0b04..12ad8ac4 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryGroupPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryGroupPage.test.tsx @@ -23,11 +23,14 @@ const timeouts = { }; /** The policy as the server has it, and whatever the page writes back to it. */ -function server() { - const written: { name: string; groups: { id: string; name: string; budget?: { maxAttemptsPerError: number } }[] }[] = []; +function server(groups: unknown[] = [timeouts]) { + const written: { + name: string; + groups: { id: string; name: string; budget?: { maxAttemptsPerError: number; whenExhausted?: string } }[]; + }[] = []; const handlers = [ http.get(apiPath("/retrypolicies/3"), () => - HttpResponse.json({ name: "Carrier API failures", groups: [timeouts], alertChannelId: 7 }), + HttpResponse.json({ name: "Carrier API failures", groups, alertChannelId: 7 }), ), http.get(apiPath("/subscriptions"), () => HttpResponse.json({ result: [], totalCount: 0 })), http.get(apiPath("/notificationchannels"), () => HttpResponse.json({ 7: "Ops email" })), @@ -58,6 +61,34 @@ describe("a retry group's page", () => { expect(written[0].groups.map((g) => [g.id, g.budget?.maxAttemptsPerError])).toEqual([["g-1", 5]]); }); + it("keeps a group that holds when its total runs out holding, through any other edit", async () => { + const holds = { ...timeouts, budget: { ...timeouts.budget, whenExhausted: "Hold" } }; + const { written, handlers } = server([holds]); + const { user } = renderApp("/retry-policies/3/groups/g-1?return=%2Felsewhere", { handlers }); + + const perMessage = await screen.findByLabelText("Retries per message"); + expect(screen.getByLabelText("When the total runs out")).toHaveValue("Hold"); + await user.clear(perMessage); + await user.type(perMessage, "5"); + await user.click(screen.getByRole("button", { name: "Save group" })); + + await waitFor(() => expect(written).toHaveLength(1)); + expect(written[0].groups[0].budget?.whenExhausted).toBe("Hold"); + }); + + it("can be set to hold when its total runs out", async () => { + const { written, handlers } = server(); + const { user } = renderApp("/retry-policies/3/groups/g-1?return=%2Felsewhere", { handlers }); + + const choice = await screen.findByLabelText("When the total runs out"); + expect(choice).toHaveValue("Fail"); + await user.selectOptions(choice, "Hold"); + await user.click(screen.getByRole("button", { name: "Save group" })); + + await waitFor(() => expect(written).toHaveLength(1)); + expect(written[0].groups[0].budget?.whenExhausted).toBe("Hold"); + }); + it("won't bring back a group that was removed while its page was open", async () => { const { written, handlers } = server(); let removed = false; diff --git a/docs/retries-and-alerts.md b/docs/retries-and-alerts.md index 4612e8a0..9d7f7e5c 100644 --- a/docs/retries-and-alerts.md +++ b/docs/retries-and-alerts.md @@ -106,6 +106,8 @@ A budget sets two limits. When a subscription succeeds, each of its budgets that was already exhausted before that exchange started is released. Partly spent budgets are left alone. +**`whenExhausted`** says what happens to a failure the group would retry once its total is spent. `Fail`, the default, fails it for good. `Hold` keeps it waiting and retries it as soon as the subscription next succeeds, so messages caught by a partner's outage go through once it is back. On a group's page it is **When the total runs out**, shown while the group has a total, and the policy's group list marks a held group "holds when spent". + ### Example group ```json From 806d91a6e1bf18d2721990190537c52958c47f32 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 3 Nov 2026 11:51:53 +0300 Subject: [PATCH 18/24] fix: exchanges quick wins review findings --- .../Services/DatabaseTimeoutMiddleware.cs | 32 ++++++---- .../BackgroundRequestTests.cs | 4 +- .../Tests/DatabaseTimeoutTests.cs | 31 ++++++++-- .../src/api/http/__tests__/errors.test.tsx | 1 + .../src/pages/exchanges/ExchangesPage.tsx | 9 ++- .../__tests__/ExchangesList.test.tsx | 58 +++++++++++-------- .../__tests__/exchangeFilters.test.ts | 31 ++++++++++ docs/api-reference.md | 2 +- 8 files changed, 126 insertions(+), 42 deletions(-) diff --git a/SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs b/SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs index 1f28a288..d19ba251 100644 --- a/SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs +++ b/SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs @@ -27,7 +27,9 @@ public async Task InvokeAsync(HttpContext context) { logger.LogError(ex, "{Method} {Path} ran past the database's time limit.", context.Request.Method, context.Request.Path); - context.Response.Clear(); + // Not Response.Clear(): that also drops the security and CORS headers set before the + // endpoint ran. Nothing has been written, so only a length set for another body is wrong. + context.Response.Headers.ContentLength = null; context.Response.StatusCode = StatusCodes.Status504GatewayTimeout; context.Response.ContentType = "application/json"; await context.Response.Body.WriteAsync(BackgroundRequests.Problem(Code, @@ -36,21 +38,29 @@ await context.Response.Body.WriteAsync(BackgroundRequests.Problem(Code, } /// - /// A command that ran out of time, as Npgsql throws it: its own exception around a - /// . Only inside a database exception, so a call elsewhere that - /// timed out isn't put down to the database. SQL Server and MySQL say it differently, and keep - /// the 500. + /// A command that ran out of time: past the connection's Command Timeout, which Npgsql throws as + /// its own exception around a , or past a statement_timeout + /// set on the database, which Postgres reports as 57014. A timeout only inside a database + /// exception, so a call elsewhere that timed out isn't put down to the database, and not a + /// cancellation the caller asked for, which Postgres also reports as 57014. SQL Server and MySQL + /// say it differently, and keep the 500. /// internal static bool IsDatabaseTimeout(Exception ex) { var inDatabase = false; for (var e = ex; e != null; e = e.InnerException) - { - if (e is DbException) - inDatabase = true; - else if (e is TimeoutException && inDatabase) - return true; - } + switch (e) + { + case OperationCanceledException: + return false; + case DbException { SqlState: "57014" }: + return true; + case DbException: + inDatabase = true; + break; + case TimeoutException when inDatabase: + return true; + } return false; } diff --git a/SW.Bitween.HttpTests/BackgroundRequestTests.cs b/SW.Bitween.HttpTests/BackgroundRequestTests.cs index f5adf189..06c04b40 100644 --- a/SW.Bitween.HttpTests/BackgroundRequestTests.cs +++ b/SW.Bitween.HttpTests/BackgroundRequestTests.cs @@ -62,7 +62,9 @@ public async Task A_read_that_does_not_ask_keeps_the_usual_limit() var response = await admin.SendAsync(Read("/api/slowreads/3")); - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + // Answered as the timeout it is, which the admin UI doesn't ask again for. + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + Assert.Contains("DATABASE_TIMED_OUT", await response.Content.ReadAsStringAsync()); } [Fact] diff --git a/SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs b/SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs index 06def121..cb34e2b5 100644 --- a/SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; @@ -21,19 +22,41 @@ public class DatabaseTimeoutTests(BitweenFixture fixture) [Fact] public async Task A_query_past_the_database_time_limit_is_answered_as_a_timeout() { - // What a real timeout throws, rather than an exception made up to look like one. - Exception timedOut; + // What real timeouts throw, rather than exceptions made up to look like them: past the + // connection's Command Timeout, past the database's own statement_timeout, and cancelled by + // the caller, which is not one. + Exception commandTimedOut, statementTimedOut, cancelled; await using (var scope = fixture.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.SetCommandTimeout(1); - timedOut = await Record.ExceptionAsync(() => db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(5)")); + commandTimedOut = await Record.ExceptionAsync(() => db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(5)")); } + await using (var scope = fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await db.Database.BeginTransactionAsync(); + await db.Database.ExecuteSqlRawAsync("SET LOCAL statement_timeout = 500"); + statementTimedOut = await Record.ExceptionAsync(() => db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(5)")); + } + + await using (var scope = fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + cancelled = await Record.ExceptionAsync(() => db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(5)", cancel.Token)); + } + + Assert.True(DatabaseTimeoutMiddleware.IsDatabaseTimeout(statementTimedOut), statementTimedOut?.ToString()); + Assert.False(DatabaseTimeoutMiddleware.IsDatabaseTimeout(cancelled), cancelled?.ToString()); + var context = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; - await Answer(context, timedOut); + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + await Answer(context, commandTimedOut); Assert.Equal(StatusCodes.Status504GatewayTimeout, context.Response.StatusCode); + Assert.Equal("nosniff", context.Response.Headers["X-Content-Type-Options"]); context.Response.Body.Position = 0; Assert.Contains(DatabaseTimeoutMiddleware.Code, await new StreamReader(context.Response.Body).ReadToEndAsync()); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx index dd6f37c8..8ff2b962 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx @@ -28,6 +28,7 @@ describe("errors from the API", () => { expect(shouldRetryQuery(0, new ApiRequestError("NOT_FOUND", "gone", 404))).toBe(false); expect(shouldRetryQuery(0, new ApiRequestError("HTTP_403", "no", 403))).toBe(false); expect(shouldRetryQuery(0, new ApiRequestError("DATABASE_TIMED_OUT", "too long", 504))).toBe(false); + expect(shouldRetryQuery(0, new ApiRequestError("ERROR", "gateway timeout", 504))).toBe(false); expect(shouldRetryQuery(0, new ApiRequestError("ERROR", "boom", 500))).toBe(true); expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true); expect(shouldRetryQuery(3, new ApiRequestError("ERROR", "boom", 500))).toBe(false); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index 4a24e518..12aaf798 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -601,8 +601,13 @@ export function ExchangesPage() { size="sm" /* Past the cap the total no longer says where the end is, so fall back to "was this page full" - otherwise Next would dead-end at row 10,000 - as while it is still - being counted. Below the cap the total is exact, so Next stops on the true last page. */ - disabled={total === undefined || totalIsCapped ? rows.length < PAGE_SIZE : query.offset + PAGE_SIZE >= total} + being counted, when the number on screen may be one kept from an earlier answer. + Otherwise the total is exact, so Next stops on the true last page. */ + disabled={ + total === undefined || totalIsCapped || breakdown.data?.counting + ? rows.length < PAGE_SIZE + : query.offset + PAGE_SIZE >= total + } onClick={() => apply({ offset: String(query.offset + PAGE_SIZE) }, { resetOffset: false })} > Next diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx index bbe51b7b..d8a61839 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx @@ -446,31 +446,43 @@ describe("the exchanges list", () => { }); it("says when the list couldn't be read, rather than that nothing matches, and reads it again on Try again", async () => { - let timedOut = true; - const list = exchanges({ total: 3 }); - const { user } = renderApp("/exchanges?status=failed&latest=1", { - handlers: [ - http.get(/\/api\/xchanges(\?|$)/, () => - timedOut - ? HttpResponse.json( - { DATABASE_TIMED_OUT: ["The database took too long to answer. Asking for less, such as a shorter time range, may help."] }, - { status: 504 }, - ) - : undefined, - ), - list.handler, - ...filterOptions, - ], - }); + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + let timedOut = true; + let listAsks = 0; + const list = exchanges({ total: 3 }); + renderApp("/exchanges?status=failed&latest=1", { + handlers: [ + http.get(/\/api\/xchanges(\?|$)/, () => { + listAsks++; + return timedOut + ? HttpResponse.json( + { DATABASE_TIMED_OUT: ["The database took too long to answer. Asking for less, such as a shorter time range, may help."] }, + { status: 504 }, + ) + : undefined; + }), + list.handler, + ...filterOptions, + ], + }); - const alert = await screen.findByRole("alert"); - expect(within(alert).getByText("Couldn't load the exchanges")).toBeVisible(); - expect(within(alert).getByText(/The database took too long to answer/)).toBeVisible(); - expect(screen.queryByText("No exchanges match")).not.toBeInTheDocument(); + const alert = await screen.findByRole("alert"); + expect(within(alert).getByText("Couldn't load the exchanges")).toBeVisible(); + expect(within(alert).getByText(/The database took too long to answer/)).toBeVisible(); + expect(screen.queryByText("No exchanges match")).not.toBeInTheDocument(); - timedOut = false; - await user.click(within(alert).getByRole("button", { name: "Try again" })); - expect(await screen.findByText("Showing 1-3 of 3")).toBeVisible(); + // Live refresh doesn't run the same query again meanwhile: it waits for Try again. + const asked = listAsks; + await act(() => vi.advanceTimersByTimeAsync(15_000)); + expect(listAsks).toBe(asked); + + timedOut = false; + fireEvent.click(within(alert).getByRole("button", { name: "Try again" })); + expect(await screen.findByText("Showing 1-3 of 3")).toBeVisible(); + } finally { + vi.useRealTimers(); + } }); it("can show only the newest attempt of each chain", async () => { diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/exchangeFilters.test.ts b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/exchangeFilters.test.ts index f0570ac3..e10cf119 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/exchangeFilters.test.ts +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/exchangeFilters.test.ts @@ -3,6 +3,7 @@ import { filterChips, filterSignature, idsIn, + keepCounted, notWith, parseBound, propertyFilters, @@ -253,3 +254,33 @@ describe("the search box", () => { expect(first.changes).toEqual({ prop: ["city:paris", "orderNo:SO-1"], propertyKey: null, property: null, not: null }); }); }); + +/** A later answer that is still counting, from an app copy that hasn't caught up yet. */ +describe("keeping what was counted", () => { + const bucket = { start: "2026-11-03T10:00:00Z", processing: 0, success: 5, badResponse: 0, failed: 1 }; + const counted = { + counts: { processing: 7, success: 100, badResponse: 0, failed: 3 }, + buckets: [bucket], + bucketMinutes: 60, + counting: false, + }; + + it("keeps a number, and the chart, that the later answer is still counting", () => { + const later = keepCounted(counted, { + counts: { processing: null, success: 101, badResponse: 0, failed: 4 }, + buckets: [], + bucketMinutes: 0, + counting: true, + }); + expect(later.counts).toEqual({ processing: 7, success: 101, badResponse: 0, failed: 4 }); + expect(later.buckets).toEqual([bucket]); + expect(later.bucketMinutes).toBe(60); + // Still asked again until the rest is in. + expect(later.counting).toBe(true); + }); + + it("takes the later answer whole once it has counted it all, an empty chart included", () => { + const later = { counts: { processing: 0, success: 101, badResponse: 0, failed: 4 }, buckets: [], bucketMinutes: 0, counting: false }; + expect(keepCounted(counted, later)).toEqual(later); + }); +}); diff --git a/docs/api-reference.md b/docs/api-reference.md index faf8d1b1..a040b3dd 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -16,7 +16,7 @@ | 401 | Not signed in, or the token has expired | | 403 | Signed in, but missing the permission | | 404 | Not found | -| 504 | `DATABASE_TIMED_OUT`: a database query ran past its time limit (30 seconds unless the connection string sets another). Asking again takes as long again, so ask for less, such as a shorter time range. PostgreSQL only; SQL Server and MySQL still answer 500. | +| 504 | `DATABASE_TIMED_OUT`: a database query ran past its time limit: the connection's (30 seconds unless the connection string sets another), or a `statement_timeout` set on the database. Asking again takes as long again, so ask for less, such as a shorter time range. PostgreSQL only; SQL Server and MySQL still answer 500. | ### Search queries From eb9954fcd3fdbfeb36b6300f6ac4bf3b98d8514f Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 3 Nov 2026 12:52:28 +0300 Subject: [PATCH 19/24] fix: explanation and retry editor review findings --- .../Resources/XchangeExplanations/Explain.cs | 167 +++++++++++++----- SW.Bitween.Api/Services/Ai/FailurePrompt.cs | 5 +- .../FailureExplanationTests.cs | 16 +- SW.Bitween.UnitTests/Ai/ExplainChainTests.cs | 62 +++++++ .../ClientApp/src/components/ui/Exception.tsx | 2 +- .../pages/retry-policies/RetryPolicyPage.tsx | 12 +- .../__tests__/RetryPolicyPage.test.tsx | 52 ++++++ docs/retries-and-alerts.md | 4 +- 8 files changed, 266 insertions(+), 54 deletions(-) create mode 100644 SW.Bitween.UnitTests/Ai/ExplainChainTests.cs create mode 100644 SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryPolicyPage.test.tsx diff --git a/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs b/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs index 98e727fc..c123eb91 100644 --- a/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs +++ b/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs @@ -1,4 +1,5 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -81,6 +82,8 @@ public async Task Handle(XchangeExplanationRequest r await AddFile(files, masking, xchange, XchangeFileType.Input, xchange.InputSize, xchange.InputContentType ?? ContentTypeOf(document)); await AddFile(files, masking, xchange, XchangeFileType.Output, result.OutputSize, result.OutputContentType); await AddFile(files, masking, xchange, XchangeFileType.Response, result.ResponseSize, result.ResponseContentType); + var chain = await Chain(xchange); + await SeeOtherAttempts(chain, xchange.Id, result.Exception, masking); sent.AppendLine("Failed step: " + FailedStep(xchange, result)); if (subscription is not null) @@ -95,9 +98,7 @@ public async Task Handle(XchangeExplanationRequest r sent.AppendLine("Handler settings (names only): " + Names(xchange.HandlerProperties)); sent.AppendLine("Document format: " + (document?.DocumentFormat.ToString() ?? "unknown")); if (result.ResponseBad) sent.AppendLine("The partner's response was marked as bad data."); - var chain = await Chain(xchange); - var position = chain.FindIndex(a => a.Id == xchange.Id) + 1; - sent.AppendLine($"This is attempt {position} of {chain.Count} in its retry chain" + (chain.Count >= MaxChain ? " (or more)" : "") + "."); + sent.AppendLine($"This is attempt {chain.Position} of {chain.Total}{(chain.More ? " or more" : "")} in its retry chain."); if (!string.IsNullOrWhiteSpace(result.RetryBlockedReason)) sent.AppendLine("Why it won't be retried: " + masking.Error(result.RetryBlockedReason)); if (delayed is not null) sent.AppendLine($"A retry is scheduled for {delayed.On:u}."); @@ -108,7 +109,7 @@ public async Task Handle(XchangeExplanationRequest r sent.AppendLine(string.IsNullOrWhiteSpace(result.Exception) ? "(no error text; see the response)" : masking.Error(result.Exception)); sent.AppendLine(""); - if (chain.Count > 1) + if (chain.Total > 1) { sent.AppendLine(); sent.AppendLine(""); @@ -186,83 +187,167 @@ static string RetryPolicy(Subscription? subscription) (g.Budget is { } b ? $", up to {b.MaxAttemptsTotal} attempts" : "") + ")")); } - /// One attempt in a retry chain: null outcome fields while it is still running. - sealed record Attempt(string Id, System.DateTime StartedOn, bool? Success, bool ResponseBad, string? Error); + /// One attempt in a retry chain, numbered from the chain's first; no outcome while it is still running. + internal sealed record Attempt(string Id, int Number, System.DateTime StartedOn, bool? Success, bool ResponseBad, string? Error); + + /// The attempts listed, where the explained one stands, and how many weren't listed. + internal sealed record RetryChain(List Attempts, int Position, int Total, bool More, int EarlierNotListed); + + /// Attempts listed before and after the explained one; each side has its own room. + const int ListedBefore = 30, ListedAfter = 20; + + /// How far the chain is followed each way to count it; past this it says "or more". + const int Walked = 200; + + /// How much of each attempt's error is read: its first line, nearly always. + internal const int ErrorRead = 4_000; + + /// The longest line an attempt's error is sent as, once masked. + internal const int MaxLineLength = 500; + + /// Attempts with an error of their own whose documents are read to mask it. + const int OtherAttemptsRead = 10; /// - /// The whole retry chain this exchange is in, first attempt first: the ones before it and the - /// ones after, since an older attempt opened from a link still belongs to the chain's story. + /// The retry chain this exchange is in: the attempts before it and after it, since an older + /// attempt opened from a link still belongs to the chain's story. /// /// /// Walked along RetryFor rather than read from the result's attempt number, which counts only /// the retries a policy made - five attempts by hand all say 1. An exchange is retried at most - /// once, so the chain has no branches. + /// once, so the chain has no branches. Each side is counted in full up to , + /// and listed nearest first up to its own limit, so a long past never crowds out a later success. /// - async Task> Chain(Xchange xchange) + async Task Chain(Xchange xchange) { - var ids = new List { xchange.Id }; - for (var id = xchange.RetryFor; id is not null && ids.Count < MaxChain;) + var before = new List(); + var earlier = 0; + var more = false; + for (var id = xchange.RetryFor; id is not null;) { + if (earlier == Walked) { more = true; break; } var previous = await dbContext.Set().AsNoTracking() .Where(x => x.Id == id).Select(x => new { x.Id, x.RetryFor }).FirstOrDefaultAsync(); if (previous is null) break; - ids.Insert(0, previous.Id); + earlier++; + if (before.Count < ListedBefore) before.Add(previous.Id); id = previous.RetryFor; } - for (var id = xchange.Id; ids.Count < MaxChain;) + var after = new List(); + var later = 0; + for (var id = xchange.Id;;) { + if (later == Walked) { more = true; break; } var next = await dbContext.Set().AsNoTracking() .Where(x => x.RetryFor == id).OrderBy(x => x.StartedOn).Select(x => x.Id).FirstOrDefaultAsync(); if (next is null) break; - ids.Add(next); + later++; + if (after.Count < ListedAfter) after.Add(next); id = next; } - // Only the start of each error: its first line is what is sent. - var started = await dbContext.Set().AsNoTracking() - .Where(x => ids.Contains(x.Id)).Select(x => new { x.Id, x.StartedOn }) - .ToDictionaryAsync(x => x.Id, x => x.StartedOn); - var results = await dbContext.Set().AsNoTracking() - .Where(r => ids.Contains(r.Id)) - .Select(r => new { r.Id, r.Success, r.ResponseBad, Error = r.Exception == null ? null : r.Exception.Substring(0, 1000) }) + before.Reverse(); + var ids = before.Append(xchange.Id).Concat(after).ToList(); + // One read for each attempt and its result, so one deleted meanwhile is gone, not "still running". + var rows = await ( + from x in dbContext.Set().AsNoTracking() + where ids.Contains(x.Id) + join r in dbContext.Set().AsNoTracking() on x.Id equals r.Id into results + from r in results.DefaultIfEmpty() + select new + { + x.Id, x.StartedOn, + Success = r == null ? (bool?)null : r.Success, + ResponseBad = r != null && r.ResponseBad, + Error = r == null || r.Exception == null ? null : r.Exception.Substring(0, ErrorRead), + }) .ToDictionaryAsync(r => r.Id); - return ids.Select(id => results.TryGetValue(id, out var r) - ? new Attempt(id, started.GetValueOrDefault(id), r.Success, r.ResponseBad, r.Error) - : new Attempt(id, started.GetValueOrDefault(id), null, false, null)) + + var first = earlier - before.Count + 1; + var attempts = ids.Select((id, i) => (id, number: first + i)) + .Where(a => rows.ContainsKey(a.id)) + .Select(a => + { + var r = rows[a.id]; + return new Attempt(a.id, a.number, r.StartedOn, r.Success, r.ResponseBad, r.Error); + }) .ToList(); + return new RetryChain(attempts, earlier + 1, earlier + 1 + later, more, earlier - before.Count); + } + + /// + /// Reads the documents of the other attempts whose error differs from this one's, so masking + /// knows their values too: a later attempt's response can hold what this one's documents don't. + /// + async Task SeeOtherAttempts(RetryChain chain, string thisId, string? thisError, Masking masking) + { + var thisLine = WholeFirstLine(thisError ?? ""); + var ids = chain.Attempts + .Where(a => a.Id != thisId && !string.IsNullOrWhiteSpace(a.Error) && WholeFirstLine(a.Error) != thisLine) + .Select(a => a.Id).Take(OtherAttemptsRead).ToList(); + if (ids.Count == 0) return; + var xchanges = await dbContext.Set().AsNoTracking().Where(x => ids.Contains(x.Id)).ToListAsync(); + var results = await dbContext.Set().AsNoTracking().Where(r => ids.Contains(r.Id)).ToDictionaryAsync(r => r.Id); + foreach (var x in xchanges) + { + if (!results.TryGetValue(x.Id, out var r)) continue; + var ignored = new List<(string, string)>(); + await AddFile(ignored, masking, x, XchangeFileType.Output, r.OutputSize, r.OutputContentType); + await AddFile(ignored, masking, x, XchangeFileType.Response, r.ResponseSize, r.ResponseContentType); + } } /// /// The chain as the model reads it: one line per attempt, its outcome and its error's first line, /// with a run of the same outcome and error folded into one line. /// - static IEnumerable ChainLines(List chain, string thisId, Masking masking) + internal static IEnumerable ChainLines(RetryChain chain, string thisId, Masking masking) { - string Describe(Attempt a) => - a.Success is null ? "still running" - : a.Success == true && !a.ResponseBad ? "succeeded" - : (a.ResponseBad ? "delivered, bad response" : "failed") + - (string.IsNullOrWhiteSpace(a.Error) ? "" : ": " + masking.Error(FirstLine(a.Error))); + string Describe(Attempt a) + { + if (a.Success is null) return "still running"; + if (a.Success == true && !a.ResponseBad) return "succeeded"; + var what = a.ResponseBad ? "delivered, bad response" : "failed"; + if (string.IsNullOrWhiteSpace(a.Error)) return what; + // Masked whole, then cut: a cut first could leave half a value masking can't recognise. + var line = masking.Error(WholeFirstLine(a.Error)); + return $"{what}: {(line.Length > MaxLineLength ? line[..MaxLineLength] + " ..." : line)}"; + } + if (chain.EarlierNotListed > 0) + yield return $"({chain.EarlierNotListed} earlier attempts not listed)"; + var list = chain.Attempts; var i = 0; - while (i < chain.Count) + while (i < list.Count) { - var what = Describe(chain[i]); + var what = Describe(list[i]); var j = i; // The opened attempt keeps a line of its own, so the model knows which one it is explaining. - while (j + 1 < chain.Count && chain[j + 1].Id != thisId && chain[i].Id != thisId && Describe(chain[j + 1]) == what) j++; - var at = chain[i].StartedOn.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); - var which = i == j ? $"Attempt {i + 1}" : $"Attempts {i + 1}-{j + 1}"; - var mark = chain[i].Id == thisId ? " (this one)" : ""; + while (j + 1 < list.Count && list[j + 1].Id != thisId && list[i].Id != thisId && Describe(list[j + 1]) == what) j++; + var at = list[i].StartedOn.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); + var which = i == j ? $"Attempt {list[i].Number}" : $"Attempts {list[i].Number}-{list[j].Number}"; + var mark = list[i].Id == thisId ? " (this one)" : ""; yield return $"{which}{mark}, from {at} UTC: {what}"; i = j + 1; } } - /// A chain is walked this far at most, both ways together. - const int MaxChain = 50; - - static string FirstLine(string? text) => (text ?? "").Split('\n')[0].Trim(); + /// + /// An error's first line. Only characters are read, so a first line that + /// runs past them is cut back to before its last, possibly partial, word - and before a quote it + /// opened and never closed - rather than send half a value masking can't recognise. + /// + internal static string WholeFirstLine(string text) + { + var newline = text.IndexOf('\n'); + if (newline >= 0) return text[..newline].Trim(); + if (text.Length < ErrorRead) return text.Trim(); + var end = Math.Max(text.LastIndexOf(' '), 0); + foreach (var quote in new[] { '\'', '"' }) + if (text[..end].Count(c => c == quote) % 2 == 1) + end = text.LastIndexOf(quote, end - 1); + return (end > 0 ? text[..end].Trim() + " " : "") + "..."; + } static string? ContentTypeOf(Document? document) => document?.DocumentFormat switch { diff --git a/SW.Bitween.Api/Services/Ai/FailurePrompt.cs b/SW.Bitween.Api/Services/Ai/FailurePrompt.cs index a6554c17..35fa6beb 100644 --- a/SW.Bitween.Api/Services/Ai/FailurePrompt.cs +++ b/SW.Bitween.Api/Services/Ai/FailurePrompt.cs @@ -22,8 +22,9 @@ public static class FailurePrompt When the exchange was retried, you also get its whole retry chain: every attempt in order, its outcome and its error's first line. Use it: an error that repeats unchanged points at a lasting - problem, one that changed or passed points at a glitch or a fix that already happened. If a - later attempt succeeded, say so first. + problem - unless it is one a retry can outlast (a timeout, a 5xx, a rate limit), which repeating + means an outage still going on. One that changed or passed points at a glitch or a fix that + already happened. If a later attempt succeeded, say so first. Every value in the documents and the error has been masked before you see it: text is , numbers are , booleans are , credentials and tokens are , and long lists show their first entries. Keys, diff --git a/SW.Bitween.HttpTests/FailureExplanationTests.cs b/SW.Bitween.HttpTests/FailureExplanationTests.cs index 1d854bde..d5132436 100644 --- a/SW.Bitween.HttpTests/FailureExplanationTests.cs +++ b/SW.Bitween.HttpTests/FailureExplanationTests.cs @@ -103,6 +103,13 @@ public async Task An_attempt_is_explained_with_its_whole_retry_chain_even_the_at var (admin, xchangeId) = await FailedExchangeAsync(); var retried = await admin.PostAsJsonAsync($"/api/xchanges/{xchangeId}/retry", new { reason = "test", reset = false }); Assert.True(retried.IsSuccessStatusCode, $"retry answered {retried.StatusCode}"); + // The retry fails the same way; waited for, so its outcome is in the chain rather than "still running". + for (var waited = 0; !await fixture.InDbAsync(db => db.Set().AsNoTracking() + .AnyAsync(r => db.Set().Any(x => x.RetryFor == xchangeId && x.Id == r.Id))); waited += 100) + { + Assert.True(waited < 15_000, "the retry never finished"); + await Task.Delay(100); + } await ConfigureAsync(admin); var model = new FakeModel("""{ "summary": "S", "cause": "C", "fix": "F" }"""); AiModels.ModelOverride = _ => model; @@ -113,8 +120,13 @@ public async Task An_attempt_is_explained_with_its_whole_retry_chain_even_the_at var sent = Assert.Single(model.Asked).Message; Assert.Contains("This is attempt 1 of 2 in its retry chain.", sent); Assert.Contains("", sent); - Assert.Contains("Attempt 1 (this one), from ", sent); - Assert.Contains("Attempt 2, from ", sent); + var chain = sent[sent.IndexOf("")..sent.IndexOf("")].Split('\n'); + var first = Assert.Single(chain, l => l.StartsWith("Attempt 1 (this one), from ")); + var second = Assert.Single(chain, l => l.StartsWith("Attempt 2, from ")); + // Both failed in mapping, the same way: each line says so, with the error's wording. + Assert.Contains(" UTC: failed: ", first); + Assert.Contains(" UTC: failed: ", second); + Assert.Equal(first[first.IndexOf(" UTC: ")..], second[second.IndexOf(" UTC: ")..]); Assert.DoesNotContain("maybe", sent); } diff --git a/SW.Bitween.UnitTests/Ai/ExplainChainTests.cs b/SW.Bitween.UnitTests/Ai/ExplainChainTests.cs new file mode 100644 index 00000000..cd7c9fd3 --- /dev/null +++ b/SW.Bitween.UnitTests/Ai/ExplainChainTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Resources.XchangeExplanations; +using SW.Bitween.Services.Ai; + +namespace SW.Bitween.UnitTests.Ai; + +/// +/// The retry chain as an explanation sends it: numbered from the chain's real first attempt, each +/// error masked whole before it is cut, so half a value never goes out. +/// +[TestClass] +public class ExplainChainTests +{ + static readonly DateTime At = new(2026, 11, 3, 9, 0, 0); + + static Explain.Attempt Failed(string id, int number, string error) => new(id, number, At, false, false, error); + + [TestMethod] + public void A_long_chain_is_numbered_from_its_real_first_attempt_and_says_what_it_left_out() + { + var chain = new Explain.RetryChain( + [Failed("a", 41, "Timeout"), Failed("b", 42, "Timeout"), new("c", 43, At, true, false, null)], + Position: 42, Total: 43, More: false, EarlierNotListed: 40); + + var lines = Explain.ChainLines(chain, "b", new Masking()).ToList(); + + CollectionAssert.AreEqual(new[] + { + "(40 earlier attempts not listed)", + "Attempt 41, from 2026-11-03 09:00 UTC: failed: Timeout", + "Attempt 42 (this one), from 2026-11-03 09:00 UTC: failed: Timeout", + "Attempt 43, from 2026-11-03 09:00 UTC: succeeded", + }, lines); + } + + [TestMethod] + public void An_error_is_masked_whole_before_it_is_cut() + { + // A first line longer than what is read, with a quoted name right where the read stops. + var read = new string('x', Explain.ErrorRead - 20) + " value 'Lina Haddad' was rejected"; + var error = read[..Explain.ErrorRead]; + var chain = new Explain.RetryChain([Failed("a", 1, error), Failed("b", 2, "Timeout")], 2, 2, false, 0); + + var line = Explain.ChainLines(chain, "b", new Masking()).First(); + + Assert.IsFalse(line.Contains("lina"), line); + Assert.IsTrue(line.Length < Explain.MaxLineLength + 100, $"{line.Length} characters"); + } + + [TestMethod] + public void A_first_line_read_only_in_part_loses_its_last_word() + { + var text = "Refused for customer " + new string('y', Explain.ErrorRead); + Assert.AreEqual("Refused for customer ...", Explain.WholeFirstLine(text[..Explain.ErrorRead])); + var quoted = "Refused for 'Lina " + new string('y', Explain.ErrorRead); + Assert.AreEqual("Refused for ...", Explain.WholeFirstLine(quoted[..Explain.ErrorRead])); + Assert.AreEqual("Timeout", Explain.WholeFirstLine("Timeout\n at Foo()")); + } +} diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/Exception.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/Exception.tsx index 4adf852a..bad80b8f 100644 --- a/SW.Bitween.Web/ClientApp/src/components/ui/Exception.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/ui/Exception.tsx @@ -20,7 +20,7 @@ export function ExceptionBlock({ const { summary, kind, hasDetail } = summarizeException(text); return (
        -
        +

        {title}

        {action} diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx index dc30d847..d3687fa8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx @@ -234,12 +234,12 @@ export function RetryPolicyPage() { }, [policy.data, loaded, name, alertChannelId]); const save = useMutation({ - mutationFn: () => - api.updateRetryPolicy(policyId, { - name, - groups: policy.data!.groups, - alertChannelId, - }), + // The groups as the server has them now, not as cached: another tab may have changed one + // since, and writing the cached copy back would undo it - a group set to Hold among them. + mutationFn: async () => { + const fresh = await api.getRetryPolicy(policyId); + await api.updateRetryPolicy(policyId, { name, groups: fresh.groups, alertChannelId }); + }, onSuccess: async () => { // Awaited before the draft is re-synced, or the re-sync would seed from stale data. await queryClient.invalidateQueries({ queryKey: keys.retryPolicies.all }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryPolicyPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryPolicyPage.test.tsx new file mode 100644 index 00000000..b8f59bb4 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryPolicyPage.test.tsx @@ -0,0 +1,52 @@ +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** A retry policy's own page: saving its name and alert writes the groups back as the server has them. */ + +const group = (whenExhausted: "Fail" | "Hold") => ({ + id: "g-1", + name: "Timeouts", + priority: 10, + enabled: true, + appliesTo: ["Error"], + matchers: [{ type: "contains", value: "timeout", caseSensitive: false }], + action: "Allow", + budget: { maxAttemptsPerError: 3, maxAttemptsTotal: 10, delayStrategy: { type: "fixed", delayMs: 60000 }, whenExhausted }, + notes: null, + alertMode: "Inherit", + alertChannelId: null, +}); + +describe("a retry policy's page", () => { + it("doesn't undo a group changed in another tab when its name is saved", async () => { + const written: { name: string; groups: { budget?: { whenExhausted?: string } }[] }[] = []; + let reads = 0; + const { user } = renderApp("/retry-policies/3", { + handlers: [ + // The page loads the group failing; by the time it saves, another tab has set it to Hold. + http.get(apiPath("/retrypolicies/3"), () => + HttpResponse.json({ name: "Carrier API failures", groups: [group(reads++ === 0 ? "Fail" : "Hold")], alertChannelId: null }), + ), + http.get(apiPath("/subscriptions"), () => HttpResponse.json({ result: [], totalCount: 0 })), + http.get(apiPath("/notificationchannels"), () => HttpResponse.json({})), + http.post(apiPath("/retrypolicies/3/usage"), () => HttpResponse.json([])), + http.get(apiPath("/audit"), () => HttpResponse.json({ result: [], totalCount: 0 })), + http.post(apiPath("/retrypolicies/3"), async ({ request }) => { + written.push((await request.json()) as (typeof written)[number]); + return new HttpResponse(null, { status: 204 }); + }), + ], + }); + + const name = await screen.findByLabelText("Name"); + await user.clear(name); + await user.type(name, "Carrier failures"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(written).toHaveLength(1)); + expect(written[0].name).toBe("Carrier failures"); + expect(written[0].groups[0].budget?.whenExhausted).toBe("Hold"); + }); +}); diff --git a/docs/retries-and-alerts.md b/docs/retries-and-alerts.md index 9d7f7e5c..1c24d5c4 100644 --- a/docs/retries-and-alerts.md +++ b/docs/retries-and-alerts.md @@ -138,10 +138,10 @@ When a subscription succeeds, each of its budgets that was already exhausted bef 4. If no group matched, there is no retry. 5. If the group blocks, there is no retry. 6. If the message reached `maxAttemptsPerError`, there is no retry. This is checked first, so it does not spend the shared total. -7. If the shared total is spent, there is no retry. When this failure is the one that used up the total, an alert is raised. +7. If the shared total is spent, there is no retry, unless the group's `whenExhausted` is `Hold`: then the failure waits, with no reason recorded, and is retried when the subscription next succeeds. Either way, when this failure is the one that used up the total, an alert is raised. 8. Otherwise a retry is scheduled after the delay, give or take a tenth, or later when the adapter [said when to try again](#when-an-adapter-says-when-to-try-again). -When no retry is scheduled, the exchange result records one of these reasons. +When no retry is scheduled, and the failure isn't held, the exchange result records one of these reasons. - No matching group (default block) - Group '…' explicitly blocks this error From a158d1dd6e41944c09523cab8e8ceabb68b38ee6 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 3 Nov 2026 14:21:15 +0300 Subject: [PATCH 20/24] fix: a failed background export offers no Download --- .../Background/BackgroundRequestHub.cs | 4 ++ .../Services/Background/BackgroundRequests.cs | 1 + .../ClientApp/src/api/http/background.ts | 2 + .../src/components/layout/BackgroundWork.tsx | 45 ++++++++++++------- .../layout/__tests__/BackgroundWork.test.tsx | 26 +++++++++++ 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/SW.Bitween.Api/Services/Background/BackgroundRequestHub.cs b/SW.Bitween.Api/Services/Background/BackgroundRequestHub.cs index 5117ad8e..c5cc2b50 100644 --- a/SW.Bitween.Api/Services/Background/BackgroundRequestHub.cs +++ b/SW.Bitween.Api/Services/Background/BackgroundRequestHub.cs @@ -37,6 +37,9 @@ public class BackgroundRequestChanged public int? Total { get; set; } public string Summary { get; set; } + /// The HTTP status it was answered with, once done: an error answer is still Done. + public int? ResultStatus { get; set; } + /// The that sent it, which told its own already. public string Node { get; set; } @@ -46,6 +49,7 @@ public class BackgroundRequestChanged Owner = row.Owner, Status = row.Status, Done = row.Done, + ResultStatus = row.ResultStatus, Total = row.Total, Summary = row.Summary, Node = node, diff --git a/SW.Bitween.Api/Services/Background/BackgroundRequests.cs b/SW.Bitween.Api/Services/Background/BackgroundRequests.cs index cfc78195..6c3e4653 100644 --- a/SW.Bitween.Api/Services/Background/BackgroundRequests.cs +++ b/SW.Bitween.Api/Services/Background/BackgroundRequests.cs @@ -493,6 +493,7 @@ internal async Task PushAsync(BackgroundRequestChanged change) done = change.Done, total = change.Total, summary = change.Summary, + resultStatus = change.ResultStatus, }); } catch (Exception ex) diff --git a/SW.Bitween.Web/ClientApp/src/api/http/background.ts b/SW.Bitween.Web/ClientApp/src/api/http/background.ts index 199bbe13..36d71809 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/background.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/background.ts @@ -29,6 +29,8 @@ export interface BackgroundRequest { done?: number | null; total?: number | null; summary?: string | null; + /** The HTTP status it was answered with, once done: an error answer is still "Done". */ + resultStatus?: number | null; } export interface BackgroundState { diff --git a/SW.Bitween.Web/ClientApp/src/components/layout/BackgroundWork.tsx b/SW.Bitween.Web/ClientApp/src/components/layout/BackgroundWork.tsx index 1b725881..7e7f850c 100644 --- a/SW.Bitween.Web/ClientApp/src/components/layout/BackgroundWork.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/layout/BackgroundWork.tsx @@ -30,6 +30,16 @@ const ENDED: Record = { /** An export's answer is the zip, which "Download" saves again rather than going back to the page. */ const isExport = (r: BackgroundRequest) => r.path.startsWith("POST /xchanges/export"); +/** Done, but answered with an error: nothing to download, and not "ready". */ +const failed = (r: BackgroundRequest) => + r.status === "Done" && r.resultStatus != null && (r.resultStatus < 200 || r.resultStatus >= 300); + +/** Finished with an answer worth having. */ +const ready = (r: BackgroundRequest) => r.status === "Done" && !failed(r); + +/** An export whose zip can be saved. */ +const downloadable = (r: BackgroundRequest) => isExport(r) && ready(r); + /** A bring's answer is what it brought, which "Open" shows on its page: `?brought=` its id. */ const isBring = (r: BackgroundRequest) => r.path.startsWith("POST /mirror/bring"); @@ -47,6 +57,7 @@ function said(r: BackgroundRequest, now: number): { text: string; title: string if (r.status === "Waiting") return { text: `Waiting for a free place · ${duration(r.startedOn, now)}`, title: "How long it has waited" }; if (r.status === "Running") return { text: `Running${counted} · ${duration(r.startedOn, now)}`, title: "How far it has got, and for how long" }; const took = `took ${duration(r.startedOn, Date.parse(r.finishedOn ?? r.startedOn))}`; + if (failed(r)) return { text: "Ended with an error. Open it to see why.", title: `It ${took}` }; if (r.status === "Done") return { text: r.summary ? `Ready · ${r.summary}` : `Ready · ${took}`, title: `It ${took}` }; return { text: r.summary ? `${ENDED[r.status] ?? "Ended."} ${r.summary}` : (ENDED[r.status] ?? "Ended."), title: "" }; } @@ -69,10 +80,14 @@ function duration(from: string, to: number): string { /** Goes back to what it was for: its page - or, for an export, its zip again; for a bring, what it brought. */ async function openWork(r: BackgroundRequest, navigate: (to: string) => void): Promise { - if (isExport(r) && r.status === "Done") { - followDownload((await api.linkToBackgroundFile(r.id)).url); - dismissBackground([r.id]); - return; + if (downloadable(r)) { + try { + followDownload((await api.linkToBackgroundFile(r.id)).url); + dismissBackground([r.id]); + return; + } catch { + // No file after all, or no longer kept: its page says why, as for any other answer. + } } if (isBring(r) && r.status === "Done") { dismissBackground([r.id]); @@ -88,11 +103,11 @@ export function BackgroundTray() { const { requests } = useBackground(); const navigate = useNavigate(); const running = requests.filter((r) => isRunning(r.status)).length; - const ready = requests.filter((r) => r.status === "Done").length; + const readyCount = requests.filter(ready).length; const now = useNow(running > 0); if (requests.length === 0) return null; - const summary = running ? `${running} running` : ready ? `${ready} ready` : `${requests.length} ended`; + const summary = running ? `${running} running` : readyCount ? `${readyCount} ready` : `${requests.length} ended`; return ( {running ? ( - ) : ready ? ( + ) : readyCount ? ( ) : ( @@ -149,10 +164,10 @@ export function BackgroundTray() { close(); void openWork(r, navigate); }} - title={isExport(r) && r.status === "Done" ? "Save the zip" : "Go back to the page it was for"} + title={downloadable(r) ? "Save the zip" : "Go back to the page it was for"} className="shrink-0 cursor-pointer rounded px-1.5 py-0.5 text-xs font-medium text-crimson-700 hover:bg-crimson-50" > - {isExport(r) && r.status === "Done" ? "Download" : "Open"} + {downloadable(r) ? "Download" : "Open"}