diff --git a/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs b/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs new file mode 100644 index 00000000..24ae6f55 --- /dev/null +++ b/SW.Bitween.Api/Controllers/BackgroundRequestsController.cs @@ -0,0 +1,168 @@ +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(); + await requests.ForgetExpiredAsync(db); + + 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(); + await requests.ForgetExpiredAsync(db); + + 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(); + await requests.ForgetExpiredAsync(db); + + 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..866efb3e 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}$"); @@ -56,12 +69,13 @@ 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) { - 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); @@ -136,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); @@ -215,8 +250,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 +263,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 +290,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 +304,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 +325,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..3cfbc6ef 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,182 @@ 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; and room + // for a whole save at its start, so what waits never passes Waiting. + if (background?.CancelRequested.IsCancellationRequested == true || + background != null && handled % SaveEvery == 0 && + !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 (request.Reset && subscription == null) + // Only those it retried: a skipped one's retry, if it has one, is someone else's. + if (await RetryOne(id, request, plan)) { - // 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." - }); - continue; + retried++; + batch.Add(id); } - // 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) + handled++; + + 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"; + + /// + /// 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. + /// + private async Task RoomFor(List unfinished, BackgroundRequestScope background, int handled, + int total, int retried, XchangeBulkRetryPlan plan) + { + 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(); + if (finished.Count > 0) { - plan.Skipped.Add(new XchangeRetrySkip - { - Id = id, - Reason = "Its input document could not be read, so there is nothing to re-send." - }); + unfinished.RemoveAll(finished.Contains); + background.Progress(handled, total, + $"{Said(retried, plan)} · waiting for the pipeline, {unfinished.Count:n0} to go"); continue; } - try + 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 { - 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); - } - } - catch (SWValidationException e) when ( - e.Validations.Any(v => v.Key == "ALREADY_RETRIED")) + 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 { - // 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; - } + Id = id, + Reason = "Its properties cannot be reset: the subscription no longer exists." + }); + return false; + } - retried++; + // 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; } - if (retried > 0) - _dbContext.Record(_requestContext, OperatorAction.BulkRetry, $"{retried} exchanges", - $"{retried} retried, {plan.Skipped?.Count ?? 0} skipped"); - await _dbContext.SaveChangesAsync(); + try + { + 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); + } + } + 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; + } - 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..b23e92b9 --- /dev/null +++ b/SW.Bitween.Api/Services/Background/AskedRequest.cs @@ -0,0 +1,151 @@ +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 = []; + // 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(); + 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(); + } + + 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..cfc78195 --- /dev/null +++ b/SW.Bitween.Api/Services/Background/BackgroundRequests.cs @@ -0,0 +1,615 @@ +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; + + // 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(); + 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. On every read of them too, so nothing is + /// served past that when no new request comes along to clear it. + /// + internal 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); + // 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 + { + 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; + + public bool IsMoved => Volatile.Read(ref state) == Moved; + + /// + /// 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..23f63f9c --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs @@ -0,0 +1,184 @@ +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 }); + }); + + // 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) < 100; i++) await Task.Delay(50); + await Task.Delay(300); + Assert.Equal(100, await Retries(checkDb, ids)); + Assert.False(retrying.IsCompleted); + + // 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); + 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/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..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; @@ -775,8 +781,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..199bbe13 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/background.ts @@ -0,0 +1,329 @@ +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; + +/** + * `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 = 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; +} + +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; +} + +/** 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(); + +/** 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; + +/** 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, merged(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(() => { + if (asked === session) 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 { + session++; + refreshing = null; + const connection = hub; + hub = null; + void connection?.stop(); + if (polling) clearInterval(polling); + if (idling) clearTimeout(idling); + 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: [] }; + 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..278cfbe3 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,24 @@ export const mirrorMethods = { return post<{ closed: number }>("/mirror/filledin", { section, key }); }, - /** What bringing the picked items from prod does; with `apply`, brings them. */ - bringFromProd(section: MirrorBringSection, keys: string[], apply: boolean): Promise { - return post("/mirror/bring", { section, keys, apply }); + /** 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, + 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 10bbd62e..e69af493 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,20 @@ 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; + /** With `background`: told its id once it carries on there. */ + onBackground?: (id: string) => void; } /** @@ -218,19 +234,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 +269,27 @@ 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 }; + 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" } }); + 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/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/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..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, 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 { @@ -29,6 +29,13 @@ 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"; + +/** 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 @@ -383,7 +390,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 +417,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 +500,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; + // 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]); + + 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..aaf5326b 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()}; 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..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 @@ -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(); - expect(within(dialog).getByRole("progressbar", { name: "Retried so far" })).toHaveAttribute("aria-valuenow", "2"); - retry.release(); + 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(/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(); 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, targets: [] })); 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; 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..69ae5167 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()}… + {progress.done.toLocaleString()} of {progress.total.toLocaleString()} done… 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/src/pages/mirror/BringFromProd.tsx b/SW.Bitween.Web/ClientApp/src/pages/mirror/BringFromProd.tsx index 49330bdb..ccfe10ef 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, @@ -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"; @@ -42,12 +43,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]; @@ -88,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. @@ -489,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/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", { 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/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/admin-ui.md b/docs/admin-ui.md index 338ba83d..59811c42 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, 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 | Page | What it does | 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.