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..264e7981 100644 --- a/SW.Bitween.Api/Controllers/XchangeExportController.cs +++ b/SW.Bitween.Api/Controllers/XchangeExportController.cs @@ -44,11 +44,36 @@ 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: a file that would take it past this is left out, and so are the exchanges + /// after the zip reaches it, with missing.txt saying so. Well inside the 2 GB of temporary disk the + /// Helm chart gives a node, with keeping it to one zip at a time. + /// Set lower by tests. + /// + public static long MaxBytes { get; internal set; } = 1L << 30; + + /// + /// One export built in the background on a node at a time: each is up to on + /// its temporary disk, and two at once could fill it. Exports asked directly stream to the browser + /// instead, and take no disk. + /// + internal static readonly SemaphoreSlim BuildingInBackground = new(1, 1); + + /// How often an export waiting its turn says so, which keeps its time limit from running out. Set shorter by tests. + internal static TimeSpan SayWaitingEvery { get; set; } = TimeSpan.FromSeconds(5); + public const string MissingList = "missing.txt"; private static readonly Regex Extension = new(@"^\.[A-Za-z0-9]{1,10}$"); @@ -56,12 +81,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,9 +111,35 @@ 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; + if (background == null) return await Build(ids, background); + + // Its turn on this node's temporary disk. Cancelled while it waits, it builds no zip but an + // empty one whose missing.txt says so, as a cancel part way does. + var held = false; + try + { + while (!(held = await BuildingInBackground.WaitAsync(SayWaitingEvery, background.CancelRequested))) + background.Progress(0, ids.Count, "Waiting for another export on this server to finish"); + } + catch (OperationCanceledException) + { + } + + try + { + return await Build(ids, background); + } + finally + { + if (held) BuildingInBackground.Release(); + } + } + private async Task Build(List ids, Services.Background.BackgroundRequestScope background) + { var xchanges = await dbContext.Set().AsNoTracking() .Where(x => ids.Contains(x.Id)).OrderByDescending(x => x.StartedOn).ToListAsync(); var results = await dbContext.Set().AsNoTracking() @@ -118,24 +170,44 @@ 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 {Size(MaxBytes)} 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); - await Add(zip, xchange, XchangeFileType.Input, $"{folder}/input", + await Add(zip, body, xchange, XchangeFileType.Input, $"{folder}/input", xchange.InputName, xchange.InputSize, xchange.InputContentType, missing, aborted); if (!results.TryGetValue(xchange.Id, out var result)) continue; - await Add(zip, xchange, XchangeFileType.Output, $"{folder}/mapped", + await Add(zip, body, xchange, XchangeFileType.Output, $"{folder}/mapped", result.OutputName, result.OutputSize, result.OutputContentType, missing, aborted, result.NotStoredReason(XchangeFileType.Output)); - await Add(zip, xchange, XchangeFileType.Response, $"{folder}/handled", + await Add(zip, body, xchange, XchangeFileType.Response, $"{folder}/handled", result.ResponseName, result.ResponseSize, result.ResponseContentType, missing, aborted, 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); @@ -153,8 +225,8 @@ await list.WriteAsync(Encoding.UTF8.GetBytes( /// gives. A file the exchange never had is left out; one storage no longer has, or never got because it /// couldn't be stored after the delivery (), goes on the missing list. /// - private async Task Add(ZipArchive zip, Xchange xchange, XchangeFileType type, string path, string name, int size, - string contentType, List missing, CancellationToken aborted, string notStored = null) + private async Task Add(ZipArchive zip, HeldSyncWrites body, Xchange xchange, XchangeFileType type, string path, + string name, int size, string contentType, List missing, CancellationToken aborted, string notStored = null) { if (notStored != null) { @@ -165,6 +237,15 @@ private async Task Add(ZipArchive zip, Xchange xchange, XchangeFileType type, st if (size == 0) return; path += ExtensionOf(name, contentType); + // Checked before it is read rather than after: one large file could otherwise take the zip, and + // the disk it is built on, well past the limit. Its stored size; compressed, it may be less. + if (body.Written + size > MaxBytes) + { + missing.Add($"{path}: left out, since it would take the zip past {Size(MaxBytes)}, the most one export builds. " + + "Export this exchange on its own."); + return; + } + Stream file; try { @@ -199,6 +280,9 @@ private async Task Add(ZipArchive zip, Xchange xchange, XchangeFileType type, st } } + private static string Size(long bytes) => + bytes >= 1L << 30 ? $"{bytes / (1L << 30)} GB" : bytes >= 1L << 20 ? $"{bytes / (1L << 20)} MB" : $"{bytes:n0} bytes"; + /// The file's own extension when it has a plain one, or else one for its content type. private static string ExtensionOf(string name, string contentType) { @@ -215,8 +299,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 +312,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 +339,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 +353,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 +374,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/Dashboard/FailuresToActOn.cs b/SW.Bitween.Api/Resources/Dashboard/FailuresToActOn.cs new file mode 100644 index 00000000..0a63988a --- /dev/null +++ b/SW.Bitween.Api/Resources/Dashboard/FailuresToActOn.cs @@ -0,0 +1,47 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Resources.Xchanges; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Dashboard; + +/// +/// How many failures nothing has been retried from yet: the ends of chains, the problems still to +/// deal with. The number the exchange list's failed + newest-attempt view counts, counted here from +/// the failures rather than from the exchanges. +/// +/// +/// The dashboard used to ask the list for it, and the list fetches its rows newest first: on a +/// database where nearly every failure gets retried, that walked back through millions of exchanges +/// before reaching one, past the 30s limit - for a row the dashboard never showed. Here the failures +/// come from their own index (ix_xchange_result_failed on Postgres) and the retry check from +/// RetryFor's, so no exchange row is read. Capped like the list's count, so the client shows +/// "10,000+" for either. +/// +[HandlerName("failurestoacton")] +public class FailuresToActOn : IQueryHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public FailuresToActOn(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle() + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Dashboard.View); + + var total = await _dbContext.Set().AsNoTracking() + .Where(result => !result.Success + && !_dbContext.Set().Any(child => child.RetryFor == result.Id)) + .Take(Search.CountCap + 1) + .CountAsync(); + + return new { Total = total }; + } +} diff --git a/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs b/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs index d59ad8b9..3e9c56b8 100644 --- a/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs +++ b/SW.Bitween.Api/Resources/Dashboard/RetrySummary.cs @@ -67,32 +67,42 @@ group result by result.Success && !result.ResponseBad into worked // Chains that keep failing: a failure nothing has been retried from - so it is where that // chain has got to - which is itself a retry, so the chain is at least two attempts long. + // + // Over the same seven days as the number above, and asked so that indexes answer all of it: + // the retries from their own (ix_xchange_retries_started_on on Postgres), whether each + // failed from the failures' and whether it was retried from RetryFor's. With no time bound + // it walked back through every retry ever made until it found five hundred failures - on a + // database where every failure gets retried, millions of exchanges, past the 30s limit. var candidates = await ( from xchange in _dbContext.Set() - join result in _dbContext.Set() on xchange.Id equals result.Id where xchange.RetryFor != null - && !result.Success + && xchange.StartedOn >= since + && _dbContext.Set().Any(result => result.Id == xchange.Id && !result.Success) && !_dbContext.Set().Any(child => child.RetryFor == xchange.Id) orderby xchange.StartedOn descending - select new - { - xchange.Id, - xchange.RetryFor, - xchange.SubscriptionId, - xchange.DocumentId, - xchange.StartedOn, - result.Exception - }).AsNoTracking().Take(Candidates).ToListAsync(); + select new { xchange.Id, xchange.RetryFor, xchange.StartedOn }) + .AsNoTracking().Take(Candidates).ToListAsync(); var attempts = await CountAttempts(candidates.ToDictionary(c => c.Id, c => c.RetryFor)); - var worst = candidates + var worstIds = candidates .OrderByDescending(c => attempts[c.Id]) .ThenByDescending(c => c.StartedOn) .Take(Listed) + .Select(c => c.Id) + .ToList(); + + // What is shown of them, and the names below, for the few that are actually listed rather + // than for all five hundred. + var worst = (await ( + from xchange in _dbContext.Set() + join result in _dbContext.Set() on xchange.Id equals result.Id + where worstIds.Contains(xchange.Id) + select new { xchange.Id, xchange.SubscriptionId, xchange.DocumentId, xchange.StartedOn, result.Exception }) + .AsNoTracking().ToListAsync()) + .OrderBy(w => worstIds.IndexOf(w.Id)) .ToList(); - // Names for the few that are actually listed, rather than for all five hundred. var subscriptionIds = worst.Where(w => w.SubscriptionId != null).Select(w => w.SubscriptionId!.Value).ToList(); var subscriptionNames = await _dbContext.Set().AsNoTracking() .Where(s => subscriptionIds.Contains(s.Id)) diff --git a/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs b/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs index 306f72af..2dcf95a3 100644 --- a/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs +++ b/SW.Bitween.Api/Resources/XchangeExplanations/Explain.cs @@ -1,4 +1,5 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -37,7 +38,8 @@ public class XchangeExplanationResponse /// /// What is sent, and nothing else: the error; which step failed; the subscription's kind and /// adapters, and the names - never the values - of their settings; the retry policy's shape and the -/// retry state; the last attempts' errors; and the documents with their values replaced by types. +/// retry state; the whole retry chain, each attempt's outcome and the first line of its error; and +/// the documents with their values replaced by types. /// Adapter settings are names only because the exchange holds them with partner and global values /// already filled in, and some of those are credentials. /// @@ -50,9 +52,6 @@ public class Explain(RequestContext requestContext, BitweenDbContext dbContext, AiModels models) : ICommandHandler { - /// Earlier attempts whose errors are worth showing beside this one. - const int EarlierAttempts = 3; - /// The largest document masked and sent; a few records are enough to explain a failure. const int MaxMaskedFileBytes = 1_000_000; @@ -83,6 +82,8 @@ public async Task Handle(XchangeExplanationRequest r await AddFile(files, masking, xchange, XchangeFileType.Input, xchange.InputSize, xchange.InputContentType ?? ContentTypeOf(document)); await AddFile(files, masking, xchange, XchangeFileType.Output, result.OutputSize, result.OutputContentType); await AddFile(files, masking, xchange, XchangeFileType.Response, result.ResponseSize, result.ResponseContentType); + var chain = await Chain(xchange); + var sendable = await SeeOtherAttempts(chain, xchange.Id, result.Exception, masking); sent.AppendLine("Failed step: " + FailedStep(xchange, result)); if (subscription is not null) @@ -97,8 +98,7 @@ public async Task Handle(XchangeExplanationRequest r sent.AppendLine("Handler settings (names only): " + Names(xchange.HandlerProperties)); sent.AppendLine("Document format: " + (document?.DocumentFormat.ToString() ?? "unknown")); if (result.ResponseBad) sent.AppendLine("The partner's response was marked as bad data."); - var earlier = await EarlierErrors(xchange); - sent.AppendLine($"Attempt {earlier.Chain + 1} of its retry chain"); + sent.AppendLine($"This is attempt {chain.Position} of {chain.Total}{(chain.More ? " or more" : "")} in its retry chain."); if (!string.IsNullOrWhiteSpace(result.RetryBlockedReason)) sent.AppendLine("Why it won't be retried: " + masking.Error(result.RetryBlockedReason)); if (delayed is not null) sent.AppendLine($"A retry is scheduled for {delayed.On:u}."); @@ -109,14 +109,12 @@ public async Task Handle(XchangeExplanationRequest r sent.AppendLine(string.IsNullOrWhiteSpace(result.Exception) ? "(no error text; see the response)" : masking.Error(result.Exception)); sent.AppendLine(""); - // Only each earlier error's first line: the rest is a stack trace, nearly always the same one. - var thisError = FirstLine(result.Exception); - foreach (var (attempt, error) in earlier.Errors) + if (chain.Total > 1) { - var line = FirstLine(error); - sent.AppendLine(line == thisError - ? $"Earlier attempt {attempt}: the same error." - : $"Earlier attempt {attempt}: {masking.Error(line)}"); + sent.AppendLine(); + sent.AppendLine(""); + foreach (var line in ChainLines(chain, xchange.Id, masking, sendable)) sent.AppendLine(line); + sent.AppendLine(""); } foreach (var (name, text) in files) @@ -189,37 +187,188 @@ static string RetryPolicy(Subscription? subscription) (g.Budget is { } b ? $", up to {b.MaxAttemptsTotal} attempts" : "") + ")")); } - /// How many attempts came before this one, and the errors of the last few. + /// One attempt in a retry chain, numbered from the chain's first; no outcome while it is still running. + internal sealed record Attempt(string Id, int Number, System.DateTime StartedOn, bool? Success, bool ResponseBad, string? Error); + + /// The attempts listed, where the explained one stands, and how many weren't listed. + internal sealed record RetryChain(List Attempts, int Position, int Total, bool More, int EarlierNotListed); + + /// Attempts listed before and after the explained one; each side has its own room. + const int ListedBefore = 30, ListedAfter = 20; + + /// How far the chain is followed each way to count it; past this it says "or more". + const int Walked = 200; + + /// How much of each attempt's error is read: its first line, nearly always. + internal const int ErrorRead = 4_000; + + /// The longest line an attempt's error is sent as, once masked. + internal const int MaxLineLength = 500; + + /// Attempts with an error of their own whose documents are read to mask it. + const int OtherAttemptsRead = 10; + + /// + /// The retry chain this exchange is in: the attempts before it and after it, since an older + /// attempt opened from a link still belongs to the chain's story. + /// /// - /// Counted along the chain rather than read from the result's attempt number, which counts only - /// the retries a policy made - five attempts by hand all say 1. + /// Walked along RetryFor rather than read from the result's attempt number, which counts only + /// the retries a policy made - five attempts by hand all say 1. An exchange is retried at most + /// once, so the chain has no branches. Each side is counted in full up to , + /// and listed nearest first up to its own limit, so a long past never crowds out a later success. /// - async Task<(int Chain, List<(string Id, string Error)> Errors)> EarlierErrors(Xchange xchange) + async Task Chain(Xchange xchange) { - var errors = new List<(string, string)>(); - var chain = 0; - var id = xchange.RetryFor; - while (id is not null && chain < MaxChain) + var before = new List(); + var earlier = 0; + var more = false; + for (var id = xchange.RetryFor; id is not null;) { + if (earlier == Walked) { more = true; break; } var previous = await dbContext.Set().AsNoTracking() .Where(x => x.Id == id).Select(x => new { x.Id, x.RetryFor }).FirstOrDefaultAsync(); if (previous is null) break; - chain++; - if (errors.Count < EarlierAttempts) - { - var error = await dbContext.Set().AsNoTracking() - .Where(r => r.Id == previous.Id).Select(r => r.Exception).FirstOrDefaultAsync(); - if (!string.IsNullOrWhiteSpace(error)) errors.Add((previous.Id, error)); - } + earlier++; + if (before.Count < ListedBefore) before.Add(previous.Id); id = previous.RetryFor; } - return (chain, errors); + var after = new List(); + var later = 0; + for (var id = xchange.Id;;) + { + if (later == Walked) { more = true; break; } + var next = await dbContext.Set().AsNoTracking() + .Where(x => x.RetryFor == id).OrderBy(x => x.StartedOn).Select(x => x.Id).FirstOrDefaultAsync(); + if (next is null) break; + later++; + if (after.Count < ListedAfter) after.Add(next); + id = next; + } + + before.Reverse(); + var ids = before.Append(xchange.Id).Concat(after).ToList(); + // One read for each attempt and its result, so one deleted meanwhile is gone, not "still running". + var rows = await ( + from x in dbContext.Set().AsNoTracking() + where ids.Contains(x.Id) + join r in dbContext.Set().AsNoTracking() on x.Id equals r.Id into results + from r in results.DefaultIfEmpty() + select new + { + x.Id, x.StartedOn, + Success = r == null ? (bool?)null : r.Success, + ResponseBad = r != null && r.ResponseBad, + Error = r == null || r.Exception == null ? null : r.Exception.Substring(0, ErrorRead), + }) + .ToDictionaryAsync(r => r.Id); + + var first = earlier - before.Count + 1; + var attempts = ids.Select((id, i) => (id, number: first + i)) + .Where(a => rows.ContainsKey(a.id)) + .Select(a => + { + var r = rows[a.id]; + return new Attempt(a.id, a.number, r.StartedOn, r.Success, r.ResponseBad, r.Error); + }) + .ToList(); + return new RetryChain(attempts, earlier + 1, earlier + 1 + later, more, earlier - before.Count); } - /// A chain is walked this far at most; a longer one says so as "50+" would, by stopping. - const int MaxChain = 50; + /// + /// Reads the documents of the other attempts whose error differs from this one's, so masking + /// knows their values too: a later attempt's response can hold what this one's documents don't. + /// Returns the attempts whose error line may be sent: this one, those with its very line, and + /// those whose documents were all read and masked. Any other's line is withheld, not guessed at. + /// + async Task> SeeOtherAttempts(RetryChain chain, string thisId, string? thisError, Masking masking) + { + var thisLine = WholeFirstLine(thisError ?? ""); + var sendable = chain.Attempts + .Where(a => a.Id == thisId || string.IsNullOrWhiteSpace(a.Error) || WholeFirstLine(a.Error) == thisLine) + .Select(a => a.Id).ToHashSet(); + var ids = chain.Attempts.Where(a => !sendable.Contains(a.Id)).Select(a => a.Id).Take(OtherAttemptsRead).ToList(); + if (ids.Count == 0) return sendable; + var xchanges = await dbContext.Set().AsNoTracking().Where(x => ids.Contains(x.Id)).ToListAsync(); + var results = await dbContext.Set().AsNoTracking().Where(r => ids.Contains(r.Id)).ToDictionaryAsync(r => r.Id); + foreach (var x in xchanges) + { + if (!results.TryGetValue(x.Id, out var r)) continue; + if (await Seen(masking, x, XchangeFileType.Output, r.OutputSize, r.OutputContentType) && + await Seen(masking, x, XchangeFileType.Response, r.ResponseSize, r.ResponseContentType)) + sendable.Add(x.Id); + } + return sendable; + } - static string FirstLine(string? text) => (text ?? "").Split('\n')[0].Trim(); + /// + /// Masks one of another attempt's documents, for its values only: whether they are now known. + /// Not when it is too large, gone, unreadable, or in a format masking can't read. + /// + async Task Seen(Masking masking, Xchange xchange, XchangeFileType type, int size, string? contentType) + { + if (size <= 0) return true; + if (size > MaxMaskedFileBytes) return false; + try + { + return masking.Document(await xchangeService.GetFile(xchange, type), contentType) != null; + } + catch (Exception) + { + // Storage that can't give it back now costs this attempt's line, not the explanation. + return false; + } + } + + /// + /// The chain as the model reads it: one line per attempt, its outcome and its error's first line, + /// with a run of the same outcome and error folded into one line. + /// + internal static IEnumerable ChainLines(RetryChain chain, string thisId, Masking masking, ISet sendable) + { + string Describe(Attempt a) + { + if (a.Success is null) return "still running"; + if (a.Success == true && !a.ResponseBad) return "succeeded"; + var what = a.ResponseBad ? "delivered, bad response" : "failed"; + if (string.IsNullOrWhiteSpace(a.Error)) return what; + if (!sendable.Contains(a.Id)) return $"{what}, with a different error, not sent: its documents couldn't be read to mask it"; + // Masked whole, then cut: a cut first could leave half a value masking can't recognise. + var line = masking.Error(WholeFirstLine(a.Error)); + return $"{what}: {(line.Length > MaxLineLength ? line[..MaxLineLength] + " ..." : line)}"; + } + + if (chain.EarlierNotListed > 0) + yield return $"({chain.EarlierNotListed} earlier attempts not listed)"; + var list = chain.Attempts; + var i = 0; + while (i < list.Count) + { + var what = Describe(list[i]); + var j = i; + // The opened attempt keeps a line of its own, so the model knows which one it is explaining. + while (j + 1 < list.Count && list[j + 1].Id != thisId && list[i].Id != thisId && Describe(list[j + 1]) == what) j++; + var at = list[i].StartedOn.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); + var which = i == j ? $"Attempt {list[i].Number}" : $"Attempts {list[i].Number}-{list[j].Number}"; + var mark = list[i].Id == thisId ? " (this one)" : ""; + yield return $"{which}{mark}, from {at} UTC: {what}"; + i = j + 1; + } + } + + /// + /// An error's first line. Only characters are read, and a first line that + /// runs past them is cut somewhere masking can't see the whole of: a name's first word looks like + /// any other. So only what comes before its first ": " - the exception's type - is kept. + /// + internal static string WholeFirstLine(string text) + { + var newline = text.IndexOf('\n'); + if (newline >= 0) return text[..newline].Trim(); + if (text.Length < ErrorRead) return text.Trim(); + var type = text.IndexOf(": ", StringComparison.Ordinal); + return (type > 0 && type < 200 ? text[..type] + ": " : "") + "(the rest of this line is too long to send)"; + } static string? ContentTypeOf(Document? document) => document?.DocumentFormat switch { diff --git a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs index 65913c5f..251b113e 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,211 @@ 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; - foreach (var id in prepared.Targets) + // Retried and saved: what a run that stops part way has done for good. + var saved = 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(); + // In the background a run can go on for hours and end with its server: who started it is + // recorded first, so retries it saves before then are never without an owner on record. + if (background != null && total > 0) + await _dbContext.RecordNowAsync(_requestContext, OperatorAction.BulkRetry, $"{total} exchanges", + $"Started in the background: {total} to retry, {plan.SkippedCount} skipped. " + + "Each save of a hundred keeps its retries; another entry says how many when it ends."); + + background?.Progress(0, total, Said(0, plan)); + try { - var xchange = await _dbContext.Set().AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == id); - if (xchange == null) + foreach (var id in prepared.Targets) { - plan.Skipped.Add(new XchangeRetrySkip + // 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)) { - Id = id, - Reason = "This exchange no longer exists." - }); - continue; - } - - var subscription = await _dbContext.Subscriptions() - .FirstOrDefaultAsync(s => s.Id == xchange.SubscriptionId); + plan.StoppedAfter = handled; + break; + } - 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. - plan.Skipped.Add(new XchangeRetrySkip + // Only those it retried: a skipped one's retry, if it has one, is someone else's. + if (await RetryOne(id, request, plan)) { - 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) - { - plan.Skipped.Add(new XchangeRetrySkip - { - Id = id, - Reason = "Its input document could not be read, so there is nothing to re-send." - }); - continue; - } + handled++; + if (handled % SaveEvery != 0) continue; + await _dbContext.SaveChangesAsync(); + saved = retried; + _dbContext.ChangeTracker.Clear(); + if (background != null) unfinished.AddRange(await RetriesOf(batch)); + batch.Clear(); + background?.Progress(handled, total, Said(retried, plan)); + } + } + catch (System.Exception) when (saved > 0) + { + // The saves before the error kept their retries: they are recorded before it goes on. + // What wasn't saved yet is dropped with the error, so it isn't recorded. + _dbContext.ChangeTracker.Clear(); 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); - } + await _dbContext.RecordNowAsync(_requestContext, OperatorAction.BulkRetry, $"{saved} exchanges", + $"{saved} retried, {plan.SkippedCount} skipped, then it stopped with an error after {handled} of {total}"); } - catch (SWValidationException e) when ( - e.Validations.Any(v => v.Key == "ALREADY_RETRIED")) + catch (System.Exception) { - // 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; + // The error that stopped the run is the one to report, not this one. } - - retried++; + throw; } if (retried > 0) _dbContext.Record(_requestContext, OperatorAction.BulkRetry, $"{retried} exchanges", - $"{retried} retried, {plan.Skipped?.Count ?? 0} skipped"); + $"{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) + { + unfinished.RemoveAll(finished.Contains); + background.Progress(handled, total, + $"{Said(retried, plan)} · waiting for the pipeline, {unfinished.Count:n0} to go"); + continue; + } + + if (background.CancelRequested.IsCancellationRequested) return false; + await Task.Delay(LookEvery, background.Stopped); + } + + return true; + } + + /// The retries just saved for . + private Task> RetriesOf(List targets) => + _dbContext.Set().AsNoTracking() + .Where(x => targets.Contains(x.RetryFor)).Select(x => x.Id).ToListAsync(); + + /// Retries one target, or says in the plan why it didn't; true when it did. + private async Task RetryOne(string id, XchangeBulkRetry request, XchangeBulkRetryPlan plan) + { + var xchange = await _dbContext.Set().AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id); + if (xchange == null) + { + BulkRetryPlanner.Skip(plan, new XchangeRetrySkip + { + Id = id, + Reason = "This exchange no longer exists." + }); + return false; + } + + var subscription = await _dbContext.Subscriptions() + .FirstOrDefaultAsync(s => s.Id == xchange.SubscriptionId); + + if (request.Reset && subscription == null) + { + // Reported per exchange rather than thrown. Throwing meant one exchange whose + // subscription had since been deleted took the whole selection down with it, + // and the caller could not tell which one. + BulkRetryPlanner.Skip(plan, new XchangeRetrySkip + { + Id = id, + Reason = "Its properties cannot be reset: the subscription no longer exists." + }); + return false; + } + + // The tolerant read, not GetFile: a retry re-sends the original input, so an + // exchange whose input has been deleted or expired cannot be retried - and one of + // those in a selection of five hundred must not take the other 499 with it. + var xchangeFile = await _xchangeService.ReadInputFile(xchange); + if (xchangeFile == null) + { + BulkRetryPlanner.Skip(plan, new XchangeRetrySkip + { + Id = id, + Reason = "Its input document could not be read, so there is nothing to re-send." + }); + return false; + } + + try + { + 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; + } + + 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/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs index 3dc89dcc..24666c01 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Search.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs @@ -156,8 +156,12 @@ from delayedRetry in drGroup.DefaultIfEmpty() // as "10,000+". 36ms drops to 2.3ms unfiltered, 264ms to 25ms on Success. Anything // filtered narrowly enough to act on still gets an exact number; only views far too // broad to page through lose it, and 10,000 rows is 400 pages of Next. - TotalCount = await query.AsNoTracking().Search(searchyRequest.Conditions) - .Take(CountCap + 1).CountAsync() + // + // Not at all with count=false, as Searchy's own search does: the Exchanges page asks + // for its rows that way so they show at once, and has the total from the summary. + TotalCount = searchyRequest.CountRows + ? await query.AsNoTracking().Search(searchyRequest.Conditions).Take(CountCap + 1).CountAsync() + : 0 }; return searchyResponse; diff --git a/SW.Bitween.Api/Resources/Xchanges/Summary.cs b/SW.Bitween.Api/Resources/Xchanges/Summary.cs index 10a807cf..f72fd2f6 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Summary.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Summary.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Data; +using System.Globalization; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Model; @@ -27,11 +29,16 @@ namespace SW.Bitween.Resources.Xchanges; /// Every special filter the search understands is applied the same way, so a count is never of a /// different set than the list it sits over. /// +/// +/// A count or a histogram that takes longer than is finished in the background +/// by , which keeps it for the next ask: until then it is missing from the +/// answer, and says so. +/// /// [HandlerName("summary")] -public class Summary(BitweenDbContext dbContext, RequestContext requestContext) : ISearchyHandler +public class Summary(BitweenDbContext dbContext, RequestContext requestContext, SlowCounts slowCounts) : ISearchyHandler { - /// The status numbers reads, in the order they are counted. + /// The status numbers reads. private const string Processing = "0", Success = "1", BadResponse = "2", Failed = "3"; public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) @@ -47,6 +54,11 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa foreach (var statusFilter in condition.Filters.Where(f => f.Field == "StatusFilter").ToList()) condition.Filters.Remove(statusFilter); + // Taken before FilteredRowsAsync takes the special filters out of the request: a count finished + // in the background filters again from this, with a database of its own. + var asked = (SearchyRequest)searchyRequest.Clone(); + var view = ViewKey(searchyRequest, DateTime.UtcNow); + var rows = await XchangeFilters.FilteredRowsAsync(dbContext, searchyRequest); // Read after FilteredRowsAsync, which is what moves the request's dates to UTC. The @@ -58,15 +70,24 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa // One capped count per status rather than a single grouped count: each can stop at the // cap, as the search's total does, where a grouping has to visit every matching row. Four // short queries over a broad filter cost far less than one exhaustive one. - var counts = new XchangeStatusCounts + // + // All four share one budget, so the answer never waits longer than it, however many are slow. + // The ones with an index of their own go first; still running, which can't have one, last. + var counts = new XchangeStatusCounts(); + using (var deadline = new CancellationTokenSource(Budget)) { - Processing = await CountCapped(rows, Processing), - Success = await CountCapped(rows, Success), - BadResponse = await CountCapped(rows, BadResponse), - Failed = await CountCapped(rows, Failed), - }; + counts.BadResponse = await Count(BadResponse, deadline.Token); + counts.Failed = await Count(Failed, deadline.Token); + counts.Success = await Count(Success, deadline.Token); + counts.Processing = await Count(Processing, deadline.Token); + } - var summary = new XchangeSummary { Counts = counts }; + var summary = new XchangeSummary + { + Counts = counts, + Counting = counts.Processing == null || counts.Success == null || counts.BadResponse == null || + counts.Failed == null + }; if (rangeStart is not { } start) return summary; @@ -75,14 +96,117 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa return summary; var bucketMinutes = XchangeHistogram.ChooseBucketMinutes(start, end); + using var histogramDeadline = new CancellationTokenSource(Budget); + var started = await Answer($"histogram:{bucketMinutes}|{view}", histogramDeadline.Token, + ct => StartedCounts(rows, bucketMinutes, ct), + async (db, ct) => await StartedCounts(await XchangeFilters.FilteredRowsAsync(db, Copy(asked)), bucketMinutes, ct)); + if (started == null) + { + summary.Counting = true; + return summary; + } + summary.BucketMinutes = bucketMinutes; - summary.Buckets = XchangeHistogram.Fold(start, end, bucketMinutes, - await StartedCounts(rows, bucketMinutes)); + summary.Buckets = XchangeHistogram.Fold(start, end, bucketMinutes, started); return summary; + + Task Count(string status, CancellationToken deadline) => + Answer($"count:{status}|{view}", deadline, + ct => CountCapped(rows, status, dbContext, ct), + async (db, ct) => await CountCapped(await XchangeFilters.FilteredRowsAsync(db, Copy(asked)), status, db, ct)); + } + + /// + /// The answer keeps for , when it is one that was too + /// slow before. Otherwise worked out within the budget, and when that runs + /// out, left to to work out : null until then. + /// + private async Task Answer(string key, CancellationToken deadline, Func> here, + Func> later) + { + if (slowCounts.TryGet(key, later, out var kept)) + return kept; + + var answer = await WithinBudget(dbContext, deadline, here); + if (answer == null) + slowCounts.Start(key, later); + return answer; + } + + private static SearchyRequest Copy(SearchyRequest request) => (SearchyRequest)request.Clone(); + + /// + /// The filters as text, naming what was counted for . A start with no end - + /// what "last 24 hours" sends, a little later on every ask - is named by how far back it reaches, + /// to five minutes, so the asks of one page find the one count rather than each starting another. + /// + internal static string ViewKey(SearchyRequest request, DateTime now) => + string.Join("|", request.Conditions.Select(condition => + { + var openEnded = !condition.Filters.Any(f => + f.Field == nameof(XchangeRow.StartedOn) && f.Rule == SearchyRule.LessThanOrEquals); + return string.Join("&", condition.Filters + .Select(f => + openEnded && f.Field == nameof(XchangeRow.StartedOn) && f.Rule == SearchyRule.GreaterThanOrEquals && + DateTime.TryParse(f.Value?.ToString(), CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var since) + ? $"StartedOn:since:{Math.Round((now - since).TotalMinutes / 5)}" + : f.ToString()) + .OrderBy(f => f, StringComparer.Ordinal)); + })); + + /// How long the counts together, and then the histogram, may take before the rest is left to the background. + /// + /// A status that is rare across a large table can only be counted by visiting every exchange: + /// with no result row to look up, "processing" can't be found any other way. On millions of + /// exchanges that ran past the database's own time limit and failed the whole request, taking + /// every other tab's count with it. + /// + internal static readonly TimeSpan Budget = TimeSpan.FromSeconds(5); + + /// + /// What returns, or the default - null - when + /// passes first. Runs inside the summary's transaction, under a savepoint: a statement cancelled + /// part-way leaves Postgres refusing every later one in the transaction until it is rolled back to + /// before it. + /// + internal static async Task WithinBudget(BitweenDbContext dbContext, CancellationToken deadline, + Func> query) + { + const string savepoint = "within_budget"; + var transaction = dbContext.Database.CurrentTransaction!; + await transaction.CreateSavepointAsync(savepoint); + + try + { + return await query(deadline); + } + // Whatever the provider throws for a cancelled command: Npgsql an OperationCanceledException, + // SqlClient a SqlException. Only once the deadline has passed: an error before it surfaces, and + // one after it is taken for the deadline, then met again by the count in the background, which + // logs it. + catch (Exception) when (deadline.IsCancellationRequested) + { + await transaction.RollbackToSavepointAsync(savepoint); + return default; + } } - private Task CountCapped(IQueryable rows, string status) => - rows.WhereStatus(status, dbContext).Take(Search.CountCap + 1).CountAsync(); + private static async Task CountCapped(IQueryable rows, string status, BitweenDbContext dbContext, + CancellationToken cancellationToken) + { + var matching = rows.WhereStatus(status, dbContext); + + // Nearly every exchange succeeds, so the newest 10,001 successes are among about the newest + // 10,001 exchanges. Unordered, Postgres matched the two tables by id instead, and ids aren't in + // the order exchanges were written, so nearly every row was read from a different place on + // disk: 12s on 5.8M exchanges, against under a second this way. The rarer statuses stay + // unordered, so they keep the plan that reads their own small index. + if (status == Success) + matching = matching.OrderByDescending(r => r.StartedOn); + + return await matching.Take(Search.CountCap + 1).CountAsync(cancellationToken); + } /// /// The matching exchanges grouped by when they started, to the precision the bucket size @@ -105,7 +229,7 @@ private Task CountCapped(IQueryable rows, string status) => /// /// private static async Task> StartedCounts(IQueryable rows, - int bucketMinutes) + int bucketMinutes, CancellationToken cancellationToken) { if (bucketMinutes >= 1440) return await rows @@ -119,7 +243,7 @@ private static async Task> StartedCounts(IQueryable= 60) return await rows @@ -138,7 +262,7 @@ private static async Task> StartedCounts(IQueryable new @@ -157,7 +281,7 @@ private static async Task> StartedCounts(IQueryable 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/Ai/FailurePrompt.cs b/SW.Bitween.Api/Services/Ai/FailurePrompt.cs index 297a24c7..35fa6beb 100644 --- a/SW.Bitween.Api/Services/Ai/FailurePrompt.cs +++ b/SW.Bitween.Api/Services/Ai/FailurePrompt.cs @@ -20,6 +20,12 @@ public static class FailurePrompt over HTTP, and may get a response back. A failure is either in mapping or in delivery. Failed exchanges can be retried by a retry policy, or by hand. + When the exchange was retried, you also get its whole retry chain: every attempt in order, its + outcome and its error's first line. Use it: an error that repeats unchanged points at a lasting + problem - unless it is one a retry can outlast (a timeout, a 5xx, a rate limit), which repeating + means an outage still going on. One that changed or passed points at a glitch or a fix that + already happened. If a later attempt succeeded, say so first. + Every value in the documents and the error has been masked before you see it: text is , numbers are , booleans are , credentials and tokens are , and long lists show their first entries. Keys, structure and the error's wording are real. Never guess at a masked value; reason from structure, 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..c5cc2b50 --- /dev/null +++ b/SW.Bitween.Api/Services/Background/BackgroundRequestHub.cs @@ -0,0 +1,80 @@ +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 HTTP status it was answered with, once done: an error answer is still Done. + public int? ResultStatus { get; set; } + + /// The that sent it, which told its own already. + public string Node { get; set; } + + public static BackgroundRequestChanged Of(BackgroundRequest row, string node) => new() + { + Id = row.Id, + Owner = row.Owner, + Status = row.Status, + Done = row.Done, + ResultStatus = row.ResultStatus, + 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..6c3e4653 --- /dev/null +++ b/SW.Bitween.Api/Services/Background/BackgroundRequests.cs @@ -0,0 +1,616 @@ +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, + resultStatus = change.ResultStatus, + }); + } + 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.Api/Services/DatabaseTimeoutMiddleware.cs b/SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs new file mode 100644 index 00000000..d19ba251 --- /dev/null +++ b/SW.Bitween.Api/Services/DatabaseTimeoutMiddleware.cs @@ -0,0 +1,67 @@ +using System; +using System.Data.Common; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using SW.Bitween.Services.Background; + +namespace SW.Bitween.Services; + +/// +/// A request whose database command ran past its time limit, answered as that: 504 with +/// DATABASE_TIMED_OUT, where it used to be a bare 500 like any other failure. The admin UI +/// doesn't ask again for it - asked again, it takes as long again - and says what happened rather +/// than showing an empty page. +/// +public class DatabaseTimeoutMiddleware(RequestDelegate next, ILogger logger) +{ + public const string Code = "DATABASE_TIMED_OUT"; + + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) when (IsDatabaseTimeout(ex) && !context.Response.HasStarted) + { + logger.LogError(ex, "{Method} {Path} ran past the database's time limit.", context.Request.Method, + context.Request.Path); + // Not Response.Clear(): that also drops the security and CORS headers set before the + // endpoint ran. Nothing has been written, so only a length set for another body is wrong. + context.Response.Headers.ContentLength = null; + context.Response.StatusCode = StatusCodes.Status504GatewayTimeout; + context.Response.ContentType = "application/json"; + await context.Response.Body.WriteAsync(BackgroundRequests.Problem(Code, + "The database took too long to answer. Asking for less, such as a shorter time range, may help.")); + } + } + + /// + /// A command that ran out of time: past the connection's Command Timeout, which Npgsql throws as + /// its own exception around a , or past a statement_timeout + /// set on the database, which Postgres reports as 57014. A timeout only inside a database + /// exception, so a call elsewhere that timed out isn't put down to the database, and not a + /// cancellation the caller asked for, which Postgres also reports as 57014. SQL Server and MySQL + /// say it differently, and keep the 500. + /// + internal static bool IsDatabaseTimeout(Exception ex) + { + var inDatabase = false; + for (var e = ex; e != null; e = e.InnerException) + switch (e) + { + case OperationCanceledException: + return false; + case DbException { SqlState: "57014" }: + return true; + case DbException: + inDatabase = true; + break; + case TimeoutException when inDatabase: + return true; + } + + return false; + } +} diff --git a/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs b/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs index b4918127..25fbe2a2 100644 --- a/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs +++ b/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs @@ -13,7 +13,7 @@ namespace SW.Bitween; /// /// Works out what the retention settings do - as they stand, or as someone is about to change them - so /// the settings page can say it before anything is saved: how long files and exchanges are kept, what -/// the next run removes, and which exchanges are left without their files. +/// the next run removes, and whether exchanges are kept longer than their files. /// public class RetentionPlanner(BitweenDbContext dbContext, BitweenOptions options, StorageRetention storageRetention, StorageAccess storageAccess) @@ -35,8 +35,8 @@ public async Task Plan(RetentionProposal proposal = null, bool var now = DateTime.UtcNow; var files = Retention(prefix, rules); - // Exchanges already written keep the prefix they were written under, so how many are left - // without files depends on the prefix in force now, not on a proposed one. + // Exchanges already written keep the prefix they were written under, so whether they outlive + // their files depends on the prefix in force now, not on a proposed one. var currentFiles = prefix == options.DocumentPrefix ? files : Retention(options.DocumentPrefix, rules); var legacyPrefix = options.LegacyDocumentPrefix; var legacyFiles = !string.IsNullOrEmpty(legacyPrefix) && legacyPrefix != prefix ? Retention(legacyPrefix, rules) : null; @@ -51,29 +51,10 @@ public async Task Plan(RetentionProposal proposal = null, bool ? await ExchangeRetention.DueBefore(dbContext, now.AddDays(-days)).Take(CountCap).CountAsync() : null; - // As things stand now, whatever the proposal: those the next run removes are still listed until then. - // Each exchange's files follow the rule of the prefix they were written under, which a later change - // of prefix doesn't move; only those older than the shortest rule can have lost them. - int? withoutFiles = currentFiles.Days == null ? null : 0; - if (rules.Problem == null && - rules.Lifecycle?.Rules.Where(r => r.Enabled).Select(r => (int?)r.Days).Min() is { } shortest) - { - var unrecordedPrefix = options.LegacyDocumentPrefix ?? options.DocumentPrefix; - var writtenUnder = await dbContext.Set() - .Where(x => x.StartedOn < now.AddDays(-shortest)) - .Select(x => x.FilesPrefix).Distinct().ToListAsync(); - foreach (var recorded in writtenUnder) - { - if (Retention(recorded ?? unrecordedPrefix, rules).Days is not { } filesDays) continue; - var filesGone = now.AddDays(-filesDays); - var counted = withoutFiles ?? 0; - withoutFiles = counted + await dbContext.Set() - .Where(x => x.FilesPrefix == recorded && x.StartedOn < filesGone) - .Take(CountCap - counted).CountAsync(); - if (withoutFiles >= CountCap) break; - } - } - + // How many exchanges have lost their files isn't counted: which rule applies depends on the + // prefix each was written under, and finding those meant reading every exchange older than the + // bucket's shortest rule - a day, with the standard temp1/ rule - which took over 30s on 5.8M + // exchanges and failed the page. When the oldest started is given instead. var oldest = await dbContext.Set().OrderBy(x => x.StartedOn) .Select(x => (DateTime?)x.StartedOn).FirstOrDefaultAsync(); @@ -106,7 +87,6 @@ public async Task Plan(RetentionProposal proposal = null, bool Cron = cron, NextRun = nextRun, DueNow = dueNow, - WithoutFiles = withoutFiles, CountCap = CountCap, Oldest = oldest } @@ -145,7 +125,7 @@ private List Notices(RetentionStatus status, PrefixRetention cu if (currentFiles.Days is { } filesDays && (days == 0 || days > filesDays)) warnings.Add(Warning("OUTLIVE_FILES", $"Exchanges older than {filesDays} days are listed without their files: they can't be opened " + - $"or retried. {(exchanges.WithoutFiles == 1 ? "There is 1" : $"There are {Count(exchanges.WithoutFiles, exchanges.CountCap)}")} right now." + + "or retried." + (newOutliveFiles ? $" New exchanges lose theirs after {newFilesDays} days." : ""))); else if (newOutliveFiles) warnings.Add(Warning("OUTLIVE_FILES", diff --git a/SW.Bitween.Api/Services/SlowCounts.cs b/SW.Bitween.Api/Services/SlowCounts.cs new file mode 100644 index 00000000..2479f689 --- /dev/null +++ b/SW.Bitween.Api/Services/SlowCounts.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SW.Bitween; + +/// +/// Counts too slow to answer within a request, finished on the server instead and kept for the page +/// that asks again: the Exchanges page's status counts, its chart, and with them the list's total. +/// "Processing" across millions of exchanges, or a busy day's chart, takes longer than a request's 30 +/// seconds. The page shows what it has and asks again until the rest is there. +/// +/// +/// Kept in memory, on the node that counted. In a cluster each node counts a slow view once for itself, +/// which is still once per node rather than once per refresh. +/// +public sealed class SlowCounts(IServiceScopeFactory scopeFactory, IHostApplicationLifetime lifetime, + ILogger logger) +{ + /// + /// How long an answer stands before it is counted again. It is still given out while the new one is + /// worked out. A failed count is tried again after as long, and gives out nothing meanwhile. + /// + internal static readonly TimeSpan KeepFor = TimeSpan.FromMinutes(5); + + /// An answer nobody has asked for in this long is dropped. + private static readonly TimeSpan ForgetAfter = TimeSpan.FromMinutes(30); + + /// + /// A count whose turn comes when nobody has asked for it in this long is left uncounted: its page was + /// closed or moved to other filters. A page still waiting asks at least every minute. + /// + private static readonly TimeSpan StillWanted = TimeSpan.FromMinutes(2); + + /// The time one count gets on the database, where a request's commands get 30 seconds. + private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(10); + + /// + /// How many count at once on a node. Each is a long read of the largest tables, and a burst of slow + /// views mustn't take the database from the exchanges themselves; the rest wait their turn. + /// + private const int AtOnce = 2; + + private readonly ConcurrentDictionary entries = new(); + private readonly SemaphoreSlim places = new(AtOnce, AtOnce); + + /// + /// Whether is one counted here, given out as - the + /// default until the first count of it is in. Counted again in the background once its answer is + /// older than . + /// + public bool TryGet(string key, Func> count, out T answer) + { + answer = default; + if (!entries.TryGetValue(key, out var entry)) + return false; + + if (entry.Last is { } last) + answer = (T)last.Value; + Start(key, count); + return true; + } + + /// + /// Counts in the background, for a later to find. Does + /// nothing while it is already being counted, or was within . + /// + public void Start(string key, Func> count) + { + var now = DateTime.UtcNow; + var entry = entries.GetOrAdd(key, _ => new Entry()); + entry.AskedOn = now; + Forget(now); + + if (now - entry.TriedOn < KeepFor || Interlocked.Exchange(ref entry.Counting, 1) == 1) + return; + + _ = Task.Run(async () => + { + var stopping = lifetime.ApplicationStopping; + try + { + await places.WaitAsync(stopping); + try + { + // Left without trying, so it starts again as soon as its page is back. + if (DateTime.UtcNow - entry.AskedOn > StillWanted) + return; + + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.SetCommandTimeout(CommandTimeout); + entry.Last = new Kept(await count(dbContext, stopping)); + entry.TriedOn = DateTime.UtcNow; + } + finally + { + places.Release(); + } + } + catch (OperationCanceledException) when (stopping.IsCancellationRequested) + { + } + catch (Exception ex) + { + // The last answer can't be brought up to date, so it isn't given out as if it were. + entry.Last = null; + entry.TriedOn = DateTime.UtcNow; + logger.LogWarning(ex, "A count left to finish in the background failed. It is tried again in {Minutes} minutes.", + KeepFor.TotalMinutes); + } + finally + { + Interlocked.Exchange(ref entry.Counting, 0); + } + }); + } + + private void Forget(DateTime now) + { + foreach (var (key, entry) in entries) + if (entry.Counting == 0 && now - entry.AskedOn > ForgetAfter) + entries.TryRemove(key, out _); + } + + private sealed class Entry + { + public volatile Kept Last; + public int Counting; + public DateTime TriedOn; + public DateTime AskedOn; + } + + private sealed record Kept(object Value); +} diff --git a/SW.Bitween.HttpTests/BackgroundRequestTests.cs b/SW.Bitween.HttpTests/BackgroundRequestTests.cs new file mode 100644 index 00000000..06c04b40 --- /dev/null +++ b/SW.Bitween.HttpTests/BackgroundRequestTests.cs @@ -0,0 +1,382 @@ +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")); + + // Answered as the timeout it is, which the admin UI doesn't ask again for. + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + Assert.Contains("DATABASE_TIMED_OUT", await response.Content.ReadAsStringAsync()); + } + + [Fact] + 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/FailureExplanationTests.cs b/SW.Bitween.HttpTests/FailureExplanationTests.cs index 19bcc5a3..d5132436 100644 --- a/SW.Bitween.HttpTests/FailureExplanationTests.cs +++ b/SW.Bitween.HttpTests/FailureExplanationTests.cs @@ -97,6 +97,39 @@ public async Task A_failed_exchange_is_explained_from_masked_data_and_audited() Assert.True(audited, "the explanation wasn't recorded"); } + [Fact] + public async Task An_attempt_is_explained_with_its_whole_retry_chain_even_the_attempts_after_it() + { + var (admin, xchangeId) = await FailedExchangeAsync(); + var retried = await admin.PostAsJsonAsync($"/api/xchanges/{xchangeId}/retry", new { reason = "test", reset = false }); + Assert.True(retried.IsSuccessStatusCode, $"retry answered {retried.StatusCode}"); + // The retry fails the same way; waited for, so its outcome is in the chain rather than "still running". + for (var waited = 0; !await fixture.InDbAsync(db => db.Set().AsNoTracking() + .AnyAsync(r => db.Set().Any(x => x.RetryFor == xchangeId && x.Id == r.Id))); waited += 100) + { + Assert.True(waited < 15_000, "the retry never finished"); + await Task.Delay(100); + } + await ConfigureAsync(admin); + var model = new FakeModel("""{ "summary": "S", "cause": "C", "fix": "F" }"""); + AiModels.ModelOverride = _ => model; + + // The first attempt, opened after its retry: the chain still shows the attempt after it. + await Api.Json(await admin.PostAsJsonAsync("/api/xchangeexplanations", new { xchangeId })); + + var sent = Assert.Single(model.Asked).Message; + Assert.Contains("This is attempt 1 of 2 in its retry chain.", sent); + Assert.Contains("", sent); + var chain = sent[sent.IndexOf("")..sent.IndexOf("")].Split('\n'); + var first = Assert.Single(chain, l => l.StartsWith("Attempt 1 (this one), from ")); + var second = Assert.Single(chain, l => l.StartsWith("Attempt 2, from ")); + // Both failed in mapping, the same way: each line says so, with the error's wording. + Assert.Contains(" UTC: failed: ", first); + Assert.Contains(" UTC: failed: ", second); + Assert.Equal(first[first.IndexOf(" UTC: ")..], second[second.IndexOf(" UTC: ")..]); + Assert.DoesNotContain("maybe", sent); + } + [Fact] public async Task Without_AI_set_up_nothing_is_read_or_sent() { 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/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index 84737102..6d711d93 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -366,6 +366,7 @@ await Task.WhenAll(_postgres.StartAsync(), _rabbitMq.StartAsync(), _mailHog.Star services.AddSingleton(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); diff --git a/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs b/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs new file mode 100644 index 00000000..9fcc384d --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/BulkRetryInBackgroundTests.cs @@ -0,0 +1,210 @@ +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 One_that_stops_with_an_error_part_way_has_what_it_saved_on_record() + { + await using var scope = fixture.CreateScope(); + var (db, xs, ctx) = Services(scope); + var ids = await FailedXchangesAsync(db, xs, 250); + var target = $"{ids.Count} exchanges"; + var from = DateTime.UtcNow; + + using var stop = new CancellationTokenSource(); + // Something fails once the first hundred are saved, as a stalled pipeline or a lost database would. + BackgroundRequestScope.Open(DateTime.UtcNow + BackgroundRequests.Limit, stop.Token, default, + (done, _, _) => { if (done >= 100) throw new InvalidOperationException("lost the database"); }); + await Assert.ThrowsAsync(() => + new Resources.Xchanges.BulkRetry(db, ctx, xs).Handle(new XchangeBulkRetry { Ids = ids })); + + Assert.Equal(100, await Retries(db, ids)); + var recorded = await db.Set().AsNoTracking() + .Where(a => a.Action == OperatorAction.BulkRetry && a.OccurredOn >= from && + (a.Target == target || a.Target == "100 exchanges")) + .OrderBy(a => a.OccurredOn).Select(a => a.Detail).ToListAsync(); + Assert.Collection(recorded, + started => Assert.StartsWith($"Started in the background: {ids.Count} to retry", started), + stopped => Assert.StartsWith("100 retried, 0 skipped, then it stopped with an error", stopped)); + } + + [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.IntegrationTests/Tests/DatabaseTimeoutTests.cs b/SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs new file mode 100644 index 00000000..cb34e2b5 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/DatabaseTimeoutTests.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Services; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// A request whose query runs past the database's time limit answers 504 with +/// DATABASE_TIMED_OUT: the admin UI doesn't ask again for it, and says what happened. +/// +[Collection("Bitween")] +public class DatabaseTimeoutTests(BitweenFixture fixture) +{ + [Fact] + public async Task A_query_past_the_database_time_limit_is_answered_as_a_timeout() + { + // What real timeouts throw, rather than exceptions made up to look like them: past the + // connection's Command Timeout, past the database's own statement_timeout, and cancelled by + // the caller, which is not one. + Exception commandTimedOut, statementTimedOut, cancelled; + await using (var scope = fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.SetCommandTimeout(1); + commandTimedOut = await Record.ExceptionAsync(() => db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(5)")); + } + + await using (var scope = fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await db.Database.BeginTransactionAsync(); + await db.Database.ExecuteSqlRawAsync("SET LOCAL statement_timeout = 500"); + statementTimedOut = await Record.ExceptionAsync(() => db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(5)")); + } + + await using (var scope = fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + cancelled = await Record.ExceptionAsync(() => db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(5)", cancel.Token)); + } + + Assert.True(DatabaseTimeoutMiddleware.IsDatabaseTimeout(statementTimedOut), statementTimedOut?.ToString()); + Assert.False(DatabaseTimeoutMiddleware.IsDatabaseTimeout(cancelled), cancelled?.ToString()); + + var context = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + await Answer(context, commandTimedOut); + + Assert.Equal(StatusCodes.Status504GatewayTimeout, context.Response.StatusCode); + Assert.Equal("nosniff", context.Response.Headers["X-Content-Type-Options"]); + context.Response.Body.Position = 0; + Assert.Contains(DatabaseTimeoutMiddleware.Code, await new StreamReader(context.Response.Body).ReadToEndAsync()); + + // Anything else fails as it did, a timeout outside the database included. + await Assert.ThrowsAsync(() => Answer(new DefaultHttpContext(), new InvalidOperationException())); + await Assert.ThrowsAsync(() => Answer(new DefaultHttpContext(), new TimeoutException())); + } + + private static Task Answer(HttpContext context, Exception thrown) => + new DatabaseTimeoutMiddleware(_ => throw thrown, NullLogger.Instance) + .InvokeAsync(context); +} diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeExportTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeExportTests.cs index 4276334d..162565db 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeExportTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeExportTests.cs @@ -66,6 +66,31 @@ public async Task Each_exchange_gets_a_folder_named_like_its_archive_with_the_fi }, zip); } + [Fact] + public async Task A_file_that_would_take_the_zip_past_its_limit_is_left_out_before_it_is_read() + { + var document = await InformationType(); + var small = await CreateExchange(document, "{\"n\":1}", "small.json"); + var large = await CreateExchange(document, "{\"text\":\"" + new string('x', 400) + "\"}", "large.json"); + + var limit = XchangeExportController.MaxBytes; + XchangeExportController.MaxBytes = 300; + try + { + await using var scope = fixture.CreateScope(); + scope.Superuser(); + var (_, zip, _) = await Export(scope, new XchangeFilesExport { Ids = [small.Id, large.Id] }); + + Assert.Equal("{\"n\":1}", zip[$"{small.Id}/input.json"]); + Assert.False(zip.ContainsKey($"{large.Id}/input.json")); + Assert.Contains($"{large.Id}/input.json: left out, since it would take the zip past 300 bytes", zip["missing.txt"]); + } + finally + { + XchangeExportController.MaxBytes = limit; + } + } + [Fact] public async Task A_file_storage_no_longer_has_is_listed_in_missing_txt_and_the_rest_still_come() { @@ -168,6 +193,48 @@ public async Task A_selection_past_the_limit_or_an_empty_one_is_refused_before_a Assert.True(Assert.IsType(Assert.IsType(empty).Value).ContainsKey("NOTHING_SELECTED")); } + [Fact] + public async Task In_the_background_one_export_is_built_at_a_time_and_the_next_says_it_waits() + { + var document = await InformationType(); + var xchange = await CreateExchange(document, "{\"n\":1}", "one.json"); + var said = new List(); + var every = XchangeExportController.SayWaitingEvery; + XchangeExportController.SayWaitingEvery = TimeSpan.FromMilliseconds(50); + // Another export is being built on this node. + await XchangeExportController.BuildingInBackground.WaitAsync(); + try + { + await using var scope = fixture.CreateScope(); + scope.Superuser(); + using var stop = new CancellationTokenSource(); + var exporting = Task.Run(async () => + { + Services.Background.BackgroundRequestScope.Open(DateTime.UtcNow + Services.Background.BackgroundRequests.Limit, + stop.Token, default, (_, _, summary) => { lock (said) said.Add(summary); }); + return await Export(scope, new XchangeFilesExport { Ids = [xchange.Id] }); + }); + + for (var waited = 0; !said.Contains("Waiting for another export on this server to finish"); waited += 50) + { + Assert.True(waited < 5_000, "it never said it was waiting"); + await Task.Delay(50); + } + Assert.False(exporting.IsCompleted); + + XchangeExportController.BuildingInBackground.Release(); + var (_, zip, _) = await exporting.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal("{\"n\":1}", zip[$"{xchange.Id}/input.json"]); + // Its turn is given back for the next. + Assert.Equal(1, XchangeExportController.BuildingInBackground.CurrentCount); + } + finally + { + XchangeExportController.SayWaitingEvery = every; + if (XchangeExportController.BuildingInBackground.CurrentCount == 0) XchangeExportController.BuildingInBackground.Release(); + } + } + [Fact] public async Task Exporting_needs_the_exchanges_view() { diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs index 9383b41d..93dc5dfc 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs @@ -260,7 +260,7 @@ private static async Task Search(AsyncServiceScope scope, params strin { var (db, xs) = Services(scope); var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser()) - .Handle(new SearchyRequest(filters) { PageSize = 50 }); + .Handle(new SearchyRequest(filters) { PageSize = 50, CountRows = true }); Assert.Equal(response.Result.Count(), response.TotalCount); return response.Result.Select(r => r.Id).Order().ToArray(); } @@ -276,6 +276,7 @@ private static async Task Select(AsyncServiceScope scope, params strin private static async Task Summarise(AsyncServiceScope scope, params string[] filters) { var db = scope.ServiceProvider.GetRequiredService(); - return (XchangeSummary)await new Summary(db, scope.Superuser()).Handle(new SearchyRequest(filters)); + return (XchangeSummary)await new Summary(db, scope.Superuser(), scope.ServiceProvider.GetRequiredService()) + .Handle(new SearchyRequest(filters)); } } diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs index 1387b566..b97f9225 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs @@ -255,9 +255,6 @@ public async Task The_preview_says_when_exchanges_outlive_their_files_and_archiv DocumentPrefix = "temp30/docs", ExchangeRetentionDays = 20, ArchiveExchanges = true }); Assert.DoesNotContain(shorter.Notices, n => n.Code is "OUTLIVE_FILES" or "ARCHIVE_WITHOUT_FILES"); - // How many are without files is as things stand now, whatever the proposal. - Assert.NotNull(shorter.Exchanges.WithoutFiles); - Assert.Equal(longer.Exchanges.WithoutFiles, shorter.Exchanges.WithoutFiles); var forever = await planner.Plan(new RetentionProposal { DocumentPrefix = "docs/kept", ExchangeRetentionDays = 0 }); Assert.Null(forever.Files.Days); @@ -265,28 +262,26 @@ public async Task The_preview_says_when_exchanges_outlive_their_files_and_archiv } [Fact] - public async Task Exchanges_without_files_are_counted_by_the_prefix_each_was_written_under() + public async Task Exchanges_kept_longer_than_their_files_are_warned_of_beside_when_the_oldest_started() { var (_, subscription) = await Setup(); - var shortPrefix = $"temp30/{Guid.NewGuid():N}"; - var longPrefix = $"temp365/{Guid.NewGuid():N}"; - _options.DocumentPrefix = longPrefix; + _options.DocumentPrefix = $"temp30/{Guid.NewGuid():N}"; + var old = await CreateExchange(subscription, "{}", new(), succeeded: true); + await Backdate(old.Id, 45); + await using var scope = fixture.CreateScope(); var planner = ActivatorUtilities.CreateInstance(scope.ServiceProvider, ExchangeFilesTests.RetentionOver( - new CloudFilesLifecycleRule { Id = "temp30", Prefix = "temp30/", Days = 30, Enabled = true }, - new CloudFilesLifecycleRule { Id = "temp365", Prefix = "temp365/", Days = 365, Enabled = true })); - var before = (await planner.Plan()).Exchanges.WithoutFiles; - - _options.DocumentPrefix = shortPrefix; - var underShort = await CreateExchange(subscription, "{}", new(), succeeded: true); - _options.DocumentPrefix = longPrefix; - var underLong = await CreateExchange(subscription, "{}", new(), succeeded: true); - await Backdate(underShort.Id, 45); - await Backdate(underLong.Id, 45); - - // Today's prefix keeps files a year, but the one written under the 30-day prefix has lost its own. - Assert.Equal(before + 1, (await planner.Plan()).Exchanges.WithoutFiles); + new CloudFilesLifecycleRule { Id = "temp30", Prefix = "temp30/", Days = 30, Enabled = true })); + + // Kept for ever, files for 30 days. Other tests leave old exchanges too, so the oldest is the + // database's, not necessarily this one. + var status = await planner.Plan(new RetentionProposal { ExchangeRetentionDays = 0 }); + var oldest = await scope.ServiceProvider.GetRequiredService().Set() + .MinAsync(x => (DateTime?)x.StartedOn); + Assert.Equal(oldest, status.Exchanges.Oldest); + Assert.True(status.Exchanges.Oldest <= DateTime.UtcNow.AddDays(-45)); + Assert.Single(status.Notices, n => n.Code == "OUTLIVE_FILES"); } [Fact] diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs index bcaecc87..4eebb683 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -63,7 +65,8 @@ await db.Set().Where(x => x.Id == id) private static async Task Summarise(AsyncServiceScope scope, params string[] filters) { var db = scope.ServiceProvider.GetRequiredService(); - return (XchangeSummary)await new Summary(db, scope.Superuser()).Handle(new SearchyRequest(filters)); + return (XchangeSummary)await new Summary(db, scope.Superuser(), scope.ServiceProvider.GetRequiredService()) + .Handle(new SearchyRequest(filters)); } private static async Task> Search(AsyncServiceScope scope, params string[] filters) @@ -71,7 +74,7 @@ private static async Task> Search(AsyncServiceScope scope, params s var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser()) - .Handle(new SearchyRequest(filters) { PageSize = 50 }); + .Handle(new SearchyRequest(filters) { PageSize = 50, CountRows = true }); Assert.Equal(response.Result.Count(), response.TotalCount); return response.Result.Select(r => r.Id).ToList(); } @@ -176,6 +179,7 @@ public async Task Summary_counts_every_status_and_ignores_a_status_filter() Assert.Equal(1, summary.Counts.Success); Assert.Equal(1, summary.Counts.BadResponse); Assert.Equal(2, summary.Counts.Failed); + Assert.False(summary.Counting); // No date range, so nothing to divide into buckets. Assert.Empty(summary.Buckets); @@ -187,6 +191,97 @@ public async Task Summary_counts_every_status_and_ignores_a_status_filter() Assert.Equal(1, narrowed.Counts.Failed); } + [Fact] + public async Task A_query_past_its_budget_is_cut_off_and_the_summary_carries_on() + { + await using var scope = fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await db.Database.BeginTransactionAsync(); + + // Stopped on the database when the budget runs out, rather than left to finish. + var watch = Stopwatch.StartNew(); + using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + var slow = await Summary.WithinBudget(db, deadline.Token, + async ct => (int?)await db.Database.SqlQueryRaw("SELECT 1 AS \"Value\" FROM pg_sleep(10)").SingleAsync(ct)); + Assert.Null(slow); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(5), $"Cut off after {watch.Elapsed}."); + + // Whatever is still to count under the same budget isn't started. + Assert.Null(await Summary.WithinBudget(db, deadline.Token, + async ct => (int?)await db.Set().CountAsync(ct))); + + // The transaction still answers: without going back to before the cancelled statement, + // Postgres refuses everything after it. + Assert.NotNull(await Summary.WithinBudget(db, CancellationToken.None, + async ct => (int?)await db.Set().CountAsync(ct))); + } + + [Fact] + public async Task A_count_left_to_the_background_is_given_out_once_it_is_in() + { + var slowCounts = fixture.App.Services.GetRequiredService(); + var key = $"test:{Guid.NewGuid():N}"; + TimeSpan? commandTimeout = null; + async Task Count(BitweenDbContext db, CancellationToken ct) + { + commandTimeout = db.Database.GetCommandTimeout() is { } seconds ? TimeSpan.FromSeconds(seconds) : null; + return await db.Database.SqlQueryRaw("SELECT 42 AS \"Value\" FROM pg_sleep(1)").SingleAsync(ct); + } + + // Never too slow before, so the caller works it out itself. + Assert.False(slowCounts.TryGet(key, Count, out _)); + + // Too slow now: on its way, and nothing to give out yet. + slowCounts.Start(key, Count); + Assert.True(slowCounts.TryGet(key, Count, out var meanwhile)); + Assert.Null(meanwhile); + + var answer = (int?)null; + for (var waited = Stopwatch.StartNew(); answer == null && waited.Elapsed < TimeSpan.FromSeconds(20);) + { + await Task.Delay(100); + slowCounts.TryGet(key, Count, out answer); + } + Assert.Equal(42, answer); + // Not held to a request's 30 seconds. + Assert.Equal(TimeSpan.FromMinutes(10), commandTimeout); + } + + [Fact] + public void A_view_since_a_moment_is_the_same_view_a_little_later_and_a_fixed_range_is_kept_exact() + { + var now = new DateTime(2026, 11, 2, 12, 0, 0, DateTimeKind.Utc); + string Since(DateTime from) => $"StartedOn:6:{from:O}"; + + // "Last 24 hours" asked twice, 15 seconds apart. + Assert.Equal( + Summary.ViewKey(new SearchyRequest([Since(now.AddDays(-1)), "DocumentId:1:5"]), now), + Summary.ViewKey(new SearchyRequest(["DocumentId:1:5", Since(now.AddDays(-1).AddSeconds(15))]), now.AddSeconds(15))); + Assert.NotEqual( + Summary.ViewKey(new SearchyRequest([Since(now.AddDays(-1))]), now), + Summary.ViewKey(new SearchyRequest([Since(now.AddHours(-1))]), now)); + + // With an end, the range doesn't move, so its dates are the view. + var fixedRange = new SearchyRequest([Since(now.AddDays(-2)), $"StartedOn:8:{now.AddDays(-1):O}"]); + Assert.Equal(Summary.ViewKey(fixedRange, now), Summary.ViewKey(fixedRange, now.AddHours(1))); + Assert.DoesNotContain("since", Summary.ViewKey(fixedRange, now)); + } + + [Fact] + public async Task Search_with_count_false_leaves_the_total_out() + { + await using var scope = fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + var seeded = await OneOfEach(db, xs, "Search Without Count Doc"); + + var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser()) + .Handle(new SearchyRequest($"filter=DocumentId:1:{seeded.DocumentId}&count=false") { PageSize = 50 }); + + Assert.Equal(5, response.Result.Count()); + Assert.Equal(0, response.TotalCount); + } + [Fact] public async Task Summary_buckets_a_range_by_status_zero_filled_and_aligned_to_the_clock() { diff --git a/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs index ef64f8f0..d1b012db 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryChainTests.cs @@ -530,6 +530,73 @@ public async Task RetrySummary_ranks_the_chains_that_keep_failing_and_says_wheth Assert.True(retries.GetProperty("Finished").GetInt32() >= retries.GetProperty("Succeeded").GetInt32()); } + /// + /// The panel looks at the same seven days as its retry numbers: a chain nothing has retried for + /// longer is still a failure to act on, but no longer one that keeps failing. + /// + [Fact] + public async Task RetrySummary_leaves_out_chains_nothing_has_retried_for_a_week() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); + + var (_, first) = await FailedXchange(db, xs, "Summary Old Doc"); + var second = await RetryOnce(db, xs, ctx, first.Id); + var third = await RetryOnce(db, xs, ctx, second.Id); + var chain = new[] { first.Id, second.Id, third.Id }; + await db.Set().Where(x => chain.Contains(x.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.StartedOn, x => x.StartedOn.AddDays(-8))); + + var summary = await new Resources.Dashboard.RetrySummary(db, ctx).Handle(); + var json = System.Text.Json.JsonSerializer.SerializeToElement(summary); + + Assert.DoesNotContain(json.GetProperty("FailingChains").EnumerateArray(), + c => c.GetProperty("Id").GetString() == third.Id); + } + + /// + /// The dashboard's failures-to-act-on tile counts from the failures rather than through the + /// exchange list, and has to come to the number the list shows for the view the tile opens. + /// + [Fact] + public async Task FailuresToActOn_counts_what_the_list_of_failures_to_act_on_counts() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); + + var tile = new Resources.Dashboard.FailuresToActOn(db, ctx); + async Task Total() => + System.Text.Json.JsonSerializer.SerializeToElement(await tile.Handle()).GetProperty("Total").GetInt32(); + var before = await Total(); + + // A chain of three failed attempts is one failure to act on: the attempt at its end. + var (sub, first) = await FailedXchange(db, xs, "To Act On Doc"); + var second = await RetryOnce(db, xs, ctx, first.Id); + await RetryOnce(db, xs, ctx, second.Id); + + // A failure whose retry worked leaves nothing to act on. + var healed = await xs.CreateXchange(sub, new XchangeFile("{}")); + await db.SaveChangesAsync(); + db.Set().Add(new XchangeResult(healed.Id, null, null, exception: "boom")); + await db.SaveChangesAsync(); + await new Resources.Xchanges.Retry(db, ctx, xs).Handle(healed.Id, new XchangeRetry()); + await db.SaveChangesAsync(); + var healedRetry = await db.Set().FirstAsync(x => x.RetryFor == healed.Id); + db.Set().Add(new XchangeResult(healedRetry.Id, null, null)); + await db.SaveChangesAsync(); + + var after = await Total(); + Assert.Equal(before + 1, after); + + var listed = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, ctx) + .Handle(new SearchyRequest("filter=StatusFilter:1:3&filter=LatestOnly:1:true") { PageSize = 1 }); + Assert.Equal(listed.TotalCount, after); + } + // ─── Reading the chain back ─────────────────────────────────────────────── [Fact] diff --git a/SW.Bitween.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..eba1655e 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"); @@ -387,6 +407,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(i => i.SubscriptionId); b.HasIndex(i => i.StartedOn); b.HasIndex(i => i.RetryFor); + // Retries newest first, for the dashboard's retry panel: a few percent of the + // exchanges, read without touching the rows. + b.HasIndex(i => i.StartedOn, "ix_xchange_retries_started_on") + .HasDatabaseName("ix_xchange_retries_started_on") + .HasFilter("retry_for IS NOT NULL") + .IncludeProperties(i => new { i.Id, i.RetryFor }) + .IsCreatedConcurrently(); }); modelBuilder.Entity(b => @@ -418,6 +445,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.HandlerVersion).HasMaxLength(50); // A subscription's latest result: what auto-pause counts from. b.HasIndex(p => new { p.SubscriptionId, p.FinishedOn }).IsCreatedConcurrently(); + // The failures alone, a few percent of the results: what the dashboard counts the + // failures still to act on from, and how it asks whether a retry failed. + b.HasIndex(p => p.Id, "ix_xchange_result_failed") + .HasDatabaseName("ix_xchange_result_failed") + .HasFilter("NOT success") + .IsCreatedConcurrently(); b.HasOne().WithOne().HasForeignKey(p => p.Id).OnDelete(DeleteBehavior.Cascade); }); diff --git a/SW.Bitween.PgSql/ConcurrentIndex.cs b/SW.Bitween.PgSql/ConcurrentIndex.cs index 2ac6eab3..e46995a7 100644 --- a/SW.Bitween.PgSql/ConcurrentIndex.cs +++ b/SW.Bitween.PgSql/ConcurrentIndex.cs @@ -10,14 +10,20 @@ namespace SW.Bitween.PgSql; /// table of tens of millions of rows, inside the startup migration. CONCURRENTLY doesn't, but it /// cannot run in a transaction, and a build that fails part-way leaves the index behind marked /// invalid: the IF NOT EXISTS would then skip the rebuild and the migration would pass with an -/// index Postgres never uses. So an invalid leftover is dropped first, as -/// PromotedPropertiesTrigramIndex does. +/// index Postgres never uses. So an invalid leftover goes first. +/// +/// Not with a plain DROP INDEX, which locks the whole table: queued behind whatever is reading it, +/// it holds up every read and insert that comes after. The leftover is renamed out of the way +/// instead - a lock reads and writes don't wait for - and dropped concurrently, which a DO block +/// can't do. A valid index of the name is left as it is. /// internal static class ConcurrentIndex { /// What follows ON infolink.table: the columns, and any WHERE. public static void Create(MigrationBuilder migrationBuilder, string table, string name, string definition) { + var leftover = name + "_invalid"; + migrationBuilder.Sql( $@"DO $$ BEGIN @@ -28,10 +34,12 @@ FROM pg_index i JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '{BitweenDbContext.Schema}' AND c.relname = '{name}' AND NOT i.indisvalid ) THEN - DROP INDEX {BitweenDbContext.Schema}.{name}; + ALTER INDEX {BitweenDbContext.Schema}.{name} RENAME TO {leftover}; END IF; END $$;"); + Drop(migrationBuilder, leftover); + migrationBuilder.Sql( $"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} ON {BitweenDbContext.Schema}.{table} {definition};", suppressTransaction: true); diff --git a/SW.Bitween.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/20261101132211_DashboardIndexes.Designer.cs b/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.Designer.cs new file mode 100644 index 00000000..fccc0ee9 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.Designer.cs @@ -0,0 +1,4209 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SW.Bitween.Model; +using SW.Bitween.PgSql; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20261101132211_DashboardIndexes")] + partial class DashboardIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasColumnName("failed_login_count"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("LastSignInOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in_on"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("MicrosoftIdentity") + .HasColumnType("text") + .HasColumnName("microsoft_identity"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("OidcIdentity") + .HasColumnType("text") + .HasColumnName("oidc_identity"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("email_error"); + + b.Property("EmailedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("emailed_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("Purpose") + .HasColumnType("smallint") + .HasColumnName("purpose"); + + b.HasKey("Id") + .HasName("pk_account_links"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_links_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_account_links_hash"); + + b.ToTable("AccountLinks", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.Property("SupersededOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_on"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("last_used_from"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("revoked_by"); + + b.Property("RevokedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_on"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("shown"); + + b.HasKey("Id") + .HasName("pk_service_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_service_tokens_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_service_tokens_hash"); + + b.ToTable("ServiceTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("base_version"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("files_hash"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("files_json"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("language"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_adapter_draft"); + + b.HasIndex("AdapterId") + .HasDatabaseName("ix_adapter_draft_adapter_id"); + + b.ToTable("adapter_draft", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("action"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("DraftId") + .HasColumnType("integer") + .HasColumnName("draft_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_release"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_release_adapter_id_occurred_on"); + + b.ToTable("adapter_release", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("path"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_source_access"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_source_access_adapter_id_occurred_on"); + + b.ToTable("adapter_source_access", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key_name"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.HasKey("PartnerId", "KeyName") + .HasName("pk_api_key_use"); + + b.ToTable("api_key_use", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Changes") + .HasColumnType("text") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("character varying(36)") + .HasColumnName("correlation_id"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_key"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_name"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Sequence") + .HasColumnType("integer") + .HasColumnName("sequence"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("character varying(10)") + .HasColumnName("state"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_audit_entries"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_entries_correlation_id"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_audit_entries_occurred_on"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn") + .HasDatabaseName("ix_audit_entries_entity_name_entity_key_occurred_on"); + + b.ToTable("AuditEntries", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Done") + .HasColumnType("integer") + .HasColumnName("done"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("Node") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("node"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("owner"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("path"); + + b.Property("Result") + .HasColumnType("bytea") + .HasColumnName("result"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("result_content_type"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_disposition"); + + b.Property("ResultKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_key"); + + b.Property("ResultStatus") + .HasColumnType("integer") + .HasColumnName("result_status"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("summary"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Id") + .HasName("pk_background_request"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_background_request_started_on"); + + b.HasIndex("Owner", "StartedOn") + .HasDatabaseName("ix_background_request_owner_started_on"); + + b.ToTable("background_request", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("AcquiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_on"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("owner_node"); + + b.Property("Term") + .HasColumnType("bigint") + .HasColumnName("term"); + + b.HasKey("Id") + .HasName("pk_cluster_lease"); + + b.ToTable("cluster_lease", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("DataSources") + .HasColumnType("boolean") + .HasColumnName("data_sources"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("host"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("integer") + .HasColumnName("outbox_published_last_hour"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("resident_health"); + + b.Property("Runtimes") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtimes"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_cluster_node"); + + b.HasIndex("LastSeenOn") + .HasDatabaseName("ix_cluster_node_last_seen_on"); + + b.ToTable("cluster_node", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("access_token_expires_on"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("authorize_parameters"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("authorize_url"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("authorized_by"); + + b.Property("AuthorizedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("authorized_on"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Grant") + .HasColumnType("integer") + .HasColumnName("grant"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property("RenewingUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("renewing_until"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("scopes"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("token_auth"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("token_url"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_connections"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_connections_name"); + + b.ToTable("Connections", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("instance_key"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("value"); + + b.HasKey("AdapterId", "InstanceKey", "Name") + .HasName("pk_adapter_state"); + + b.ToTable("adapter_state", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .HasColumnType("text") + .HasColumnName("adapter_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CpuLimitSamples") + .HasColumnType("integer") + .HasColumnName("cpu_limit_samples"); + + b.Property("CpuPercentLimit") + .HasColumnType("double precision") + .HasColumnName("cpu_percent_limit"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("integer") + .HasColumnName("deduplication_window_days"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("hard_memory_limit_mb"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("LastHeartbeatOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_heartbeat_on"); + + b.Property("LastKnownState") + .HasColumnType("text") + .HasColumnName("last_known_state"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnedByNode") + .HasColumnType("text") + .HasColumnName("owned_by_node"); + + b.Property("Placement") + .HasColumnType("integer") + .HasColumnName("placement"); + + b.Property>("Properties") + .HasColumnType("hstore") + .HasColumnName("properties"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("text[]") + .HasColumnName("secret_properties"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("soft_memory_limit_mb"); + + b.HasKey("Id") + .HasName("pk_data_source"); + + b.ToTable("data_source", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("CursorColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("cursor_column"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key_column"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sql"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_data_source_statement"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_data_source_statement_work_group_id"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique() + .HasDatabaseName("ix_data_source_statement_data_source_id_name"); + + b.ToTable("data_source_statement", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("id"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("SeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("seen_on"); + + b.Property("XchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_inbound_message"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_inbound_message_data_source_id"); + + b.HasIndex("SeenOn") + .HasDatabaseName("ix_inbound_message_seen_on"); + + b.ToTable("inbound_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.Property("RunFailures") + .HasColumnType("integer") + .HasColumnName("run_failures"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DropDuplicates") + .HasColumnType("boolean") + .HasColumnName("drop_duplicates"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("json") + .HasColumnName("promoted_properties"); + + b.Property("RetiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_on"); + + b.Property("ValidationSchema") + .HasColumnType("text") + .HasColumnName("validation_schema"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Dropped") + .HasColumnType("boolean") + .HasColumnName("dropped"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("original_xchange_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.HasKey("Id") + .HasName("pk_duplicate_arrival"); + + b.HasIndex("DocumentId", "OccurredOn") + .HasDatabaseName("ix_duplicate_arrival_document_id_occurred_on"); + + b.ToTable("duplicate_arrival", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("integer") + .HasColumnName("auth_method"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FailureAnswer") + .HasColumnType("integer") + .HasColumnName("failure_answer"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_audience"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_issuer"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("jwt_partner_claim"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("partner_key_header"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Endpoint") + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property>("EndpointProperties") + .HasColumnType("hstore") + .HasColumnName("endpoint_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_bus_gateway_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.Property("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_idempotency_key"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_idempotency_key_created_on"); + + b.HasIndex("Scope", "Key") + .IsUnique() + .HasDatabaseName("ix_idempotency_key_scope_key"); + + b.ToTable("idempotency_key", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("Role") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Runtime") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("runtime"); + + b.Property("Calls") + .HasColumnType("integer") + .HasColumnName("calls"); + + b.Property("Crashes") + .HasColumnType("integer") + .HasColumnName("crashes"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failures") + .HasColumnType("integer") + .HasColumnName("failures"); + + b.Property("LimitStops") + .HasColumnType("integer") + .HasColumnName("limit_stops"); + + b.Property("Timeouts") + .HasColumnType("integer") + .HasColumnName("timeouts"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime") + .HasName("pk_adapter_stats"); + + b.ToTable("adapter_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("Scope") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("scope"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope_key"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("fingerprint"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("sample"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint") + .HasName("pk_error_stats"); + + b.ToTable("error_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("BadResponse") + .HasColumnType("integer") + .HasColumnName("bad_response"); + + b.Property("BytesIn") + .HasColumnType("bigint") + .HasColumnName("bytes_in"); + + b.Property("BytesOut") + .HasColumnType("bigint") + .HasColumnName("bytes_out"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DbCommands") + .HasColumnType("bigint") + .HasColumnName("db_commands"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failed") + .HasColumnType("integer") + .HasColumnName("failed"); + + b.Property("FailedAtHandler") + .HasColumnType("integer") + .HasColumnName("failed_at_handler"); + + b.Property("FailedAtMapper") + .HasColumnType("integer") + .HasColumnName("failed_at_mapper"); + + b.Property("ManualRetries") + .HasColumnType("integer") + .HasColumnName("manual_retries"); + + b.Property("PayloadParses") + .HasColumnType("bigint") + .HasColumnName("payload_parses"); + + b.Property("Retries") + .HasColumnType("integer") + .HasColumnName("retries"); + + b.Property("Succeeded") + .HasColumnType("integer") + .HasColumnName("succeeded"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId") + .HasName("pk_exchange_stats"); + + b.ToTable("exchange_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("timestamp with time zone") + .HasColumnName("at"); + + b.Property("AckRate") + .HasColumnType("double precision") + .HasColumnName("ack_rate"); + + b.Property("Consumers") + .HasColumnType("integer") + .HasColumnName("consumers"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint") + .HasColumnName("dead_letter_backlog"); + + b.Property("IncomingRate") + .HasColumnType("double precision") + .HasColumnName("incoming_rate"); + + b.Property("QueueDepth") + .HasColumnType("bigint") + .HasColumnName("queue_depth"); + + b.Property("RetryBacklog") + .HasColumnType("bigint") + .HasColumnName("retry_backlog"); + + b.Property("UnhealthyConsumers") + .HasColumnType("integer") + .HasColumnName("unhealthy_consumers"); + + b.HasKey("At") + .HasName("pk_queue_snapshot"); + + b.ToTable("queue_snapshot", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_notification_channel"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_channel_name"); + + b.ToTable("notification_channel", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.PrimitiveCollection("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("detail"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("target"); + + b.HasKey("Id") + .HasName("pk_operator_action"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_operator_action_occurred_on"); + + b.ToTable("operator_action", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ClaimedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_until"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_on"); + + b.HasKey("Id") + .HasName("pk_outbox_message"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_outbox_message_created_on") + .HasFilter("published_on IS NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("outbox_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("login_identity"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.HasIndex("LoginIdentity") + .IsUnique() + .HasDatabaseName("ix_partner_login_identity"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.PrimitiveCollection("ExchangeIds") + .HasColumnType("text[]") + .HasColumnName("exchange_ids"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_receive_attempt"); + + b.HasIndex("SubscriptionId", "StartedOn") + .HasDatabaseName("ix_receive_attempt_subscription_id_started_on"); + + b.ToTable("receive_attempt", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("AlertMode") + .HasColumnType("smallint") + .HasColumnName("alert_mode"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_alert_override"); + + b.ToTable("retry_alert_override", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AttemptsUsed") + .HasColumnType("integer") + .HasColumnName("attempts_used"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("exhausted_notified_on"); + + b.Property("LastAttemptOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_on"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_group_usage"); + + b.ToTable("retry_group_usage", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_settings"); + + b.ToTable("Settings", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("AutoPauseAfterFailures") + .HasColumnType("integer") + .HasColumnName("auto_pause_after_failures"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ExpectTraffic") + .HasColumnType("text") + .HasColumnName("expect_traffic"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Notifications") + .HasColumnType("text") + .HasColumnName("notifications"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedAutomatically") + .HasColumnType("boolean") + .HasColumnName("paused_automatically"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("QuietSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("quiet_since"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ReceiverVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("receiver_version"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("RunOnBadResponses") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_responses"); + + b.Property("RunningSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("running_since"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("ValidatorVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("validator_version"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_subscription_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("saved_by"); + + b.Property("SavedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("saved_on"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("text") + .HasColumnName("snapshot"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_revision"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique() + .HasDatabaseName("ix_subscription_revision_subscription_id_number"); + + b.ToTable("subscription_revision", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("files_prefix"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on") + .HasDatabaseName("ix_xchange_retries_started_on") + .HasFilter("retry_for IS NOT NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on"), new[] { "Id", "RetryFor" }); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer") + .HasColumnName("channel_id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Suppressed") + .HasColumnType("boolean") + .HasColumnName("suppressed"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.HasIndex("XchangeId") + .HasDatabaseName("ix_xchange_notification_xchange_id"); + + b.HasIndex("ChannelId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_channel_id_finished_on"); + + b.HasIndex("NotifierId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_notifier_id_finished_on"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.PrimitiveCollection("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("NodeName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node_name"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("retry_blocked_reason"); + + b.Property("RetryGroupId") + .HasColumnType("uuid") + .HasColumnName("retry_group_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("warning"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.HasIndex("RetryGroupId") + .HasDatabaseName("ix_xchange_result_retry_group_id"); + + b.HasIndex("SubscriptionId", "FinishedOn") + .HasDatabaseName("ix_xchange_result_subscription_id_finished_on") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex(new[] { "Id" }, "ix_xchange_result_failed") + .HasDatabaseName("ix_xchange_result_failed") + .HasFilter("NOT success") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("ClaimedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_on"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_retry_claim"); + + b.ToTable("xchange_retry_claim", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_links_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_service_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_data_source_statement_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_data_source_statement_work_group_work_group_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inbound_message_data_source_data_source_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .HasConstraintName("fk_bus_gateway_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("key_prefix"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "ResponseSubscription") + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_revision_subscription_subscription_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_retry_claim_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.cs b/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.cs new file mode 100644 index 00000000..fe5b41ca --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261101132211_DashboardIndexes.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + /// The failed results and the retries, each in an index of its own, so the dashboard counts the + /// failures still to act on and finds the chains that keep failing without walking every + /// exchange. Both are a few percent of their table. Built without stopping writes to it. + /// + public partial class DashboardIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Outside the transaction, on what can be the largest tables there are: see ConcurrentIndex. + ConcurrentIndex.Create(migrationBuilder, "xchange_result", "ix_xchange_result_failed", + "(id) WHERE NOT success"); + + ConcurrentIndex.Create(migrationBuilder, "xchange", "ix_xchange_retries_started_on", + "(started_on) INCLUDE (id, retry_for) WHERE retry_for IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ConcurrentIndex.Drop(migrationBuilder, "ix_xchange_result_failed"); + + ConcurrentIndex.Drop(migrationBuilder, "ix_xchange_retries_started_on"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs new file mode 100644 index 00000000..ca6f132d --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs @@ -0,0 +1,4209 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SW.Bitween.Model; +using SW.Bitween.PgSql; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20261102124651_BadResponseIndex")] + partial class BadResponseIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasColumnName("failed_login_count"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("LastSignInOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in_on"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("MicrosoftIdentity") + .HasColumnType("text") + .HasColumnName("microsoft_identity"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("OidcIdentity") + .HasColumnType("text") + .HasColumnName("oidc_identity"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("email_error"); + + b.Property("EmailedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("emailed_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("Purpose") + .HasColumnType("smallint") + .HasColumnName("purpose"); + + b.HasKey("Id") + .HasName("pk_account_links"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_links_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_account_links_hash"); + + b.ToTable("AccountLinks", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.Property("SupersededOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_on"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("last_used_from"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("revoked_by"); + + b.Property("RevokedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_on"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("shown"); + + b.HasKey("Id") + .HasName("pk_service_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_service_tokens_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_service_tokens_hash"); + + b.ToTable("ServiceTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("base_version"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("files_hash"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("files_json"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("language"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_adapter_draft"); + + b.HasIndex("AdapterId") + .HasDatabaseName("ix_adapter_draft_adapter_id"); + + b.ToTable("adapter_draft", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("action"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("DraftId") + .HasColumnType("integer") + .HasColumnName("draft_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_release"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_release_adapter_id_occurred_on"); + + b.ToTable("adapter_release", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("path"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_source_access"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_source_access_adapter_id_occurred_on"); + + b.ToTable("adapter_source_access", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key_name"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.HasKey("PartnerId", "KeyName") + .HasName("pk_api_key_use"); + + b.ToTable("api_key_use", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Changes") + .HasColumnType("text") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("character varying(36)") + .HasColumnName("correlation_id"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_key"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_name"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Sequence") + .HasColumnType("integer") + .HasColumnName("sequence"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("character varying(10)") + .HasColumnName("state"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_audit_entries"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_entries_correlation_id"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_audit_entries_occurred_on"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn") + .HasDatabaseName("ix_audit_entries_entity_name_entity_key_occurred_on"); + + b.ToTable("AuditEntries", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Done") + .HasColumnType("integer") + .HasColumnName("done"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("Node") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("node"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("owner"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("path"); + + b.Property("Result") + .HasColumnType("bytea") + .HasColumnName("result"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("result_content_type"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_disposition"); + + b.Property("ResultKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_key"); + + b.Property("ResultStatus") + .HasColumnType("integer") + .HasColumnName("result_status"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("summary"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Id") + .HasName("pk_background_request"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_background_request_started_on"); + + b.HasIndex("Owner", "StartedOn") + .HasDatabaseName("ix_background_request_owner_started_on"); + + b.ToTable("background_request", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("AcquiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_on"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("owner_node"); + + b.Property("Term") + .HasColumnType("bigint") + .HasColumnName("term"); + + b.HasKey("Id") + .HasName("pk_cluster_lease"); + + b.ToTable("cluster_lease", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("DataSources") + .HasColumnType("boolean") + .HasColumnName("data_sources"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("host"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("integer") + .HasColumnName("outbox_published_last_hour"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("resident_health"); + + b.Property("Runtimes") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtimes"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_cluster_node"); + + b.HasIndex("LastSeenOn") + .HasDatabaseName("ix_cluster_node_last_seen_on"); + + b.ToTable("cluster_node", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("access_token_expires_on"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("authorize_parameters"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("authorize_url"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("authorized_by"); + + b.Property("AuthorizedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("authorized_on"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Grant") + .HasColumnType("integer") + .HasColumnName("grant"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property("RenewingUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("renewing_until"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("scopes"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("token_auth"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("token_url"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_connections"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_connections_name"); + + b.ToTable("Connections", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("instance_key"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("value"); + + b.HasKey("AdapterId", "InstanceKey", "Name") + .HasName("pk_adapter_state"); + + b.ToTable("adapter_state", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .HasColumnType("text") + .HasColumnName("adapter_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CpuLimitSamples") + .HasColumnType("integer") + .HasColumnName("cpu_limit_samples"); + + b.Property("CpuPercentLimit") + .HasColumnType("double precision") + .HasColumnName("cpu_percent_limit"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("integer") + .HasColumnName("deduplication_window_days"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("hard_memory_limit_mb"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("LastHeartbeatOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_heartbeat_on"); + + b.Property("LastKnownState") + .HasColumnType("text") + .HasColumnName("last_known_state"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnedByNode") + .HasColumnType("text") + .HasColumnName("owned_by_node"); + + b.Property("Placement") + .HasColumnType("integer") + .HasColumnName("placement"); + + b.Property>("Properties") + .HasColumnType("hstore") + .HasColumnName("properties"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("text[]") + .HasColumnName("secret_properties"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("soft_memory_limit_mb"); + + b.HasKey("Id") + .HasName("pk_data_source"); + + b.ToTable("data_source", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("CursorColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("cursor_column"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key_column"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sql"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_data_source_statement"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_data_source_statement_work_group_id"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique() + .HasDatabaseName("ix_data_source_statement_data_source_id_name"); + + b.ToTable("data_source_statement", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("id"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("SeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("seen_on"); + + b.Property("XchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_inbound_message"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_inbound_message_data_source_id"); + + b.HasIndex("SeenOn") + .HasDatabaseName("ix_inbound_message_seen_on"); + + b.ToTable("inbound_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.Property("RunFailures") + .HasColumnType("integer") + .HasColumnName("run_failures"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DropDuplicates") + .HasColumnType("boolean") + .HasColumnName("drop_duplicates"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("json") + .HasColumnName("promoted_properties"); + + b.Property("RetiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_on"); + + b.Property("ValidationSchema") + .HasColumnType("text") + .HasColumnName("validation_schema"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Dropped") + .HasColumnType("boolean") + .HasColumnName("dropped"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("original_xchange_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.HasKey("Id") + .HasName("pk_duplicate_arrival"); + + b.HasIndex("DocumentId", "OccurredOn") + .HasDatabaseName("ix_duplicate_arrival_document_id_occurred_on"); + + b.ToTable("duplicate_arrival", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("integer") + .HasColumnName("auth_method"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FailureAnswer") + .HasColumnType("integer") + .HasColumnName("failure_answer"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_audience"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_issuer"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("jwt_partner_claim"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("partner_key_header"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Endpoint") + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property>("EndpointProperties") + .HasColumnType("hstore") + .HasColumnName("endpoint_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_bus_gateway_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.Property("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_idempotency_key"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_idempotency_key_created_on"); + + b.HasIndex("Scope", "Key") + .IsUnique() + .HasDatabaseName("ix_idempotency_key_scope_key"); + + b.ToTable("idempotency_key", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("Role") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Runtime") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("runtime"); + + b.Property("Calls") + .HasColumnType("integer") + .HasColumnName("calls"); + + b.Property("Crashes") + .HasColumnType("integer") + .HasColumnName("crashes"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failures") + .HasColumnType("integer") + .HasColumnName("failures"); + + b.Property("LimitStops") + .HasColumnType("integer") + .HasColumnName("limit_stops"); + + b.Property("Timeouts") + .HasColumnType("integer") + .HasColumnName("timeouts"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime") + .HasName("pk_adapter_stats"); + + b.ToTable("adapter_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("Scope") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("scope"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope_key"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("fingerprint"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("sample"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint") + .HasName("pk_error_stats"); + + b.ToTable("error_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("BadResponse") + .HasColumnType("integer") + .HasColumnName("bad_response"); + + b.Property("BytesIn") + .HasColumnType("bigint") + .HasColumnName("bytes_in"); + + b.Property("BytesOut") + .HasColumnType("bigint") + .HasColumnName("bytes_out"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DbCommands") + .HasColumnType("bigint") + .HasColumnName("db_commands"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failed") + .HasColumnType("integer") + .HasColumnName("failed"); + + b.Property("FailedAtHandler") + .HasColumnType("integer") + .HasColumnName("failed_at_handler"); + + b.Property("FailedAtMapper") + .HasColumnType("integer") + .HasColumnName("failed_at_mapper"); + + b.Property("ManualRetries") + .HasColumnType("integer") + .HasColumnName("manual_retries"); + + b.Property("PayloadParses") + .HasColumnType("bigint") + .HasColumnName("payload_parses"); + + b.Property("Retries") + .HasColumnType("integer") + .HasColumnName("retries"); + + b.Property("Succeeded") + .HasColumnType("integer") + .HasColumnName("succeeded"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId") + .HasName("pk_exchange_stats"); + + b.ToTable("exchange_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("timestamp with time zone") + .HasColumnName("at"); + + b.Property("AckRate") + .HasColumnType("double precision") + .HasColumnName("ack_rate"); + + b.Property("Consumers") + .HasColumnType("integer") + .HasColumnName("consumers"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint") + .HasColumnName("dead_letter_backlog"); + + b.Property("IncomingRate") + .HasColumnType("double precision") + .HasColumnName("incoming_rate"); + + b.Property("QueueDepth") + .HasColumnType("bigint") + .HasColumnName("queue_depth"); + + b.Property("RetryBacklog") + .HasColumnType("bigint") + .HasColumnName("retry_backlog"); + + b.Property("UnhealthyConsumers") + .HasColumnType("integer") + .HasColumnName("unhealthy_consumers"); + + b.HasKey("At") + .HasName("pk_queue_snapshot"); + + b.ToTable("queue_snapshot", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_notification_channel"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_channel_name"); + + b.ToTable("notification_channel", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.PrimitiveCollection("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("detail"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("target"); + + b.HasKey("Id") + .HasName("pk_operator_action"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_operator_action_occurred_on"); + + b.ToTable("operator_action", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ClaimedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_until"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_on"); + + b.HasKey("Id") + .HasName("pk_outbox_message"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_outbox_message_created_on") + .HasFilter("published_on IS NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("outbox_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("login_identity"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.HasIndex("LoginIdentity") + .IsUnique() + .HasDatabaseName("ix_partner_login_identity"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.PrimitiveCollection("ExchangeIds") + .HasColumnType("text[]") + .HasColumnName("exchange_ids"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_receive_attempt"); + + b.HasIndex("SubscriptionId", "StartedOn") + .HasDatabaseName("ix_receive_attempt_subscription_id_started_on"); + + b.ToTable("receive_attempt", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("AlertMode") + .HasColumnType("smallint") + .HasColumnName("alert_mode"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_alert_override"); + + b.ToTable("retry_alert_override", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AttemptsUsed") + .HasColumnType("integer") + .HasColumnName("attempts_used"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("exhausted_notified_on"); + + b.Property("LastAttemptOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_on"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_group_usage"); + + b.ToTable("retry_group_usage", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_settings"); + + b.ToTable("Settings", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("AutoPauseAfterFailures") + .HasColumnType("integer") + .HasColumnName("auto_pause_after_failures"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ExpectTraffic") + .HasColumnType("text") + .HasColumnName("expect_traffic"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Notifications") + .HasColumnType("text") + .HasColumnName("notifications"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedAutomatically") + .HasColumnType("boolean") + .HasColumnName("paused_automatically"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("QuietSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("quiet_since"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ReceiverVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("receiver_version"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("RunOnBadResponses") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_responses"); + + b.Property("RunningSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("running_since"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("ValidatorVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("validator_version"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_subscription_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("saved_by"); + + b.Property("SavedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("saved_on"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("text") + .HasColumnName("snapshot"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_revision"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique() + .HasDatabaseName("ix_subscription_revision_subscription_id_number"); + + b.ToTable("subscription_revision", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("files_prefix"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on") + .HasDatabaseName("ix_xchange_retries_started_on") + .HasFilter("retry_for IS NOT NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on"), new[] { "Id", "RetryFor" }); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer") + .HasColumnName("channel_id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Suppressed") + .HasColumnType("boolean") + .HasColumnName("suppressed"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.HasIndex("XchangeId") + .HasDatabaseName("ix_xchange_notification_xchange_id"); + + b.HasIndex("ChannelId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_channel_id_finished_on"); + + b.HasIndex("NotifierId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_notifier_id_finished_on"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.PrimitiveCollection("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("NodeName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node_name"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("retry_blocked_reason"); + + b.Property("RetryGroupId") + .HasColumnType("uuid") + .HasColumnName("retry_group_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("warning"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.HasIndex("RetryGroupId") + .HasDatabaseName("ix_xchange_result_retry_group_id"); + + b.HasIndex("SubscriptionId", "FinishedOn") + .HasDatabaseName("ix_xchange_result_subscription_id_finished_on") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex(new[] { "Id" }, "ix_xchange_result_failed") + .HasDatabaseName("ix_xchange_result_failed") + .HasFilter("NOT success") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("ClaimedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_on"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_retry_claim"); + + b.ToTable("xchange_retry_claim", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_links_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_service_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_data_source_statement_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_data_source_statement_work_group_work_group_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inbound_message_data_source_data_source_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .HasConstraintName("fk_bus_gateway_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("key_prefix"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "ResponseSubscription") + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_revision_subscription_subscription_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_retry_claim_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs new file mode 100644 index 00000000..6ac3e4f3 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + /// The results with a bad response, in an index of their own, so the Bad response tab counts them + /// and the dashboard finds the latest without reading every result. They are a small part of the + /// table, often none of it. Built without stopping writes to it. + /// + public partial class BadResponseIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Outside the transaction, on what can be the largest tables there are: see ConcurrentIndex. + ConcurrentIndex.Create(migrationBuilder, "xchange_result", "ix_xchange_result_bad_response", + "(id) WHERE response_bad"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ConcurrentIndex.Drop(migrationBuilder, "ix_xchange_result_bad_response"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index 492ba258..d074251a 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") @@ -2835,6 +2929,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SubscriptionId") .HasDatabaseName("ix_xchange_subscription_id"); + b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on") + .HasDatabaseName("ix_xchange_retries_started_on") + .HasFilter("retry_for IS NOT NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on"), new[] { "Id", "RetryFor" }); + b.ToTable("xchange", "infolink"); }); @@ -3071,6 +3172,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDatabaseName("ix_xchange_result_subscription_id_finished_on") .HasAnnotation("Npgsql:CreatedConcurrently", true); + b.HasIndex(new[] { "Id" }, "ix_xchange_result_failed") + .HasDatabaseName("ix_xchange_result_failed") + .HasFilter("NOT success") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + b.ToTable("xchange_result", "infolink"); }); diff --git a/SW.Bitween.Sdk/Model/Retention.cs b/SW.Bitween.Sdk/Model/Retention.cs index 67ccbf41..51c4f0cd 100644 --- a/SW.Bitween.Sdk/Model/Retention.cs +++ b/SW.Bitween.Sdk/Model/Retention.cs @@ -85,15 +85,10 @@ public class ExchangeRetentionInfo /// Exchanges the next run would remove; null while retention is off. public int? DueNow { get; set; } - /// - /// Exchanges still kept but older than their files: listed without them. Null when the bucket keeps - /// files for ever or its rules are unknown. - /// - public int? WithoutFiles { get; set; } - /// Counts stop at this many; a count equal to it means "at least". public int CountCap { get; set; } + /// When the oldest exchange still kept started; null when there are none. public DateTime? Oldest { get; set; } } diff --git a/SW.Bitween.Sdk/Model/Xchange.cs b/SW.Bitween.Sdk/Model/Xchange.cs index 902ba788..e4c1c4ec 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 @@ -337,30 +356,40 @@ public class XchangeSummary /// When the matching exchanges started, by status, in fixed buckets of /// each - contiguous and zero-filled from the start of the /// filter's date range to its end. Empty when the filter has no start date: with no range - /// there is nothing to divide into buckets but the whole history. + /// there is nothing to divide into buckets but the whole history. Also empty while they are + /// still being worked out: see . /// public List Buckets { get; set; } = new List(); /// How long each bucket is, in minutes; 0 when there are no buckets. public int BucketMinutes { get; set; } + + /// + /// True while part of this is still being worked out on the server, which takes over from a + /// request for anything slower than a few seconds: a count that is null, or the buckets of a + /// date range, are on their way. Asking again in a little while brings them. + /// + public bool Counting { get; set; } } /// /// Exchanges by status, defined exactly as the list's status filter defines them. A count of - /// 10001 means "more than 10,000": counting stops there, as the search's total does. + /// 10001 means "more than 10,000": counting stops there, as the search's total does. Null + /// means still being counted on the server, which a status rare across a large table can take + /// minutes over: see . /// public class XchangeStatusCounts { /// Still running: no result yet. - public int Processing { get; set; } + public int? Processing { get; set; } /// Finished, and the response (if any) was not marked bad. - public int Success { get; set; } + public int? Success { get; set; } /// Finished, but the response was marked bad. - public int BadResponse { get; set; } + public int? BadResponse { get; set; } - public int Failed { get; set; } + public int? Failed { get; set; } } /// One bucket of : exchanges that started in it, by status. diff --git a/SW.Bitween.UnitTests/Ai/ExplainChainTests.cs b/SW.Bitween.UnitTests/Ai/ExplainChainTests.cs new file mode 100644 index 00000000..0b66a6eb --- /dev/null +++ b/SW.Bitween.UnitTests/Ai/ExplainChainTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Resources.XchangeExplanations; +using SW.Bitween.Services.Ai; + +namespace SW.Bitween.UnitTests.Ai; + +/// +/// The retry chain as an explanation sends it: numbered from the chain's real first attempt, each +/// error masked, and a line masking couldn't be sure of withheld rather than sent. +/// +[TestClass] +public class ExplainChainTests +{ + static readonly DateTime At = new(2026, 11, 3, 9, 0, 0); + + static Explain.Attempt Failed(string id, int number, string error) => new(id, number, At, false, false, error); + + static HashSet All(Explain.RetryChain chain) => chain.Attempts.Select(a => a.Id).ToHashSet(); + + [TestMethod] + public void A_long_chain_is_numbered_from_its_real_first_attempt_and_says_what_it_left_out() + { + var chain = new Explain.RetryChain( + [Failed("a", 41, "Timeout"), Failed("b", 42, "Timeout"), new("c", 43, At, true, false, null)], + Position: 42, Total: 43, More: false, EarlierNotListed: 40); + + var lines = Explain.ChainLines(chain, "b", new Masking(), All(chain)).ToList(); + + CollectionAssert.AreEqual(new[] + { + "(40 earlier attempts not listed)", + "Attempt 41, from 2026-11-03 09:00 UTC: failed: Timeout", + "Attempt 42 (this one), from 2026-11-03 09:00 UTC: failed: Timeout", + "Attempt 43, from 2026-11-03 09:00 UTC: succeeded", + }, lines); + } + + [TestMethod] + public void An_attempts_error_loses_the_values_its_documents_hold() + { + var masking = new Masking(); + masking.Document("""{ "customer": "Lina Haddad" }""", "application/json"); + var chain = new Explain.RetryChain([Failed("a", 1, "Customer Lina Haddad was rejected"), Failed("b", 2, "Timeout")], 2, 2, false, 0); + + var line = Explain.ChainLines(chain, "b", masking, All(chain)).First(); + + Assert.IsFalse(line.Contains("Lina") || line.Contains("Haddad"), line); + StringAssert.Contains(line, "was rejected"); + } + + [TestMethod] + public void An_attempt_whose_documents_couldnt_be_read_has_its_line_withheld() + { + var chain = new Explain.RetryChain([Failed("a", 1, "Customer Lina Haddad was rejected"), Failed("b", 2, "Timeout")], 2, 2, false, 0); + + var line = Explain.ChainLines(chain, "b", new Masking(), new HashSet { "b" }).First(); + + Assert.AreEqual("Attempt 1, from 2026-11-03 09:00 UTC: failed, with a different error, not sent: its documents couldn't be read to mask it", line); + } + + [TestMethod] + public void A_first_line_read_only_in_part_keeps_only_its_exception_type() + { + var cut = ("System.Exception: Refused for customer Lina Haddad " + new string('y', Explain.ErrorRead))[..Explain.ErrorRead]; + Assert.AreEqual("System.Exception: (the rest of this line is too long to send)", Explain.WholeFirstLine(cut)); + Assert.AreEqual("(the rest of this line is too long to send)", Explain.WholeFirstLine(new string('y', Explain.ErrorRead))); + Assert.AreEqual("Timeout", Explain.WholeFirstLine("Timeout\n at Foo()")); + } +} diff --git a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts index f192974e..05624c59 100644 --- a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts @@ -10,6 +10,14 @@ test("dashboard loads with real aggregated data", async ({ page }) => { await expect(page.getByText("Success rate, 7 days")).toBeVisible(); await expect(page.getByText("Traffic, last 48 hours")).toBeVisible(); await expect(page.getByText("Housekeeping")).toBeVisible(); + // The page opens before its numbers arrive; check them once they have. + await expect(page.getByText("loading…")).toHaveCount(0, { timeout: 15000 }); + await expect(page.getByText("Finding the latest failures…")).toHaveCount(0, { timeout: 15000 }); + await expect(page.getByText("Reading the retry chains…")).toHaveCount(0, { timeout: 15000 }); + // A part that couldn't be read stops loading too, and says so. + await expect(page.getByRole("link").filter({ hasText: /^Exchanges, last 24 hours/ })).not.toContainText( + "unavailable right now", + ); await expect(page.getByText("undefined")).toHaveCount(0); await expect(page.getByText("NaN")).toHaveCount(0); @@ -19,6 +27,8 @@ test("dashboard loads with real aggregated data", async ({ page }) => { // which an unanchored match picks up as well. const tile = page.getByRole("link").filter({ hasText: /^Failures to act on/ }); await expect(tile).toBeVisible(); + // Counted apart from the rest of the page, so it can fill in after it. + await expect(tile).toContainText(/\d/, { timeout: 15000 }); const count = (await tile.innerText()).match(/([\d,]+)/)?.[1]; expect(count).toBeTruthy(); diff --git a/SW.Bitween.Web/ClientApp/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..13a0444c 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -38,7 +38,8 @@ import type { DataSourceStatementUsage, DataSourceTelemetry, DataSourceTestResult, - DashboardData, + LatestFailure, + RetrySummary, ExchangeBreakdown, ExchangeQuery, ExchangeRow, @@ -683,7 +684,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; @@ -748,7 +755,7 @@ export interface ApiClient { ): Promise>; // - exchanges - - searchExchanges(query: ExchangeQuery): Promise>; + searchExchanges(query: ExchangeQuery, opts?: { count?: boolean }): Promise>; getExchangeBreakdown(query: ExchangeQuery): Promise; // - audit trail - @@ -775,8 +782,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; @@ -813,7 +825,23 @@ export interface ApiClient { requeueDeadLetters(queueName: string): Promise; // - dashboard - - getDashboard(): Promise; + // One call per part, so a slow one fills in on its own tile rather than holding up the page. + /** The newest failed or badly answered exchanges of the last two weeks, at most six, newest first. */ + getLatestFailures(): Promise; + /** Automatic retries waiting for their slot. */ + getPendingRetries(): Promise; + /** Queue alerts worth acting on. Null when the RabbitMQ management API is unavailable - never counts as zero. */ + getQueueAlerts(): Promise; + // The counts over the exchange tables, which grow with traffic. + /** + * Failures that are the newest attempt of their chain. Counts problems rather than attempts: a + * chain retried nine times is one of these, not nine. Capped like the exchange search, so a value + * above 10,000 means "more than that". + */ + getFailuresToActOn(): Promise; + /** Exchanges started in the last 24 hours and not finished yet. */ + getInFlight(): Promise; + getRetrySummary(): Promise; // Metrics, read from the hourly rows the server keeps. getMetricsOverview(): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx index c8c71805..8ff2b962 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/errors.test.tsx @@ -24,9 +24,11 @@ describe("errors from the API", () => { expect(refusal.errors).toEqual({ Name: ["A name is needed."], Port: ["The port must be a number."] }); }); - it("doesn't ask again for a refusal, but does for a server error or a dropped connection", () => { + it("doesn't ask again for a refusal or a timeout, but does for a server error or a dropped connection", () => { expect(shouldRetryQuery(0, new ApiRequestError("NOT_FOUND", "gone", 404))).toBe(false); expect(shouldRetryQuery(0, new ApiRequestError("HTTP_403", "no", 403))).toBe(false); + expect(shouldRetryQuery(0, new ApiRequestError("DATABASE_TIMED_OUT", "too long", 504))).toBe(false); + expect(shouldRetryQuery(0, new ApiRequestError("ERROR", "gateway timeout", 504))).toBe(false); expect(shouldRetryQuery(0, new ApiRequestError("ERROR", "boom", 500))).toBe(true); expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true); expect(shouldRetryQuery(3, new ApiRequestError("ERROR", "boom", 500))).toBe(false); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/background.ts b/SW.Bitween.Web/ClientApp/src/api/http/background.ts new file mode 100644 index 00000000..36d71809 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/background.ts @@ -0,0 +1,331 @@ +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; + /** The HTTP status it was answered with, once done: an error answer is still "Done". */ + resultStatus?: number | 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/dashboard.ts b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts index 9f7be4a0..2698bab4 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts @@ -1,6 +1,5 @@ import type { ApiClient } from "../client"; -import type { DashboardData, ExchangeStatus } from "../types"; -import { subscriptionMethods } from "./subscriptions"; +import type { ExchangeStatus, LatestFailure, RetrySummary } from "../types"; import { get } from "./request"; // --- backend shapes (camelCase over the wire) --- @@ -38,70 +37,73 @@ const toStatus = (raw: Pick): const LATEST = 6; +const startedSince = (ms: number) => encodeURIComponent(`StartedOn:6:${new Date(Date.now() - ms).toISOString()}`); + +/** + * What the dashboard shows beside the metrics (which come from /metrics/overview) and the + * subscription rows, one method per part so each can fill in on its own. + * + * Traffic, rates and the busiest used to be counted here, in the browser, from up to 1000 + * exchanges fetched every minute: wrong at any volume above that, and a heavy query for every + * open dashboard. They come from the server's hourly metrics now. + */ export const dashboardMethods = { - /** - * What the dashboard shows beside the metrics (which come from /metrics/overview): the latest - * failures, what is in flight, retries, queue alerts and subscriptions needing attention. - * - * Traffic, rates and the busiest used to be counted here, in the browser, from up to 1000 - * exchanges fetched every minute: wrong at any volume above that, and a heavy query for every - * open dashboard. They come from the server's hourly metrics now; what is left here is small, - * bounded queries over the last two weeks. - */ - async getDashboard(): Promise { - const now = Date.now(); - const since = (ms: number) => encodeURIComponent(`StartedOn:6:${new Date(now - ms).toISOString()}`); - const twoWeeks = since(14 * 86_400_000); + /** Two searches over the exchanges of the last two weeks: on a busy instance, seconds. */ + async getLatestFailures(): Promise { + const twoWeeks = startedSince(14 * 86_400_000); const latest = (status: number) => get>( `/xchanges?filter=${twoWeeks}&filter=${encodeURIComponent(`StatusFilter:1:${status}`)}&sort=StartedOn:2&size=${LATEST}`, ); - const [failed, badResponses, inFlight, delayedRes, failuresRes, retrySummary, alertsRaw, subscriptionRows] = - await Promise.all([ - latest(3), - latest(2), - get>(`/xchanges?filter=${since(86_400_000)}&filter=${encodeURIComponent("StatusFilter:1:0")}&size=1`), - get>("/delayedretries?size=1"), - // Only the total is wanted: the problems still to deal with, which the tile's list opens. - get>( - `/xchanges?filter=${encodeURIComponent("StatusFilter:1:3")}&filter=${encodeURIComponent("LatestOnly:1:true")}&size=1`, - ), - get("/dashboard/retrysummary"), - // Depends on RabbitMQ management being configured on the backend - don't let it take the - // rest of the dashboard down when it isn't; the "Queue alerts" tile flags it instead. - get("/ops/alerts").catch(() => null), - subscriptionMethods.listSubscriptionRows(), - ]); - - const subscriptionNameById = new Map(subscriptionRows.map((i) => [i.id, i.name])); - const latestFailures = [...failed.result, ...badResponses.result] + const [failed, badResponses] = await Promise.all([latest(3), latest(2)]); + return [...failed.result, ...badResponses.result] .sort((a, b) => b.startedOn.localeCompare(a.startedOn)) .slice(0, LATEST) .map((r) => ({ id: r.id, status: toStatus(r), subscriptionId: r.subscriptionId, - subscriptionName: r.subscriptionId !== null ? (subscriptionNameById.get(r.subscriptionId) ?? null) : null, informationTypeCode: r.documentName, on: r.startedOn, exception: r.exception, })); + }, + + async getPendingRetries(): Promise { + return (await get>("/delayedretries?size=1")).totalCount; + }, + + async getQueueAlerts(): Promise { + // Depends on RabbitMQ management being configured on the backend; the "Queue alerts" tile + // flags it when it isn't. + const alerts = await get("/ops/alerts").catch(() => null); + return alerts === null ? null : alerts.filter((a) => a.severity !== "Info").length; + }, + + /** + * The problems still to deal with, which the tile's list opens. Counted by the server from the + * failures: the list fetches rows newest first, which for this filter can mean reading back + * through millions of exchanges first. + */ + async getFailuresToActOn(): Promise { + return (await get<{ total: number }>("/dashboard/failurestoacton")).total; + }, + + async getInFlight(): Promise { + const lastDay = startedSince(86_400_000); + return ( + await get>( + `/xchanges?filter=${lastDay}&filter=${encodeURIComponent("StatusFilter:1:0")}&size=1`, + ) + ).totalCount; + }, + async getRetrySummary(): Promise { + const raw = await get("/dashboard/retrysummary"); return { - inFlight: inFlight.totalCount, - failuresToActOn: failuresRes.totalCount, - failingChains: retrySummary.failingChains ?? [], - retriesLast7Days: retrySummary.retriesLast7Days ?? { finished: 0, succeeded: 0 }, - pendingRetries: delayedRes.totalCount, - queueAlerts: alertsRaw === null ? null : alertsRaw.filter((a) => a.severity !== "Info").length, - latestFailures, - attention: { - failingSubscriptions: subscriptionRows - .filter((i) => i.consecutiveFailures > 0) - .map((i) => ({ id: i.id, name: i.name, consecutiveFailures: i.consecutiveFailures })), - pausedSubscriptions: subscriptionRows.filter((i) => i.paused).map((i) => ({ id: i.id, name: i.name })), - }, + failingChains: raw.failingChains ?? [], + retriesLast7Days: raw.retriesLast7Days ?? { finished: 0, succeeded: 0 }, }; }, } satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index e551d995..30199254 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 ?? {}, @@ -298,9 +312,13 @@ export const exchangeMethods = { return res.data; }, - async searchExchanges(query: ExchangeQuery): Promise> { + /** + * A page of exchanges and how many match. With `count: false` the total is left out, as 0, so the + * rows come back without waiting for it: the Exchanges page has its total from the status counts. + */ + async searchExchanges(query: ExchangeQuery, { count = true }: { count?: boolean } = {}): Promise> { const [res, partnerNameById] = await Promise.all([ - get>(`/xchanges?${buildExchangeQuery(query)}`), + get>(`/xchanges?${buildExchangeQuery(query)}${count ? "" : "&count=false"}`), partnerNameMap(), ]); return { result: res.result.map((r) => toExchangeRow(r, partnerNameById)), total: res.totalCount }; @@ -316,13 +334,14 @@ export const exchangeMethods = { const res = await get(`/xchanges/summary${params.toString() ? `?${searchyQueryString(params)}` : ""}`); return { counts: { - processing: res.counts?.processing ?? 0, - success: res.counts?.success ?? 0, - badResponse: res.counts?.badResponse ?? 0, - failed: res.counts?.failed ?? 0, + processing: res.counts?.processing ?? null, + success: res.counts?.success ?? null, + badResponse: res.counts?.badResponse ?? null, + failed: res.counts?.failed ?? null, }, buckets: res.buckets ?? [], bucketMinutes: res.bucketMinutes ?? 0, + counting: res.counting ?? false, }; }, @@ -369,18 +388,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 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); }, - /** 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" }; + /** 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/http/retryPolicies.ts b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts index 61d905b4..36b3f127 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts @@ -6,6 +6,7 @@ import { type RetryAlertLevel, type RetryAttempts, type RetryDelay, + type RetryExhaustedAction, type RetryGroup, type RetryPolicy, type RetryPolicyDetail, @@ -81,6 +82,8 @@ interface RawRetryBudget { maxAttemptsPerError: number; maxAttemptsTotal: number | null; delayStrategy: RawDelayStrategy; + /** Missing on policies saved before it existed: those fail, as they always did. */ + whenExhausted?: RetryExhaustedAction; } interface RawRetryGroup extends Omit { budget?: RawRetryBudget | null; @@ -139,6 +142,7 @@ const toGroup = ({ alertHandlerId: _retired, alertHandlerProperties: _retiredPro maxAttemptsPerError: g.budget.maxAttemptsPerError, maxAttemptsTotal: g.budget.maxAttemptsTotal, delay: toDelay(g.budget.delayStrategy), + whenExhausted: g.budget.whenExhausted ?? "Fail", } : undefined, // Named rather than left to the spread: a policy saved from an older client has no @@ -155,6 +159,8 @@ const toRawGroup = (g: RetryGroup): RawRetryGroup => ({ maxAttemptsPerError: g.budget.maxAttemptsPerError, maxAttemptsTotal: g.budget.maxAttemptsTotal, delayStrategy: toRawDelay(g.budget.delay), + // Sent back as read: leaving it out turned a held group into one that fails on every save. + whenExhausted: g.budget.whenExhausted, } : undefined, }); diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts index d2c37c45..13bf4b30 100644 --- a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -201,6 +201,13 @@ export const keys = { categories: ["subscription-categories"] as const, background: ["queue-health", "background"] as const, dashboard: ["dashboard"] as const, + // Under the dashboard's own key, so whatever refreshes the dashboard refreshes these too. + dashboardLatestFailures: ["dashboard", "latest-failures"] as const, + dashboardPendingRetries: ["dashboard", "pending-retries"] as const, + dashboardQueueAlerts: ["dashboard", "queue-alerts"] as const, + dashboardFailuresToActOn: ["dashboard", "failures-to-act-on"] as const, + dashboardInFlight: ["dashboard", "in-flight"] as const, + dashboardRetrySummary: ["dashboard", "retry-summary"] as const, metrics: { all: ["metrics"] as const, overview: ["metrics", "overview"] as const, @@ -221,6 +228,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..fe620ed8 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -155,11 +155,13 @@ export const isPermanentError = (error: unknown): boolean => /** * Whether a failed query is asked again. A refusal answers the same however often it is asked: - * retrying it only held "Loading…" on screen for the ~7s of backoff before the error showed. - * Server errors and dropped connections are retried, up to three times. + * retrying it only held "Loading…" on screen for the ~7s of backoff before the error showed. A + * timeout (504) takes as long again each time: three more of the database's 30 seconds held a list + * on "Loading…" for two minutes. Other server errors and dropped connections are retried, up to + * three times. */ export const shouldRetryQuery = (failureCount: number, error: unknown): boolean => - !isPermanentError(error) && failureCount < 3; + !isPermanentError(error) && !(error instanceof ApiRequestError && error.status === 504) && failureCount < 3; // --- Configuration entities (sub-phase 2) --- @@ -417,6 +419,8 @@ export interface RetryAlertConfig { alertChannelId: number | null; } +export type RetryExhaustedAction = "Fail" | "Hold"; + export interface RetryGroup { id: string; name: string; @@ -427,8 +431,12 @@ export interface RetryGroup { /** OR logic; empty = any failure of the applicable kind. */ matchers: RetryMatcher[]; action: "Allow" | "Block"; - /** `maxAttemptsTotal: null` is no group-wide limit - only the per-message cap applies. */ - budget?: { maxAttemptsPerError: number; maxAttemptsTotal: number | null; delay: RetryDelay }; + /** + * `maxAttemptsTotal: null` is no group-wide limit - only the per-message cap applies. + * `whenExhausted` is what happens to a failure once that total is spent: fail it for good, or + * hold it and retry it when the subscription next succeeds. + */ + budget?: { maxAttemptsPerError: number; maxAttemptsTotal: number | null; delay: RetryDelay; whenExhausted: RetryExhaustedAction }; notes?: string; /** Where this group's budget-exhausted alert goes, for every subscription using the policy. */ alertMode: RetryAlertMode; @@ -1408,9 +1416,9 @@ export interface RetentionStatus { cron: string; nextRun: string | null; dueNow: number | null; - withoutFiles: number | null; /** Counts stop here; a count equal to it means "at least". */ countCap: number; + /** When the oldest exchange still kept started. */ oldest: string | null; }; notices: RetentionNotice[]; @@ -1546,14 +1554,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[]; /** @@ -1638,11 +1651,16 @@ export interface ExchangeVolumeBucket { * and, when the filter has a start time, how they spread over that range. */ export interface ExchangeBreakdown { - /** Each capped like the list total: a value past the cap means "more than". */ - counts: Record; + /** + * Each capped like the list total: a value past the cap means "more than". Null while it is still + * being counted on the server, which a status rare across a large table can take minutes over. + */ + counts: Record; buckets: ExchangeVolumeBucket[]; /** The width of one bucket; 0 when there are none. */ bucketMinutes: number; + /** Part of it is still being worked out on the server: a later ask brings what is missing. */ + counting: boolean; } export interface Paged { @@ -1832,8 +1850,12 @@ export interface FailingChain { exception: string | null; } -export interface DashboardData { - /** Worst first. Empty when nothing has been retried more than once and left failing. */ +/** The dashboard's retry panel. */ +export interface RetrySummary { + /** + * Worst first, among chains whose newest attempt started in the last 7 days. Empty when nothing + * has been retried more than once and left failing. + */ failingChains: FailingChain[]; /** * Retries started in the last 7 days: how many have finished, and how many of those worked. @@ -1841,30 +1863,16 @@ export interface DashboardData { * luck or a configuration problem. */ retriesLast7Days: { finished: number; succeeded: number }; - /** - * Failures that are the newest attempt of their chain. Counts problems rather than attempts: a - * chain retried nine times is one of these, not nine. Subject to the search's count cap, so a - * value above it means "at least this many". - */ - failuresToActOn: number; - /** Exchanges started in the last 24 hours and not finished yet. */ - inFlight: number; - pendingRetries: number; - /** Null when the RabbitMQ management API is unavailable - never counts as zero. */ - queueAlerts: number | null; - latestFailures: { - id: string; - status: ExchangeStatus; - subscriptionId: number | null; - subscriptionName: string | null; - informationTypeCode: string; - on: string; - exception: string | null; - }[]; - attention: { - failingSubscriptions: { id: number; name: string; consecutiveFailures: number }[]; - pausedSubscriptions: { id: number; name: string }[]; - }; +} + +/** One of the dashboard's latest failures. */ +export interface LatestFailure { + id: string; + status: ExchangeStatus; + subscriptionId: number | null; + informationTypeCode: string; + on: string; + exception: string | null; } /** diff --git a/SW.Bitween.Web/ClientApp/src/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 (ready(r)) 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 isReady = ready(notice); + return ( +
+ {isReady ? ( + + ) : ( + + )} + + {notice.label ?? "Background work"}{" "} + {isReady ? "is ready" : failed(notice) ? "ended with an error" : 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/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index dc7055e6..4cf3302b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -47,15 +47,23 @@ function StatTile({ to, accent, title, + failed, }: { label: string; + /** Undefined until its numbers arrive. */ value: ReactNode; sub?: ReactNode; to: string; accent?: "danger" | "warn"; /** For a number whose meaning its label cannot carry on its own. */ title?: string; + /** Its numbers couldn't be read: says so instead of loading forever. */ + failed?: boolean; }) { + if (value === undefined) { + value = failed ? "-" : ; + sub = failed ? "unavailable right now" : "loading…"; + } return ( + This couldn't be read in time. The dashboard tries again every minute. + + ) : ( + + ); +} + /** Every hour in the window, the empty ones as zero: a gap in the chart is an hour with nothing. */ function hours(overview: MetricsOverview) { const byHour = new Map(overview.hourly.map((h) => [Date.parse(h.hour), h])); @@ -103,53 +122,97 @@ export function DashboardPage() { const rabbitMqConfigured = useRabbitMqManagementConfigured(); const [healthOffset, setHealthOffset] = useState(0); const canSeeAdapters = useSessionCan("subscriptions.view"); - const dashboard = useQuery({ - queryKey: keys.dashboard, - queryFn: () => api.getDashboard(), - refetchInterval: 60_000, - placeholderData: keepPreviousData, - }); + // Each part is asked for on its own and fills in when it arrives: the page opens at once, and a + // slow part keeps only its own tile waiting. const metrics = useQuery({ queryKey: keys.metrics.overview, queryFn: () => api.getMetricsOverview(), refetchInterval: 60_000, placeholderData: keepPreviousData, }); - const insights = useQuery({ queryKey: keys.metrics.insights, queryFn: () => api.getInsights() }); + const insights = useQuery({ + queryKey: keys.metrics.insights, + queryFn: () => api.getInsights(), + refetchInterval: 60_000, + }); + const subscriptions = useQuery({ + queryKey: keys.subscriptions.rows, + queryFn: () => api.listSubscriptionRows(), + refetchInterval: 60_000, + }); + const pendingRetries = useQuery({ + queryKey: keys.dashboardPendingRetries, + queryFn: () => api.getPendingRetries(), + refetchInterval: 60_000, + }); + const queueAlerts = useQuery({ + queryKey: keys.dashboardQueueAlerts, + queryFn: () => api.getQueueAlerts(), + refetchInterval: 60_000, + }); + // Read from the exchange tables, which grow with traffic: the parts most likely to be slow. Not + // retried - the next minute's refresh asks again, rather than three retries piling onto a + // database already struggling with it. + const latestFailures = useQuery({ + queryKey: keys.dashboardLatestFailures, + queryFn: () => api.getLatestFailures(), + refetchInterval: 60_000, + retry: false, + }); + const failuresToActOn = useQuery({ + queryKey: keys.dashboardFailuresToActOn, + queryFn: () => api.getFailuresToActOn(), + refetchInterval: 60_000, + retry: false, + }); + const inFlight = useQuery({ + queryKey: keys.dashboardInFlight, + queryFn: () => api.getInFlight(), + refetchInterval: 60_000, + retry: false, + }); + const retrySummary = useQuery({ + queryKey: keys.dashboardRetrySummary, + queryFn: () => api.getRetrySummary(), + refetchInterval: 60_000, + retry: false, + }); - const data = dashboard.data; + // Each undefined until its numbers arrive. const o = metrics.data; - if ((dashboard.isError && !data) || (metrics.isError && !o)) - return ( - - Something went wrong fetching dashboard data. It'll keep retrying in the background - try refreshing the page. - - ); - if (!data || !o) return ; + const delta = o && o.last24h.total - o.previous24h.total; + const failed24 = o && o.last24h.failed + o.last24h.badResponse; + const perHour = o && hours(o); + const days = o?.daily.map((d) => ({ + at: d.day, + values: { succeeded: d.succeeded, badResponse: d.badResponse, failed: d.failed }, + })); + const moved30 = o?.daily.reduce((n, d) => n + d.bytes, 0); + const since = o?.since && Date.now() - Date.parse(o.since) < 30 * 86_400_000 ? o.since : null; + const waitingForMetrics = ; - const queueAlerts = rabbitMqConfigured ? data.queueAlerts : null; + // Null when it can't be counted, which never shows as zero. + const alerts = rabbitMqConfigured ? queueAlerts.data : null; const queueAlertsSub = !rabbitMqConfigured ? "needs RabbitMQ management configured" - : queueAlerts === null + : alerts === null ? "unavailable right now" : "live consumer health"; - const delta = o.last24h.total - o.previous24h.total; - const failed24 = o.last24h.failed + o.last24h.badResponse; - const perHour = hours(o); - const days = o.daily.map((d) => ({ - at: d.day, - values: { succeeded: d.succeeded, badResponse: d.badResponse, failed: d.failed }, - })); - const moved30 = o.daily.reduce((n, d) => n + d.bytes, 0); - const sinceRecent = o.since && Date.now() - Date.parse(o.since) < 30 * 86_400_000; + const pending = pendingRetries.data; + const toActOn = failuresToActOn.data; - const unhealthy = [ - ...data.attention.failingSubscriptions.map((s) => ({ ...s, paused: false })), - ...data.attention.pausedSubscriptions.map((s) => ({ ...s, consecutiveFailures: 0, paused: true })), + const subscriptionNames = new Map(subscriptions.data?.map((s) => [s.id, s.name])); + const latest = latestFailures.data?.map((f) => ({ + ...f, + subscriptionName: f.subscriptionId === null ? null : (subscriptionNames.get(f.subscriptionId) ?? null), + })); + const unhealthy = subscriptions.data && [ + ...subscriptions.data.filter((s) => s.consecutiveFailures > 0).map((s) => ({ ...s, paused: false })), + ...subscriptions.data.filter((s) => s.paused).map((s) => ({ ...s, consecutiveFailures: 0, paused: true })), ]; // The list can shrink on a refetch; don't leave the page pointing past its end. Reset the stored // offset too, or a later refetch that grows the list again jumps back to the old page. - const healthStart = healthOffset < unhealthy.length ? healthOffset : 0; + const healthStart = unhealthy && healthOffset < unhealthy.length ? healthOffset : 0; if (healthStart !== healthOffset) setHealthOffset(0); return ( @@ -157,101 +220,122 @@ export function DashboardPage() { - d.total > 0) || o.last24h.total > 0} /> + {o && d.total > 0) || o.last24h.total > 0} />}
- {delta >= 0 ? "+" : ""} - {compact(delta)} vs the day before - {data.inFlight > 0 && <> · {compact(data.inFlight)} in flight} - + delta !== undefined && ( + <> + {delta >= 0 ? "+" : ""} + {compact(delta)} vs the day before + {inFlight.data !== undefined && inFlight.data > 0 && <> · {compact(inFlight.data)} in flight} + {inFlight.isError && inFlight.data === undefined && <> · in flight unavailable} + + ) } to="/exchanges" + failed={metrics.isError} /> 0 ? `${compact(o.last24h.badResponse)} bad responses` : "nothing failed"} + value={failed24 === undefined ? undefined : compact(failed24)} + sub={o && (failed24 ? `${compact(o.last24h.badResponse)} bad responses` : "nothing failed")} to="/exchanges?status=failed" - accent={failed24 > 0 ? "danger" : undefined} + accent={failed24 ? "danger" : undefined} + failed={metrics.isError} /> 0 ? `of ${compact(o.last7d.total)} exchanges` : "nothing finished yet"} + value={o && percent(o.last7d.successRate)} + sub={o && (o.last7d.total > 0 ? `of ${compact(o.last7d.total)} exchanges` : "nothing finished yet")} to="/exchanges?status=success" + failed={metrics.isError} /> {/* "Failed" counts attempts, and a chain retried nine times is nine of them - none of which is the current state of anything. This counts the ends of chains: the problems. */} 10_000 ? "10,000+" : whole(data.failuresToActOn)} - sub={data.failuresToActOn > 0 ? "newest attempt of each chain" : "every failure was retried"} + // The count stops past its cap and reports the cap + 1. + value={toActOn === undefined ? undefined : toActOn > 10_000 ? "10,000+" : whole(toActOn)} + sub={toActOn ? "newest attempt of each chain" : "every failure was retried"} to="/exchanges?status=failed&latest=1" - accent={data.failuresToActOn > 0 ? "danger" : undefined} + accent={toActOn ? "danger" : undefined} title="Failures that nothing has been retried from yet. A chain of nine failed attempts counts once - as the attempt at the end of it, which is the one still to deal with." + failed={failuresToActOn.isError} /> 0 ? "waiting for their slot" : "queue is clear"} + value={pending === undefined ? undefined : whole(pending)} + sub={pending ? "waiting for their slot" : "queue is clear"} to="/scheduled-retries" - accent={data.pendingRetries > 0 ? "warn" : undefined} + accent={pending ? "warn" : undefined} + failed={pendingRetries.isError} /> 0 ? "warn" : undefined} + accent={alerts ? "warn" : undefined} /> 0 ? "usually busy, now silent" : "every busy subscription is busy"} + value={o?.silent.length} + sub={o && (o.silent.length > 0 ? "usually busy, now silent" : "every busy subscription is busy")} to="/subscriptions?health=silent" - accent={o.silent.length > 0 ? "warn" : undefined} + accent={o?.silent.length ? "warn" : undefined} title="Subscriptions that normally have traffic and have had none for well over their usual gap. Nothing fails when a partner stops sending, so nothing else would say so." + failed={metrics.isError} />
- p.weekAgo) }} - label={(at) => hourLabel.format(new Date(at))} - tick={(at) => hourTick.format(new Date(at))} - every={8} - /> + {!perHour ? ( + waitingForMetrics + ) : ( + p.weekAgo) }} + label={(at) => hourLabel.format(new Date(at))} + tick={(at) => hourTick.format(new Date(at))} + every={8} + /> + )} - {days.length === 0 ? ( + {!days ? ( + waitingForMetrics + ) : days.length === 0 ? ( Once exchanges flow, each day's volume shows here. ) : ( - {o.silent.length === 0 ? ( + {!o ? ( + waitingForMetrics + ) : o.silent.length === 0 ? ( } title="Nothing has gone quiet"> Every subscription with regular traffic still has it. @@ -295,7 +381,9 @@ export function DashboardPage() { - {unhealthy.length === 0 ? ( + {!unhealthy ? ( + + ) : unhealthy.length === 0 ? ( No failures piling up, nothing paused. ) : ( <> @@ -350,11 +438,13 @@ export function DashboardPage() {
- {data.latestFailures.length === 0 ? ( + {!latest ? ( + + ) : latest.length === 0 ? ( When an exchange fails, it shows up here first. ) : (
    - {data.latestFailures.map((f) => ( + {latest.map((f) => (
  • @@ -387,16 +477,18 @@ export function DashboardPage() { - {data.failingChains.length === 0 ? ( + {!retrySummary.data ? ( + + ) : retrySummary.data.failingChains.length === 0 ? ( Nothing has been retried more than once and left failing. ) : (
      - {data.failingChains.map((c) => ( + {retrySummary.data.failingChains.map((c) => (
    • - ({ - key: s.subscriptionId, - // Counted while it existed; deleted since, so there is nothing to open. - name: s.name ?? `Deleted subscription #${s.subscriptionId}`, - to: s.name ? subscriptionPath(s.subscriptionId) : null, - ok: s.total - s.failed - s.badResponse, - bad: s.failed + s.badResponse, - }))} - empty="Once exchanges flow, the busiest pipelines rank here." - /> + {!o ? ( + waitingForMetrics + ) : ( + ({ + key: s.subscriptionId, + // Counted while it existed; deleted since, so there is nothing to open. + name: s.name ?? `Deleted subscription #${s.subscriptionId}`, + to: s.name ? subscriptionPath(s.subscriptionId) : null, + ok: s.total - s.failed - s.badResponse, + bad: s.failed + s.badResponse, + }))} + empty="Once exchanges flow, the busiest pipelines rank here." + /> + )} - ({ - key: p.partnerId, - name: p.name ?? `Deleted partner #${p.partnerId}`, - to: p.name ? `/partners/${p.partnerId}` : null, - ok: p.total - p.failed, - bad: p.failed, - }))} - empty="No partner has sent or received anything today." - /> + {!o ? ( + waitingForMetrics + ) : ( + ({ + key: p.partnerId, + name: p.name ?? `Deleted partner #${p.partnerId}`, + to: p.name ? `/partners/${p.partnerId}` : null, + ok: p.total - p.failed, + bad: p.failed, + }))} + empty="No partner has sent or received anything today." + /> + )} - {o.slowestAdapters.length === 0 ? ( + {!o ? ( + waitingForMetrics + ) : o.slowestAdapters.length === 0 ? ( Adapters with five or more calls in a day rank here. ) : (
        @@ -490,7 +592,7 @@ export function DashboardPage() {
      - {o.backlog.length > 1 && ( + {o && o.backlog.length > 1 && ( {!insights.data ? ( - + ) : insights.data.insights.length === 0 ? ( Everything configured is in use. ) : ( diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx index cd560d0b..677fcfc8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx @@ -1,11 +1,17 @@ import { screen, within } from "@testing-library/react"; -import { http, HttpResponse } from "msw"; +import { delay, http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; import { metricsHandlers, overview, summary } from "../../../__tests__/support/metricsFixtures"; const none = { result: [], totalCount: 0 }; +/** An answer that never comes, for a part still loading. */ +const never = async () => { + await delay("infinite"); + return HttpResponse.json({}); +}; + /** A subscription row as GET /subscriptions sends it: `RawSubscription` in src/api/http/subscriptions.ts. */ const subscription = (i: number, { failures = 0, paused = false } = {}) => ({ id: 900000 + i, @@ -50,6 +56,7 @@ const dashboard = (subscriptions: ReturnType[], metrics = m http.get(apiPath("/dashboard/retrysummary"), () => HttpResponse.json({ retriesLast7Days: { finished: 0, succeeded: 0 }, failingChains: [] }), ), + http.get(apiPath("/dashboard/failurestoacton"), () => HttpResponse.json({ total: 0 })), http.get(apiPath("/ops/alerts"), () => HttpResponse.json([])), http.get(apiPath("/subscriptions"), () => HttpResponse.json({ result: subscriptions, totalCount: subscriptions.length }), @@ -119,10 +126,117 @@ describe("the dashboard", () => { ...dashboard([subscription(0)]), ], }); - expect(await screen.findByRole("heading", { name: "Subscription health" })).toBeVisible(); + expect(await screen.findByText("All subscriptions healthy")).toBeVisible(); + // The card waits for the metrics, which say whether anything has moved yet. + expect(await screen.findByText(/moved in and out over the month/)).toBeVisible(); expect(screen.queryByRole("heading", { name: "Getting started" })).not.toBeInTheDocument(); }); + it("shows a count that couldn't be read in time on its own tile, and the rest of the dashboard anyway", async () => { + renderApp("/dashboard", { + handlers: [ + // A database timeout reaches the browser as a 500. + http.get(apiPath("/dashboard/failurestoacton"), () => new HttpResponse(null, { status: 500 })), + http.get(apiPath("/dashboard/retrysummary"), () => new HttpResponse(null, { status: 500 })), + http.get(apiPath("/xchanges"), ({ request }) => + new URL(request.url).searchParams.getAll("filter").includes("StatusFilter:1:0") + ? new HttpResponse(null, { status: 500 }) + : HttpResponse.json(none), + ), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const tile = (await screen.findByText("Failures to act on")).closest("a")!; + expect(await within(tile).findByText("unavailable right now")).toBeVisible(); + expect(within(tile).getByText("-")).toBeVisible(); + + // Not left looking like nothing is in flight. + const exchanges = screen.getByText("Exchanges, last 24 hours").closest("a")!; + expect(await within(exchanges).findByText(/in flight unavailable/)).toBeVisible(); + + const chains = within(screen.getByRole("heading", { name: "Chains that keep failing" }).closest("section")!); + expect(await chains.findByText("Unavailable right now")).toBeVisible(); + + const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); + expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); + }); + + it("keeps the rest of the page when the metrics can't be read", async () => { + renderApp("/dashboard", { + handlers: [ + http.get(apiPath("/metrics/overview"), () => new HttpResponse(null, { status: 500 })), + http.get(apiPath("/metrics/insights"), () => new HttpResponse(null, { status: 500 })), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const exchanges = (await screen.findByText("Exchanges, last 24 hours")).closest("a")!; + expect(await within(exchanges).findByText("unavailable right now")).toBeVisible(); + const traffic = within(screen.getByRole("heading", { name: "Traffic, last 48 hours" }).closest("section")!); + expect(traffic.getByText("Unavailable right now")).toBeVisible(); + const housekeeping = within(screen.getByRole("heading", { name: "Housekeeping" }).closest("section")!); + expect(await housekeeping.findByText("Unavailable right now")).toBeVisible(); + + const health = within(screen.getByRole("heading", { name: "Subscription health" }).closest("section")!); + expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); + }); + + it("opens at once, and each part fills in when it arrives", async () => { + renderApp("/dashboard", { + handlers: [ + http.get(apiPath("/metrics/overview"), never), + http.get(apiPath("/dashboard/failurestoacton"), never), + // The latest failures; the in-flight count answers. + http.get(apiPath("/xchanges"), ({ request }) => + new URL(request.url).searchParams.getAll("filter").includes("StatusFilter:1:0") ? HttpResponse.json(none) : never(), + ), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const health = within((await screen.findByRole("heading", { name: "Subscription health" })).closest("section")!); + expect(await health.findByText("Health page 1", { exact: true })).toBeVisible(); + const retries = screen.getByText("Pending auto-retries").closest("a")!; + expect(await within(retries).findByText("queue is clear")).toBeVisible(); + + for (const label of ["Failures to act on", "Exchanges, last 24 hours", "Gone quiet"]) + expect(within(screen.getAllByText(label)[0].closest("a")!).getByText("loading…")).toBeVisible(); + const latest = within(screen.getByRole("heading", { name: "Latest failures" }).closest("section")!); + expect(latest.getByText("Finding the latest failures…")).toBeVisible(); + }); + + it("shows the latest failures without waiting for the metrics", async () => { + const failure = { + id: "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + subscriptionId: 900000, + documentName: "ORDER", + status: false, + responseBad: null, + exception: "Request failed with status NotFound", + startedOn: "2026-10-01T10:00:00Z", + }; + renderApp("/dashboard", { + handlers: [ + http.get(apiPath("/metrics/overview"), never), + http.get(apiPath("/xchanges"), ({ request }) => + HttpResponse.json( + new URL(request.url).searchParams.getAll("filter").includes("StatusFilter:1:3") + ? { result: [failure], totalCount: 1 } + : none, + ), + ), + ...dashboard([subscription(0, { failures: 2 })]), + ], + }); + + const latest = within((await screen.findByRole("heading", { name: "Latest failures" })).closest("section")!); + // Named from the subscription rows, which answer apart from it. + expect(await latest.findByRole("link", { name: "Health page 1" })).toBeVisible(); + expect(latest.getByText("Request failed with status NotFound")).toBeVisible(); + expect(within(screen.getByText("Exchanges, last 24 hours").closest("a")!).getByText("loading…")).toBeVisible(); + }); + it("reads its numbers from the metrics, and names subscriptions that have gone quiet", async () => { const metrics = overview({ since: "2026-09-01T00:00:00Z", @@ -160,7 +274,7 @@ describe("the dashboard", () => { }); const exchanges = (await screen.findByText("Exchanges, last 24 hours")).closest("a")!; - expect(within(exchanges).getByText("12K")).toBeVisible(); + expect(await within(exchanges).findByText("12K")).toBeVisible(); expect(within(exchanges).getByText(/\+1.4K vs the day before/)).toBeVisible(); expect(within(screen.getByText("Success rate, 7 days").closest("a")!).getByText("98.5%")).toBeVisible(); expect(within(screen.getByText("End-to-end time, last 24 hours").closest("a")!).getByText("2.4s")).toBeVisible(); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx index b8aa0777..e322a773 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx @@ -306,7 +306,13 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { )} {/* - failure - */} - {x.exception && } + {/* Explain sits on the error itself, where the eye already is; any viewer may ask, it changes nothing. */} + {x.exception && ( + } + /> + )} {/* - delivered, with a file that could not be stored - */} {x.warning && ( @@ -480,8 +486,8 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) {
    )} - {/* Outside the operate gate: an explanation changes nothing, so seeing the exchange is enough. */} - {(x.status === "failed" || x.status === "badResponse") && ( + {/* A bad response can come without error text: then Explain has no error box to sit on. */} + {!x.exception && (x.status === "failed" || x.status === "badResponse") && (
    diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index 01cc3cae..12aaf798 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -4,14 +4,16 @@ 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 ExchangeBreakdown, type ExchangeStatus, type ExportFilesCheck, } from "../../api"; import { Can } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; -import { Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { useBackgroundProgress } from "../../lib/useBackground"; +import { followDownload } from "../../lib/saveFile"; +import { Button, EmptyState, LoadError, LoadingBlock } from "../../components/ui/basics"; import { ConfirmDialog } from "../../components/ui/overlays"; import { openDialogs } from "../../components/ui/dialogStack"; import { useSubscriptionsCache } from "../../components/config/lookups"; @@ -29,9 +31,11 @@ import { COUNT_CAP, PAGE_SIZE, activeFilterCount, + allCount, filterSignature, hasTimeFilter, idsIn, + keepCounted, readQuery, timeLabel, toLocalInput, @@ -40,8 +44,14 @@ 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; + +/** How often the counts and the chart are asked for again while the server is still working them out. */ +const COUNTING_MS = 15_000; const exchangesCount = (n: number) => (n === 1 ? "1 exchange" : `${n.toLocaleString()} exchanges`); @@ -99,10 +109,14 @@ export function ExchangesPage() { // A relative range ("last hour") is worked out each time the list is asked for, so a live list // keeps showing the last hour rather than the hour before the page was opened. - const { data, isLoading, isFetching, isPlaceholderData, dataUpdatedAt, refetch } = useQuery({ + // The rows alone, so they show as soon as they're found: how many match comes with the status + // counts below, which can take far longer to work out on a busy instance. + const { data, isLoading, isFetching, isPlaceholderData, isError, error, dataUpdatedAt, refetch } = useQuery({ queryKey: keys.exchanges.search(viewSearch(searchParams) + `&offset=${query.offset}`), - queryFn: () => api.searchExchanges(readQuery(searchParams)), - refetchInterval: refreshMs || false, + queryFn: () => api.searchExchanges(readQuery(searchParams), { count: false }), + // A list that failed with nothing to show waits for Try again: asked every 15s, a timeout kept + // the database busy with the same query for as long as the page stayed open. + refetchInterval: (q) => (q.state.status === "error" && q.state.data === undefined ? false : refreshMs || false), placeholderData: keepPreviousData, }); // Loads someone is waiting on: a changed filter or page (the old rows stay up meanwhile), or the @@ -114,13 +128,22 @@ export function ExchangesPage() { // With a time filter, one request answers both. Without one the list covers all time, which // the counts follow, while the chart shows the last 24 hours and says so - a histogram of // "all time" is one bar. + // + // What takes too long to count is finished on the server, so while any of it is still coming + // these ask again every 15 seconds, live refresh or not. const timed = hasTimeFilter(searchParams); const breakdownKey = viewSearch(withParams(searchParams, { status: null })); + const askAgain = (q: { state: { data?: { counting: boolean } } }) => + refreshMs || (q.state.data?.counting ? COUNTING_MS : false); + // A number, once in, stays while a later answer is still counting it: see keepCounted. + const keepNumbers = (previous: unknown, next: unknown) => + keepCounted(previous as ExchangeBreakdown | undefined, next as ExchangeBreakdown); const breakdown = useQuery({ queryKey: keys.exchanges.breakdown(breakdownKey), queryFn: () => api.getExchangeBreakdown(readQuery(searchParams)), - refetchInterval: refreshMs || false, + refetchInterval: askAgain, placeholderData: keepPreviousData, + structuralSharing: keepNumbers, retry: false, }); const chartParams = timed ? null : withParams(searchParams, { range: "24h", status: null }); @@ -128,8 +151,9 @@ export function ExchangesPage() { queryKey: keys.exchanges.breakdown(chartParams ? viewSearch(chartParams) : ""), queryFn: () => api.getExchangeBreakdown(readQuery(chartParams!)), enabled: !!chartParams, - refetchInterval: refreshMs || false, + refetchInterval: askAgain, placeholderData: keepPreviousData, + structuralSharing: keepNumbers, retry: false, }); const volume = timed ? breakdown.data : chartBreakdown.data; @@ -200,8 +224,11 @@ export function ExchangesPage() { const filterCount = activeFilterCount(searchParams); const rows = data?.result ?? []; - const total = data?.total ?? 0; - const totalIsCapped = total > COUNT_CAP; + // How many match: the selected status's count, or every status's together - none while they're + // still being counted, still the last filter's, or the rows on screen are still the last view's. + const counts = breakdown.isPlaceholderData || isPlaceholderData ? undefined : breakdown.data?.counts; + const total = counts && (query.status ? (counts[query.status] ?? undefined) : allCount(counts)); + const totalIsCapped = total !== undefined && total > COUNT_CAP; // --- the open exchange --- // In the URL, so a link can open one. A link to a single exchange (`?ids=`, as the @@ -247,7 +274,10 @@ export function ExchangesPage() { }; const isSelected = (id: string) => (allMatching ? !excluded.has(id) : selected.has(id)); const allOnPageSelected = rows.length > 0 && rows.every((r) => isSelected(r.id)); - const selectedCount = allMatching ? Math.max(0, total - excluded.size) : selected.size; + const selectedCount = allMatching ? Math.max(0, (total ?? 0) - excluded.size) : selected.size; + // Everything matching, while how many that is isn't known: the selection stays up, and what acts on + // it waits for the number. + const countUnknown = allMatching && total === undefined; /** What the retry acts on: the ticked rows, or the filter itself minus the unticked ones. */ const selection: BulkRetrySelection = allMatching ? { matching: query, excludeIds: [...excluded] } : { ids: [...selected] }; @@ -312,56 +342,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 +362,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 +392,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 }); @@ -477,7 +460,7 @@ export function ExchangesPage() { {asked && asked.search === viewSearch(searchParams) && ( - setAsked(null)} /> + setAsked(null)} /> )} {volume && volume.buckets.length > 0 && ( @@ -515,6 +498,9 @@ export function ExchangesPage() {
    {isLoading ? ( + ) : isError && !data ? ( + /* Without it, a list that couldn't be read said "No exchanges match", as if there were none. */ + void refetch()} /> ) : rows.length === 0 ? ( /* An empty page and an empty search are different problems. This branch replaces the table, paging footer and all, so a page past the end of the list would otherwise @@ -583,13 +569,24 @@ export function ExchangesPage() { {/* - paging - */}
    - Showing {query.offset + 1}-{query.offset + rows.length} of{" "} - {totalIsCapped ? ( - - {COUNT_CAP.toLocaleString()}+ + Showing {query.offset + 1}-{query.offset + rows.length} + {total === undefined ? ( + + {" "} + of … ) : ( - total.toLocaleString() + <> + {" "} + of{" "} + {totalIsCapped ? ( + + {COUNT_CAP.toLocaleString()}+ + + ) : ( + total.toLocaleString() + )} + )} @@ -603,9 +600,14 @@ export function ExchangesPage() { - @@ -670,7 +688,7 @@ export function ExchangesPage() { {/* The whole page is ticked but there is more behind it - the one moment where "select all matching" is what someone actually wants, so it is offered there rather than living permanently in the toolbar. */} - {!allMatching && allOnPageSelected && total > rows.length && ( + {!allMatching && allOnPageSelected && total !== undefined && total > rows.length && (

    Only the {rows.length} rows on this page.{" "}

  • ))} +
)} - {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/src/pages/retry-policies/RetryGroupPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryGroupPage.tsx index 910859a2..4b691987 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryGroupPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryGroupPage.tsx @@ -2,7 +2,15 @@ import { useEffect, useRef, useState } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, Trash2 } from "lucide-react"; -import { api, type RetryAlertConfig, type RetryDelay, type RetryGroup, type RetryMatcher, type RetryResultType } from "../../api"; +import { + api, + type RetryAlertConfig, + type RetryDelay, + type RetryExhaustedAction, + type RetryGroup, + type RetryMatcher, + type RetryResultType, +} from "../../api"; import { keys } from "../../api/queryKeys"; import { useSessionCan } from "../../auth/useSessionCan"; import { useLeaveGuard } from "../../lib/useLeaveGuard"; @@ -178,7 +186,7 @@ interface GroupDraft { appliesTo: RetryResultType[]; action: RetryGroup["action"]; matchers: RetryMatcher[]; - budget: { maxAttemptsPerError: number; delay: RetryDelay; maxAttemptsTotal: number }; + budget: { maxAttemptsPerError: number; delay: RetryDelay; maxAttemptsTotal: number; whenExhausted: RetryExhaustedAction }; noTotal: boolean; notes: string; alert: RetryAlertConfig; @@ -192,7 +200,7 @@ const draftOf = (group: RetryGroup | undefined): GroupDraft => ({ action: group?.action ?? "Allow", matchers: group?.matchers ?? [], budget: { - ...(group?.budget ?? { maxAttemptsPerError: 3, delay: defaultDelayFor("exponential") }), + ...(group?.budget ?? { maxAttemptsPerError: 3, delay: defaultDelayFor("exponential"), whenExhausted: "Fail" as const }), // Kept while "No limit" is ticked, so unticking it brings back a number rather than a blank. maxAttemptsTotal: group?.budget?.maxAttemptsTotal ?? 10, }, @@ -534,6 +542,28 @@ export function RetryGroupPage({ isNew = false }: { isNew?: boolean }) {
+ {/* Only a total can run out, so without one there is nothing to choose. */} + {!draft.noTotal && ( + + - api.updateRetryPolicy(policyId, { - name, - groups: policy.data!.groups, - alertChannelId, - }), + // The groups as the server has them now, not as cached: another tab may have changed one + // since, and writing the cached copy back would undo it - a group set to Hold among them. + mutationFn: async () => { + const fresh = await api.getRetryPolicy(policyId); + await api.updateRetryPolicy(policyId, { name, groups: fresh.groups, alertChannelId }); + }, onSuccess: async () => { // Awaited before the draft is re-synced, or the re-sync would seed from stale data. await queryClient.invalidateQueries({ queryKey: keys.retryPolicies.all }); @@ -398,6 +398,12 @@ export function RetryPolicyPage() { {g.budget.maxAttemptsPerError} tries ( {g.budget.maxAttemptsTotal == null ? "no total limit" : `${g.budget.maxAttemptsTotal} total`}) ·{" "} {g.budget.delay.type} + {g.budget.maxAttemptsTotal != null && g.budget.whenExhausted === "Hold" && ( + + {" "} + · holds when spent + + )}
) : ( - diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryGroupPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryGroupPage.test.tsx index b96b0b04..12ad8ac4 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryGroupPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryGroupPage.test.tsx @@ -23,11 +23,14 @@ const timeouts = { }; /** The policy as the server has it, and whatever the page writes back to it. */ -function server() { - const written: { name: string; groups: { id: string; name: string; budget?: { maxAttemptsPerError: number } }[] }[] = []; +function server(groups: unknown[] = [timeouts]) { + const written: { + name: string; + groups: { id: string; name: string; budget?: { maxAttemptsPerError: number; whenExhausted?: string } }[]; + }[] = []; const handlers = [ http.get(apiPath("/retrypolicies/3"), () => - HttpResponse.json({ name: "Carrier API failures", groups: [timeouts], alertChannelId: 7 }), + HttpResponse.json({ name: "Carrier API failures", groups, alertChannelId: 7 }), ), http.get(apiPath("/subscriptions"), () => HttpResponse.json({ result: [], totalCount: 0 })), http.get(apiPath("/notificationchannels"), () => HttpResponse.json({ 7: "Ops email" })), @@ -58,6 +61,34 @@ describe("a retry group's page", () => { expect(written[0].groups.map((g) => [g.id, g.budget?.maxAttemptsPerError])).toEqual([["g-1", 5]]); }); + it("keeps a group that holds when its total runs out holding, through any other edit", async () => { + const holds = { ...timeouts, budget: { ...timeouts.budget, whenExhausted: "Hold" } }; + const { written, handlers } = server([holds]); + const { user } = renderApp("/retry-policies/3/groups/g-1?return=%2Felsewhere", { handlers }); + + const perMessage = await screen.findByLabelText("Retries per message"); + expect(screen.getByLabelText("When the total runs out")).toHaveValue("Hold"); + await user.clear(perMessage); + await user.type(perMessage, "5"); + await user.click(screen.getByRole("button", { name: "Save group" })); + + await waitFor(() => expect(written).toHaveLength(1)); + expect(written[0].groups[0].budget?.whenExhausted).toBe("Hold"); + }); + + it("can be set to hold when its total runs out", async () => { + const { written, handlers } = server(); + const { user } = renderApp("/retry-policies/3/groups/g-1?return=%2Felsewhere", { handlers }); + + const choice = await screen.findByLabelText("When the total runs out"); + expect(choice).toHaveValue("Fail"); + await user.selectOptions(choice, "Hold"); + await user.click(screen.getByRole("button", { name: "Save group" })); + + await waitFor(() => expect(written).toHaveLength(1)); + expect(written[0].groups[0].budget?.whenExhausted).toBe("Hold"); + }); + it("won't bring back a group that was removed while its page was open", async () => { const { written, handlers } = server(); let removed = false; diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryPolicyPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryPolicyPage.test.tsx new file mode 100644 index 00000000..b8f59bb4 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/__tests__/RetryPolicyPage.test.tsx @@ -0,0 +1,52 @@ +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** A retry policy's own page: saving its name and alert writes the groups back as the server has them. */ + +const group = (whenExhausted: "Fail" | "Hold") => ({ + id: "g-1", + name: "Timeouts", + priority: 10, + enabled: true, + appliesTo: ["Error"], + matchers: [{ type: "contains", value: "timeout", caseSensitive: false }], + action: "Allow", + budget: { maxAttemptsPerError: 3, maxAttemptsTotal: 10, delayStrategy: { type: "fixed", delayMs: 60000 }, whenExhausted }, + notes: null, + alertMode: "Inherit", + alertChannelId: null, +}); + +describe("a retry policy's page", () => { + it("doesn't undo a group changed in another tab when its name is saved", async () => { + const written: { name: string; groups: { budget?: { whenExhausted?: string } }[] }[] = []; + let reads = 0; + const { user } = renderApp("/retry-policies/3", { + handlers: [ + // The page loads the group failing; by the time it saves, another tab has set it to Hold. + http.get(apiPath("/retrypolicies/3"), () => + HttpResponse.json({ name: "Carrier API failures", groups: [group(reads++ === 0 ? "Fail" : "Hold")], alertChannelId: null }), + ), + http.get(apiPath("/subscriptions"), () => HttpResponse.json({ result: [], totalCount: 0 })), + http.get(apiPath("/notificationchannels"), () => HttpResponse.json({})), + http.post(apiPath("/retrypolicies/3/usage"), () => HttpResponse.json([])), + http.get(apiPath("/audit"), () => HttpResponse.json({ result: [], totalCount: 0 })), + http.post(apiPath("/retrypolicies/3"), async ({ request }) => { + written.push((await request.json()) as (typeof written)[number]); + return new HttpResponse(null, { status: 204 }); + }), + ], + }); + + const name = await screen.findByLabelText("Name"); + await user.clear(name); + await user.type(name, "Carrier failures"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(written).toHaveLength(1)); + expect(written[0].name).toBe("Carrier failures"); + expect(written[0].groups[0].budget?.whenExhausted).toBe("Hold"); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx index be0bc358..c0b8800d 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx @@ -73,7 +73,6 @@ export function RetentionPanel({ rows, draft }: { rows: SettingRow[]; draft: Set const filesRulePrefix = status.files.rulePrefix; const ex = status.exchanges; - const count = (n: number | null) => (n === null ? "-" : n >= ex.countCap ? `${ex.countCap.toLocaleString()}+` : n.toLocaleString()); return (
@@ -94,9 +93,9 @@ export function RetentionPanel({ rows, draft }: { rows: SettingRow[]; draft: Set title={`When the retention job next runs (${ex.cron}), in your time zone. It does nothing while exchanges are kept for ever.`} />
diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx index cbdff7cc..cd592362 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx @@ -163,7 +163,6 @@ const retention = (overrides: Partial = {}): RetentionStatus => cron: "0 0 4 * * ?", nextRun: "2026-10-01T04:00:00Z", dueNow: null, - withoutFiles: 12, countCap: 100000, oldest: "2026-01-01T00:00:00Z", }, @@ -300,6 +299,8 @@ describe("the settings page", () => { // The saved state, and the bucket's own rules with the one exchange files fall under marked. expect(await screen.findByText("What these settings do")).toBeVisible(); expect(screen.getByText(/nothing removes them from the database/)).toBeVisible(); + const oldest = screen.getByText("Oldest exchange").parentElement!; + expect(within(oldest).getByText(new Date("2026-01-01T00:00:00Z").toLocaleDateString(undefined, { dateStyle: "medium" }))).toBeVisible(); const rules = within(screen.getByRole("table")); expect(rules.getByText("temp30/")).toBeVisible(); expect(rules.getByText("Exchange files")).toBeVisible(); diff --git a/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..a327f284 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(); @@ -167,6 +171,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); @@ -578,6 +583,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,9 +1080,18 @@ 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); + // Next to the endpoints, so it sees a timeout before anything turns it into a plain 500, + // and after the request context, so its log line carries the caller. + app.UseMiddleware(); + if (apiDocsExposed) app.UseSwaggerUI(c => { c.SwaggerEndpoint("/api/swagger.json", "Bitween Api"); }); @@ -1079,6 +1099,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..f5b69777 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -16,6 +16,7 @@ | 401 | Not signed in, or the token has expired | | 403 | Signed in, but missing the permission | | 404 | Not found | +| 504 | `DATABASE_TIMED_OUT`: a database query ran past its time limit: the connection's (30 seconds unless the connection string sets another), or a `statement_timeout` set on the database. Asking again takes as long again, so ask for less, such as a shorter time range. PostgreSQL only; SQL Server and MySQL still answer 500. | ### Search queries @@ -30,6 +31,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 | @@ -79,7 +97,8 @@ All need the global values permissions: `global-values.view` to read, `.create`, | Method and path | Permission | Description | |---|---|---| -| `GET /api/xchanges` | `exchanges.view` or `dashboard.view`, or signed in with `lookup=true` | Searchy. See the filters below. A lookup returns at most 100 exchange ids, newest first, each with its information type's name. | +| `GET /api/xchanges` | `exchanges.view` or `dashboard.view`, or signed in with `lookup=true` | Searchy. See the filters below. `totalCount` stops at 10,001; `count=false` leaves it out, as 0, so the rows come back without waiting for it. A lookup returns at most 100 exchange ids, newest first, each with its information type's name. | +| `GET /api/xchanges/summary` | `exchanges.view` or `dashboard.view` | The search's filters, any status filter ignored, as `counts` per status (`processing`, `success`, `badResponse`, `failed`), each capped at 10,001 like the search's total. With a start date, also `buckets` of when they started and `bucketMinutes`. What isn't worked out within 5 seconds is finished in the background, at most 2 at a time per node and each with up to 10 minutes on the database, and kept 5 minutes: until then its count is `null` or the buckets are empty, and `counting` is `true`. Asking again brings it. One nobody has asked about for 2 minutes when its turn comes is dropped until asked again. | | `POST /api/xchangequeries` | `exchanges.view` | Turn a question into an Exchanges page filter with AI: `{ question, timeZone }` returns `{ query, answer, notes, model, sent }`. See [Asking your exchanges](configuration.md#asking-your-exchanges). | | `GET /api/xchanges/statuslist` | `exchanges.view` | Status values for filters | | `GET /api/xchanges/retrytree?id=` | `exchanges.view` | The retry chain any attempt belongs to | @@ -109,9 +128,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 leaves out any file that would take it past that before reading it; `missing.txt` lists both. A server builds one background export at a time: the next waits its turn, saying so in its progress. 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. @@ -372,5 +391,6 @@ See [Data sources](data-sources.md) and [Databases](databases.md). | `GET /api/dashboard/MainInfo` | `dashboard.view` | | `GET /api/dashboard/ChartsDataPoints` | `dashboard.view` | | `GET /api/dashboard/XChangesAndSubscriptionsInfo` | `dashboard.view` | -| `GET /api/dashboard/retrysummary` | `dashboard.view`. Retries finished in the last 7 days, and the five failing chains with the most attempts. | +| `GET /api/dashboard/retrysummary` | `dashboard.view`. Retries finished in the last 7 days, and the five failing chains with the most attempts among those retried in the same 7 days. | +| `GET /api/dashboard/failurestoacton` | `dashboard.view`. `{ total }`: failed exchanges nothing has been retried from, the count the exchange search gives for `StatusFilter:1:3` with `LatestOnly:1:true`. Capped like the search: past 10,000 it reports 10,001. | | `GET /health` | none | diff --git a/docs/caveats.md b/docs/caveats.md index 3f65ca28..9310ea77 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -26,7 +26,6 @@ These behaviours were found while documenting Bitween from its source code, and | Mappers other than the rules-based mapper overwrite payload keys named `__partner__` or `__globals__`. | `XchangeService.RunMapper` | | An API gateway attached to an inactive subscription returns 404. | `Controllers/GatewayController.cs` | | Bulk retry includes exchanges that have no result yet, so work still in flight can run twice. Deliberate: after a broker outage those are the exchanges a bulk retry is for. | `Resources/Xchanges/BulkRetryPlanner.cs` | -| The dashboard's *Failures to act on* tile says it covers 14 days, but counts failures of any age. | `ClientApp/src/pages/dashboard/DashboardPage.tsx` | | An exchange's delivered time is never recorded. | `Domain/XchangeDelivery.cs` | | Exchanges, results and notifications are kept for ever by default (`ExchangeRetentionDays` 0), while their files expire from storage. The retention panel on the Settings page warns when exchanges outlive their files. | `Services/Retention/ExchangeRetentionJob.cs` | | Cached configuration can stay stale on other instances for up to 10 minutes if the revoke broadcast fails. Adapter descriptions and catalog entries are cached per node; publishing, promoting or withdrawing through Bitween clears them everywhere over the bus, but a package published straight to storage is seen with its old properties for up to a minute, or five for one without a manifest, which is described by starting it. That answer is kept until the adapter's package changes, so it is started once a build rather than every five minutes. | `Services/Caching/InMemoryInfolinkCache.cs`, `Services/ServerlessAdapterDescriber.cs` | @@ -96,6 +95,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, and the audit trail has the run's start and how many it had saved when it stopped. | `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. The audit trail has the run's start, but no entry for how many it saved. 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/configuration.md b/docs/configuration.md index 9550ba6f..7025faee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -232,7 +232,7 @@ Sample documents an AI feature sends go to this provider, with the names - never **Explain this failure** on a failed exchange sends this, and only this, to the provider: - the error, which step failed, the subscription's kind and adapters, and the names of the handler's settings, never their values (they hold partner and global values already filled in, some of them credentials); -- the retry policy's groups, the attempt's place in its retry chain, why it won't be retried and any scheduled retry, and the first line of up to three earlier attempts' errors; +- the retry policy's groups, the attempt's place in its retry chain, why it won't be retried and any scheduled retry, and the chain itself: each attempt's outcome and the first line of its error, up to 30 attempts before this one and 20 after. Another attempt's line is masked with that attempt's own documents too; when they can't be read (gone, over 1 MB, or not JSON, XML or CSV), its line isn't sent. A first line longer than 4,000 characters is sent as its exception type alone; - the input, mapped and response documents, with every value replaced by its type: `"name": "Lina"` goes as `"name": ""`. Long lists show their first three entries. A CSV's every row is masked, a header row included, so a file without one can't send its first record as column names. A document that isn't JSON, XML or CSV, or is over 1 MB, isn't sent at all. Error text is masked too: a document inside it, such as a partner's response body, is masked as a document; every value the exchange's documents hold is taken out wherever it appears; quoted text, numbers of four or more digits, email addresses, URL query strings and anything that looks like a credential or token are replaced; and a stack frame keeps its file name and line but not the server's folders. 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/operations.md b/docs/operations.md index d3c683a5..dca8c111 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,6 +8,8 @@ The Exchanges page filters by status, subscription, partner, information type, exchange ids, correlation id, a promoted property value and a date range. Pick a promoted property to search inside its value only; any part of the value matches, ignoring case. Filters live in the URL, so a filtered view can be shared as a link. The list reloads itself every 15 seconds by default (adjustable, or paused); **Refresh**, beside the pause button, reloads the list and its counts right away. +Each status above the list shows how many exchanges it holds under the other filters, up to 10,000+. The list doesn't wait for them: its rows show at once, and how many match ("Showing 1-25 of ...") comes with the counts. A count that takes more than a few seconds, such as Processing across millions of exchanges, is finished on the server and shows up once it's ready; until then that status shows no number, and the page asks again every 15 seconds. The server runs 2 such counts at a time, each for up to 10 minutes, so one can wait its turn first; one whose page was closed before its turn is dropped. A finished count is kept for 5 minutes, then counted again, and a number stays on screen while it is. The chart works the same way. If the database takes too long to find the rows themselves, the list says so and offers **Try again**, rather than showing an empty list. + - Search by **promoted property** to find a business document, such as an order number. - Search by **correlation id** to see every exchange in one flow, including exchanges created from responses. - Searching by **id** also finds retries of that exchange and the aggregation that collected it. @@ -69,9 +71,11 @@ See [Scheduling](scheduling.md). ## Dashboard -The dashboard, opened from the logo, shows today's exchanges, the 7-day success rate, today's failures, waiting retries, queue alerts, a 14-day chart, the latest failures, unhealthy subscriptions and the busiest subscriptions. The browser computes it from up to 1,000 recent exchanges, so figures on a busy instance are approximate. +The dashboard, opened from the logo, shows the last 24 hours of exchanges, failures and end-to-end time, the 7-day success rate, waiting retries, queue alerts and subscriptions that have gone quiet. Below come traffic for the last 48 hours against the same hours a week before, volume for the last 30 days, unhealthy subscriptions, the latest failures, the busiest subscriptions and partners, the slowest adapters, and configuration worth tidying up. The traffic figures come from hourly metrics the server keeps, so they count every exchange however busy the instance is. + +Some parts are read from the exchanges themselves, so on a busy instance they are the slowest: the latest failures, the count in flight, and the two below. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts among those retried in the last 7 days, with the success rate of retries over the same 7 days. -Two parts come from the server instead. **Failures to act on** counts failed exchanges that are the newest attempt of their chain, of any age, and links to that search. **Chains that keep failing** lists the five retry chains with the most attempts, with the success rate of retries over the last 7 days. +The page opens at once, and each tile and panel fills in when its own numbers arrive, so a slow one doesn't hold up the rest. One that can't be read in time shows as unavailable, and the dashboard asks again every minute. ## Audit trail @@ -150,7 +154,7 @@ When a partner says a gateway refuses them, the gateway's page lists **Calls tur **Settings › About this instance** shows what this Bitween is and how it is set up, as the node that answered sees it: its version, the node's name and whether it runs data sources, the database and RabbitMQ checks `/health/ready` runs (run when the page opens), storage and queue prefix, which adapter runtimes (.NET, Python, Node.js) and pip/npm are on the node, the adapter editor's limits, rate limits, and the network and housekeeping settings that live in configuration. In a cluster, open it on each node to compare. -The **Documents & storage** section of the Settings page shows the bucket's rules and what the settings do: how long files and exchanges are kept, when the job next runs, how many exchanges are listed without their files, and warnings such as exchanges that outlive their files, archives that come too late to hold them, aggregations that run less often than exchanges are kept, or a bucket that lets anyone read Bitween's files without credentials. Saving a change to these settings shows the same list and asks first. +The **Documents & storage** section of the Settings page shows the bucket's rules and what the settings do: how long files and exchanges are kept, when the job next runs, when the oldest exchange started, and warnings such as exchanges that outlive their files, archives that come too late to hold them, aggregations that run less often than exchanges are kept, or a bucket that lets anyone read Bitween's files without credentials. Saving a change to these settings shows the same list and asks first. Exchange rows can outlive their files. Opening such a file says it was deleted by the retention policy, and so does retrying an exchange whose input has gone. A waiting retry for one is dropped with a reason. diff --git a/docs/retries-and-alerts.md b/docs/retries-and-alerts.md index a2caf9bd..1c24d5c4 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. @@ -103,6 +106,8 @@ A budget sets two limits. When a subscription succeeds, each of its budgets that was already exhausted before that exchange started is released. Partly spent budgets are left alone. +**`whenExhausted`** says what happens to a failure the group would retry once its total is spent. `Fail`, the default, fails it for good. `Hold` keeps it waiting and retries it as soon as the subscription next succeeds, so messages caught by a partner's outage go through once it is back. On a group's page it is **When the total runs out**, shown while the group has a total, and the policy's group list marks a held group "holds when spent". + ### Example group ```json @@ -133,10 +138,10 @@ When a subscription succeeds, each of its budgets that was already exhausted bef 4. If no group matched, there is no retry. 5. If the group blocks, there is no retry. 6. If the message reached `maxAttemptsPerError`, there is no retry. This is checked first, so it does not spend the shared total. -7. If the shared total is spent, there is no retry. When this failure is the one that used up the total, an alert is raised. +7. If the shared total is spent, there is no retry, unless the group's `whenExhausted` is `Hold`: then the failure waits, with no reason recorded, and is retried when the subscription next succeeds. Either way, when this failure is the one that used up the total, an alert is raised. 8. Otherwise a retry is scheduled after the delay, give or take a tenth, or later when the adapter [said when to try again](#when-an-adapter-says-when-to-try-again). -When no retry is scheduled, the exchange result records one of these reasons. +When no retry is scheduled, and the failure isn't held, the exchange result records one of these reasons. - No matching group (default block) - Group '…' explicitly blocks this error