From 08217d72a192ed102971ce159080cb08a7238b50 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 17:41:32 +0300 Subject: [PATCH 1/3] fix: Settings retention panel no longer reads every exchange --- .../Services/Retention/RetentionPlanner.cs | 39 ++++++------------- .../Tests/ExchangeRetentionTests.cs | 32 ++++++--------- SW.Bitween.Sdk/Model/Retention.cs | 7 +--- SW.Bitween.Web/ClientApp/src/api/types.ts | 2 +- .../src/pages/settings/RetentionPanel.tsx | 7 ++-- .../settings/__tests__/SettingsPage.test.tsx | 3 +- docs/operations.md | 2 +- 7 files changed, 31 insertions(+), 61 deletions(-) diff --git a/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs b/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs index b4918127..6492507a 100644 --- a/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs +++ b/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs @@ -13,7 +13,7 @@ namespace SW.Bitween; /// /// Works out what the retention settings do - as they stand, or as someone is about to change them - so /// the settings page can say it before anything is saved: how long files and exchanges are kept, what -/// the next run removes, and which exchanges are left without their files. +/// the next run removes, and whether exchanges are kept longer than their files. /// public class RetentionPlanner(BitweenDbContext dbContext, BitweenOptions options, StorageRetention storageRetention, StorageAccess storageAccess) @@ -35,8 +35,8 @@ public async Task Plan(RetentionProposal proposal = null, bool var now = DateTime.UtcNow; var files = Retention(prefix, rules); - // Exchanges already written keep the prefix they were written under, so how many are left - // without files depends on the prefix in force now, not on a proposed one. + // Exchanges already written keep the prefix they were written under, so whether they outlive + // their files depends on the prefix in force now, not on a proposed one. var currentFiles = prefix == options.DocumentPrefix ? files : Retention(options.DocumentPrefix, rules); var legacyPrefix = options.LegacyDocumentPrefix; var legacyFiles = !string.IsNullOrEmpty(legacyPrefix) && legacyPrefix != prefix ? Retention(legacyPrefix, rules) : null; @@ -51,29 +51,10 @@ public async Task Plan(RetentionProposal proposal = null, bool ? await ExchangeRetention.DueBefore(dbContext, now.AddDays(-days)).Take(CountCap).CountAsync() : null; - // As things stand now, whatever the proposal: those the next run removes are still listed until then. - // Each exchange's files follow the rule of the prefix they were written under, which a later change - // of prefix doesn't move; only those older than the shortest rule can have lost them. - int? withoutFiles = currentFiles.Days == null ? null : 0; - if (rules.Problem == null && - rules.Lifecycle?.Rules.Where(r => r.Enabled).Select(r => (int?)r.Days).Min() is { } shortest) - { - var unrecordedPrefix = options.LegacyDocumentPrefix ?? options.DocumentPrefix; - var writtenUnder = await dbContext.Set() - .Where(x => x.StartedOn < now.AddDays(-shortest)) - .Select(x => x.FilesPrefix).Distinct().ToListAsync(); - foreach (var recorded in writtenUnder) - { - if (Retention(recorded ?? unrecordedPrefix, rules).Days is not { } filesDays) continue; - var filesGone = now.AddDays(-filesDays); - var counted = withoutFiles ?? 0; - withoutFiles = counted + await dbContext.Set() - .Where(x => x.FilesPrefix == recorded && x.StartedOn < filesGone) - .Take(CountCap - counted).CountAsync(); - if (withoutFiles >= CountCap) break; - } - } - + // How many exchanges have lost their files isn't counted: which rule applies depends on the + // prefix each was written under, and finding those meant reading every exchange older than the + // bucket's shortest rule - a day, with the standard temp1/ rule - which took over 30s on 5.8M + // exchanges and failed the page. Their age says enough: see OUTLIVE_FILES. var oldest = await dbContext.Set().OrderBy(x => x.StartedOn) .Select(x => (DateTime?)x.StartedOn).FirstOrDefaultAsync(); @@ -106,7 +87,6 @@ public async Task Plan(RetentionProposal proposal = null, bool Cron = cron, NextRun = nextRun, DueNow = dueNow, - WithoutFiles = withoutFiles, CountCap = CountCap, Oldest = oldest } @@ -145,7 +125,10 @@ private List Notices(RetentionStatus status, PrefixRetention cu if (currentFiles.Days is { } filesDays && (days == 0 || days > filesDays)) warnings.Add(Warning("OUTLIVE_FILES", $"Exchanges older than {filesDays} days are listed without their files: they can't be opened " + - $"or retried. {(exchanges.WithoutFiles == 1 ? "There is 1" : $"There are {Count(exchanges.WithoutFiles, exchanges.CountCap)}")} right now." + + "or retried." + + (exchanges.Oldest is { } oldest && oldest < DateTime.UtcNow.AddDays(-filesDays) + ? $" Some already are: the oldest started on {oldest.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}." + : "") + (newOutliveFiles ? $" New exchanges lose theirs after {newFilesDays} days." : ""))); else if (newOutliveFiles) warnings.Add(Warning("OUTLIVE_FILES", diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs index 1387b566..a0be35c2 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs @@ -255,9 +255,6 @@ public async Task The_preview_says_when_exchanges_outlive_their_files_and_archiv DocumentPrefix = "temp30/docs", ExchangeRetentionDays = 20, ArchiveExchanges = true }); Assert.DoesNotContain(shorter.Notices, n => n.Code is "OUTLIVE_FILES" or "ARCHIVE_WITHOUT_FILES"); - // How many are without files is as things stand now, whatever the proposal. - Assert.NotNull(shorter.Exchanges.WithoutFiles); - Assert.Equal(longer.Exchanges.WithoutFiles, shorter.Exchanges.WithoutFiles); var forever = await planner.Plan(new RetentionProposal { DocumentPrefix = "docs/kept", ExchangeRetentionDays = 0 }); Assert.Null(forever.Files.Days); @@ -265,28 +262,23 @@ public async Task The_preview_says_when_exchanges_outlive_their_files_and_archiv } [Fact] - public async Task Exchanges_without_files_are_counted_by_the_prefix_each_was_written_under() + public async Task Exchanges_already_older_than_their_files_are_pointed_out_by_the_oldest() { var (_, subscription) = await Setup(); - var shortPrefix = $"temp30/{Guid.NewGuid():N}"; - var longPrefix = $"temp365/{Guid.NewGuid():N}"; - _options.DocumentPrefix = longPrefix; + _options.DocumentPrefix = $"temp30/{Guid.NewGuid():N}"; + var old = await CreateExchange(subscription, "{}", new(), succeeded: true); + await Backdate(old.Id, 45); + await using var scope = fixture.CreateScope(); var planner = ActivatorUtilities.CreateInstance(scope.ServiceProvider, ExchangeFilesTests.RetentionOver( - new CloudFilesLifecycleRule { Id = "temp30", Prefix = "temp30/", Days = 30, Enabled = true }, - new CloudFilesLifecycleRule { Id = "temp365", Prefix = "temp365/", Days = 365, Enabled = true })); - var before = (await planner.Plan()).Exchanges.WithoutFiles; - - _options.DocumentPrefix = shortPrefix; - var underShort = await CreateExchange(subscription, "{}", new(), succeeded: true); - _options.DocumentPrefix = longPrefix; - var underLong = await CreateExchange(subscription, "{}", new(), succeeded: true); - await Backdate(underShort.Id, 45); - await Backdate(underLong.Id, 45); - - // Today's prefix keeps files a year, but the one written under the 30-day prefix has lost its own. - Assert.Equal(before + 1, (await planner.Plan()).Exchanges.WithoutFiles); + new CloudFilesLifecycleRule { Id = "temp30", Prefix = "temp30/", Days = 30, Enabled = true })); + + // Kept for ever, files for 30 days, and an exchange 45 days old: some are listed without their files. + var status = await planner.Plan(new RetentionProposal { ExchangeRetentionDays = 0 }); + Assert.True(status.Exchanges.Oldest <= DateTime.UtcNow.AddDays(-45)); + var outlive = Assert.Single(status.Notices, n => n.Code == "OUTLIVE_FILES"); + Assert.Contains($"Some already are: the oldest started on {status.Exchanges.Oldest:yyyy-MM-dd}.", outlive.Message); } [Fact] diff --git a/SW.Bitween.Sdk/Model/Retention.cs b/SW.Bitween.Sdk/Model/Retention.cs index 67ccbf41..51c4f0cd 100644 --- a/SW.Bitween.Sdk/Model/Retention.cs +++ b/SW.Bitween.Sdk/Model/Retention.cs @@ -85,15 +85,10 @@ public class ExchangeRetentionInfo /// Exchanges the next run would remove; null while retention is off. public int? DueNow { get; set; } - /// - /// Exchanges still kept but older than their files: listed without them. Null when the bucket keeps - /// files for ever or its rules are unknown. - /// - public int? WithoutFiles { get; set; } - /// Counts stop at this many; a count equal to it means "at least". public int CountCap { get; set; } + /// When the oldest exchange still kept started; null when there are none. public DateTime? Oldest { get; set; } } diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index c1acc69d..9e486947 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1408,9 +1408,9 @@ export interface RetentionStatus { cron: string; nextRun: string | null; dueNow: number | null; - withoutFiles: number | null; /** Counts stop here; a count equal to it means "at least". */ countCap: number; + /** When the oldest exchange still kept started. */ oldest: string | null; }; notices: RetentionNotice[]; diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx index be0bc358..c0b8800d 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/settings/RetentionPanel.tsx @@ -73,7 +73,6 @@ export function RetentionPanel({ rows, draft }: { rows: SettingRow[]; draft: Set const filesRulePrefix = status.files.rulePrefix; const ex = status.exchanges; - const count = (n: number | null) => (n === null ? "-" : n >= ex.countCap ? `${ex.countCap.toLocaleString()}+` : n.toLocaleString()); return (
@@ -94,9 +93,9 @@ export function RetentionPanel({ rows, draft }: { rows: SettingRow[]; draft: Set title={`When the retention job next runs (${ex.cron}), in your time zone. It does nothing while exchanges are kept for ever.`} />
diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx index cbdff7cc..cd592362 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx @@ -163,7 +163,6 @@ const retention = (overrides: Partial = {}): RetentionStatus => cron: "0 0 4 * * ?", nextRun: "2026-10-01T04:00:00Z", dueNow: null, - withoutFiles: 12, countCap: 100000, oldest: "2026-01-01T00:00:00Z", }, @@ -300,6 +299,8 @@ describe("the settings page", () => { // The saved state, and the bucket's own rules with the one exchange files fall under marked. expect(await screen.findByText("What these settings do")).toBeVisible(); expect(screen.getByText(/nothing removes them from the database/)).toBeVisible(); + const oldest = screen.getByText("Oldest exchange").parentElement!; + expect(within(oldest).getByText(new Date("2026-01-01T00:00:00Z").toLocaleDateString(undefined, { dateStyle: "medium" }))).toBeVisible(); const rules = within(screen.getByRole("table")); expect(rules.getByText("temp30/")).toBeVisible(); expect(rules.getByText("Exchange files")).toBeVisible(); diff --git a/docs/operations.md b/docs/operations.md index 667c97d0..52501789 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -152,7 +152,7 @@ When a partner says a gateway refuses them, the gateway's page lists **Calls tur **Settings › About this instance** shows what this Bitween is and how it is set up, as the node that answered sees it: its version, the node's name and whether it runs data sources, the database and RabbitMQ checks `/health/ready` runs (run when the page opens), storage and queue prefix, which adapter runtimes (.NET, Python, Node.js) and pip/npm are on the node, the adapter editor's limits, rate limits, and the network and housekeeping settings that live in configuration. In a cluster, open it on each node to compare. -The **Documents & storage** section of the Settings page shows the bucket's rules and what the settings do: how long files and exchanges are kept, when the job next runs, how many exchanges are listed without their files, and warnings such as exchanges that outlive their files, archives that come too late to hold them, aggregations that run less often than exchanges are kept, or a bucket that lets anyone read Bitween's files without credentials. Saving a change to these settings shows the same list and asks first. +The **Documents & storage** section of the Settings page shows the bucket's rules and what the settings do: how long files and exchanges are kept, when the job next runs, when the oldest exchange started, and warnings such as exchanges that outlive their files, archives that come too late to hold them, aggregations that run less often than exchanges are kept, or a bucket that lets anyone read Bitween's files without credentials. Saving a change to these settings shows the same list and asks first. Exchange rows can outlive their files. Opening such a file says it was deleted by the retention policy, and so does retrying an exchange whose input has gone. A waiting retry for one is dropped with a reason. From 173aadc631e80bb86c43af95ef8dfd3877a1c635 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 2 Nov 2026 17:41:32 +0300 Subject: [PATCH 2/3] fix: Exchanges page shows its rows at once and finishes slow counts on the server --- SW.Bitween.Api/Resources/Xchanges/Search.cs | 8 +- SW.Bitween.Api/Resources/Xchanges/Summary.cs | 156 +- SW.Bitween.Api/Services/SlowCounts.cs | 127 + .../Fixtures/BitweenFixture.cs | 1 + .../Tests/ExchangeFilterOperatorTests.cs | 5 +- .../Tests/ExchangeSummaryTests.cs | 99 +- ...0261102124651_BadResponseIndex.Designer.cs | 4209 +++++++++++++++++ .../20261102124651_BadResponseIndex.cs | 28 + SW.Bitween.Sdk/Model/Xchange.cs | 22 +- SW.Bitween.Web/ClientApp/src/api/client.ts | 2 +- .../ClientApp/src/api/http/exchanges.ts | 17 +- SW.Bitween.Web/ClientApp/src/api/types.ts | 9 +- .../src/pages/exchanges/ExchangesPage.tsx | 57 +- .../src/pages/exchanges/StatusTabs.tsx | 9 +- .../__tests__/ExchangesList.test.tsx | 68 +- .../src/pages/exchanges/exchangeFilters.ts | 11 + SW.Bitween.Web/Startup.cs | 1 + docs/api-reference.md | 3 +- docs/operations.md | 2 + 19 files changed, 4769 insertions(+), 65 deletions(-) create mode 100644 SW.Bitween.Api/Services/SlowCounts.cs create mode 100644 SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs diff --git a/SW.Bitween.Api/Resources/Xchanges/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs index 3dc89dcc..24666c01 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Search.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs @@ -156,8 +156,12 @@ from delayedRetry in drGroup.DefaultIfEmpty() // as "10,000+". 36ms drops to 2.3ms unfiltered, 264ms to 25ms on Success. Anything // filtered narrowly enough to act on still gets an exact number; only views far too // broad to page through lose it, and 10,000 rows is 400 pages of Next. - TotalCount = await query.AsNoTracking().Search(searchyRequest.Conditions) - .Take(CountCap + 1).CountAsync() + // + // Not at all with count=false, as Searchy's own search does: the Exchanges page asks + // for its rows that way so they show at once, and has the total from the summary. + TotalCount = searchyRequest.CountRows + ? await query.AsNoTracking().Search(searchyRequest.Conditions).Take(CountCap + 1).CountAsync() + : 0 }; return searchyResponse; diff --git a/SW.Bitween.Api/Resources/Xchanges/Summary.cs b/SW.Bitween.Api/Resources/Xchanges/Summary.cs index 10a807cf..ce5d2d2a 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Summary.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Summary.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Data; +using System.Globalization; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Model; @@ -27,11 +29,16 @@ namespace SW.Bitween.Resources.Xchanges; /// Every special filter the search understands is applied the same way, so a count is never of a /// different set than the list it sits over. /// +/// +/// A count or a histogram that takes longer than is finished in the background +/// by , which keeps it for the next ask: until then it is missing from the +/// answer, and says so. +/// /// [HandlerName("summary")] -public class Summary(BitweenDbContext dbContext, RequestContext requestContext) : ISearchyHandler +public class Summary(BitweenDbContext dbContext, RequestContext requestContext, SlowCounts slowCounts) : ISearchyHandler { - /// The status numbers reads, in the order they are counted. + /// The status numbers reads. private const string Processing = "0", Success = "1", BadResponse = "2", Failed = "3"; public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) @@ -47,6 +54,11 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa foreach (var statusFilter in condition.Filters.Where(f => f.Field == "StatusFilter").ToList()) condition.Filters.Remove(statusFilter); + // Taken before FilteredRowsAsync takes the special filters out of the request: a count finished + // in the background filters again from this, with a database of its own. + var asked = (SearchyRequest)searchyRequest.Clone(); + var view = ViewKey(searchyRequest, DateTime.UtcNow); + var rows = await XchangeFilters.FilteredRowsAsync(dbContext, searchyRequest); // Read after FilteredRowsAsync, which is what moves the request's dates to UTC. The @@ -58,15 +70,24 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa // One capped count per status rather than a single grouped count: each can stop at the // cap, as the search's total does, where a grouping has to visit every matching row. Four // short queries over a broad filter cost far less than one exhaustive one. - var counts = new XchangeStatusCounts + // + // All four share one budget, so the answer never waits longer than it, however many are slow. + // The ones with an index of their own go first; still running, which can't have one, last. + var counts = new XchangeStatusCounts(); + using (var deadline = new CancellationTokenSource(Budget)) { - Processing = await CountCapped(rows, Processing), - Success = await CountCapped(rows, Success), - BadResponse = await CountCapped(rows, BadResponse), - Failed = await CountCapped(rows, Failed), - }; + counts.BadResponse = await Count(BadResponse, deadline.Token); + counts.Failed = await Count(Failed, deadline.Token); + counts.Success = await Count(Success, deadline.Token); + counts.Processing = await Count(Processing, deadline.Token); + } - var summary = new XchangeSummary { Counts = counts }; + var summary = new XchangeSummary + { + Counts = counts, + Counting = counts.Processing == null || counts.Success == null || counts.BadResponse == null || + counts.Failed == null + }; if (rangeStart is not { } start) return summary; @@ -75,14 +96,115 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa return summary; var bucketMinutes = XchangeHistogram.ChooseBucketMinutes(start, end); + using var histogramDeadline = new CancellationTokenSource(Budget); + var started = await Answer($"histogram:{bucketMinutes}|{view}", histogramDeadline.Token, + ct => StartedCounts(rows, bucketMinutes, ct), + async (db, ct) => await StartedCounts(await XchangeFilters.FilteredRowsAsync(db, Copy(asked)), bucketMinutes, ct)); + if (started == null) + { + summary.Counting = true; + return summary; + } + summary.BucketMinutes = bucketMinutes; - summary.Buckets = XchangeHistogram.Fold(start, end, bucketMinutes, - await StartedCounts(rows, bucketMinutes)); + summary.Buckets = XchangeHistogram.Fold(start, end, bucketMinutes, started); return summary; + + Task Count(string status, CancellationToken deadline) => + Answer($"count:{status}|{view}", deadline, + ct => CountCapped(rows, status, dbContext, ct), + async (db, ct) => await CountCapped(await XchangeFilters.FilteredRowsAsync(db, Copy(asked)), status, db, ct)); + } + + /// + /// The answer keeps for , when it is one that was too + /// slow before. Otherwise worked out within the budget, and when that runs + /// out, left to to work out : null until then. + /// + private async Task Answer(string key, CancellationToken deadline, Func> here, + Func> later) + { + if (slowCounts.TryGet(key, later, out var kept)) + return kept; + + var answer = await WithinBudget(dbContext, deadline, here); + if (answer == null) + slowCounts.Start(key, later); + return answer; + } + + private static SearchyRequest Copy(SearchyRequest request) => (SearchyRequest)request.Clone(); + + /// + /// The filters as text, naming what was counted for . A start with no end - + /// what "last 24 hours" sends, a little later on every ask - is named by how far back it reaches, + /// to five minutes, so the asks of one page find the one count rather than each starting another. + /// + internal static string ViewKey(SearchyRequest request, DateTime now) => + string.Join("|", request.Conditions.Select(condition => + { + var openEnded = !condition.Filters.Any(f => + f.Field == nameof(XchangeRow.StartedOn) && f.Rule == SearchyRule.LessThanOrEquals); + return string.Join("&", condition.Filters + .Select(f => + openEnded && f.Field == nameof(XchangeRow.StartedOn) && f.Rule == SearchyRule.GreaterThanOrEquals && + DateTime.TryParse(f.Value?.ToString(), CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var since) + ? $"StartedOn:since:{Math.Round((now - since).TotalMinutes / 5)}" + : f.ToString()) + .OrderBy(f => f, StringComparer.Ordinal)); + })); + + /// How long the counts together, and then the histogram, may take before the rest is left to the background. + /// + /// A status that is rare across a large table can only be counted by visiting every exchange: + /// with no result row to look up, "processing" can't be found any other way. On millions of + /// exchanges that ran past the database's own time limit and failed the whole request, taking + /// every other tab's count with it. + /// + internal static readonly TimeSpan Budget = TimeSpan.FromSeconds(5); + + /// + /// What returns, or the default - null - when + /// passes first. Runs inside the summary's transaction, under a savepoint: a statement cancelled + /// part-way leaves Postgres refusing every later one in the transaction until it is rolled back to + /// before it. + /// + internal static async Task WithinBudget(BitweenDbContext dbContext, CancellationToken deadline, + Func> query) + { + const string savepoint = "within_budget"; + var transaction = dbContext.Database.CurrentTransaction!; + await transaction.CreateSavepointAsync(savepoint); + + try + { + return await query(deadline); + } + // Whatever the provider throws for a cancelled command: Npgsql an OperationCanceledException, + // SqlClient a SqlException. Only once the deadline has passed, so a real error still surfaces. + catch (Exception) when (deadline.IsCancellationRequested) + { + await transaction.RollbackToSavepointAsync(savepoint); + return default; + } } - private Task CountCapped(IQueryable rows, string status) => - rows.WhereStatus(status, dbContext).Take(Search.CountCap + 1).CountAsync(); + private static async Task CountCapped(IQueryable rows, string status, BitweenDbContext dbContext, + CancellationToken cancellationToken) + { + var matching = rows.WhereStatus(status, dbContext); + + // Nearly every exchange succeeds, so the newest 10,001 successes are among about the newest + // 10,001 exchanges. Unordered, Postgres matched the two tables by id instead, and ids aren't in + // the order exchanges were written, so nearly every row was read from a different place on + // disk: 12s on 5.8M exchanges, against under a second this way. The rarer statuses stay + // unordered, so they keep the plan that reads their own small index. + if (status == Success) + matching = matching.OrderByDescending(r => r.StartedOn); + + return await matching.Take(Search.CountCap + 1).CountAsync(cancellationToken); + } /// /// The matching exchanges grouped by when they started, to the precision the bucket size @@ -105,7 +227,7 @@ private Task CountCapped(IQueryable rows, string status) => /// /// private static async Task> StartedCounts(IQueryable rows, - int bucketMinutes) + int bucketMinutes, CancellationToken cancellationToken) { if (bucketMinutes >= 1440) return await rows @@ -119,7 +241,7 @@ private static async Task> StartedCounts(IQueryable= 60) return await rows @@ -138,7 +260,7 @@ private static async Task> StartedCounts(IQueryable new @@ -157,7 +279,7 @@ private static async Task> StartedCounts(IQueryable diff --git a/SW.Bitween.Api/Services/SlowCounts.cs b/SW.Bitween.Api/Services/SlowCounts.cs new file mode 100644 index 00000000..fd01dcb4 --- /dev/null +++ b/SW.Bitween.Api/Services/SlowCounts.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SW.Bitween; + +/// +/// Counts too slow to answer within a request, finished on the server instead and kept for the page +/// that asks again: the Exchanges page's status counts, its chart, and with them the list's total. +/// "Processing" across millions of exchanges, or a busy day's chart, takes longer than a request's 30 +/// seconds. The page shows what it has and asks again until the rest is there. +/// +/// +/// Kept in memory, on the node that counted. In a cluster each node counts a slow view once for itself, +/// which is still once per node rather than once per refresh. +/// +public sealed class SlowCounts(IServiceScopeFactory scopeFactory, IHostApplicationLifetime lifetime, + ILogger logger) +{ + /// + /// How long an answer stands before it is counted again. It is still given out while the new one is + /// worked out. A failed count is tried again after as long. + /// + internal static readonly TimeSpan KeepFor = TimeSpan.FromMinutes(5); + + /// An answer nobody has asked for in this long is dropped. + private static readonly TimeSpan ForgetAfter = TimeSpan.FromMinutes(30); + + /// The time one count gets on the database, where a request's commands get 30 seconds. + private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(10); + + /// + /// How many count at once on a node. Each is a long read of the largest tables, and a burst of slow + /// views mustn't take the database from the exchanges themselves; the rest wait their turn. + /// + private const int AtOnce = 2; + + private readonly ConcurrentDictionary entries = new(); + private readonly SemaphoreSlim places = new(AtOnce, AtOnce); + + /// + /// Whether is one counted here, given out as - the + /// default until the first count of it is in. Counted again in the background once its answer is + /// older than . + /// + public bool TryGet(string key, Func> count, out T answer) + { + answer = default; + if (!entries.TryGetValue(key, out var entry)) + return false; + + if (entry.Last is { } last) + answer = (T)last.Value; + Start(key, count); + return true; + } + + /// + /// Counts in the background, for a later to find. Does + /// nothing while it is already being counted, or was within . + /// + public void Start(string key, Func> count) + { + var now = DateTime.UtcNow; + var entry = entries.GetOrAdd(key, _ => new Entry()); + entry.AskedOn = now; + Forget(now); + + if (now - entry.TriedOn < KeepFor || Interlocked.Exchange(ref entry.Counting, 1) == 1) + return; + + _ = Task.Run(async () => + { + var stopping = lifetime.ApplicationStopping; + try + { + await places.WaitAsync(stopping); + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.SetCommandTimeout(CommandTimeout); + entry.Last = new Kept(await count(dbContext, stopping)); + } + finally + { + places.Release(); + } + } + catch (OperationCanceledException) when (stopping.IsCancellationRequested) + { + } + catch (Exception ex) + { + logger.LogWarning(ex, "A count left to finish in the background failed. It is tried again in {Minutes} minutes.", + KeepFor.TotalMinutes); + } + finally + { + entry.TriedOn = DateTime.UtcNow; + Interlocked.Exchange(ref entry.Counting, 0); + } + }); + } + + private void Forget(DateTime now) + { + foreach (var (key, entry) in entries) + if (entry.Counting == 0 && now - entry.AskedOn > ForgetAfter) + entries.TryRemove(key, out _); + } + + private sealed class Entry + { + public volatile Kept Last; + public int Counting; + public DateTime TriedOn; + public DateTime AskedOn; + } + + private sealed record Kept(object Value); +} diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index 84737102..6d711d93 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -366,6 +366,7 @@ await Task.WhenAll(_postgres.StartAsync(), _rabbitMq.StartAsync(), _mailHog.Star services.AddSingleton(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs index 9383b41d..93dc5dfc 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeFilterOperatorTests.cs @@ -260,7 +260,7 @@ private static async Task Search(AsyncServiceScope scope, params strin { var (db, xs) = Services(scope); var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser()) - .Handle(new SearchyRequest(filters) { PageSize = 50 }); + .Handle(new SearchyRequest(filters) { PageSize = 50, CountRows = true }); Assert.Equal(response.Result.Count(), response.TotalCount); return response.Result.Select(r => r.Id).Order().ToArray(); } @@ -276,6 +276,7 @@ private static async Task Select(AsyncServiceScope scope, params strin private static async Task Summarise(AsyncServiceScope scope, params string[] filters) { var db = scope.ServiceProvider.GetRequiredService(); - return (XchangeSummary)await new Summary(db, scope.Superuser()).Handle(new SearchyRequest(filters)); + return (XchangeSummary)await new Summary(db, scope.Superuser(), scope.ServiceProvider.GetRequiredService()) + .Handle(new SearchyRequest(filters)); } } diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs index bcaecc87..4eebb683 100644 --- a/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -63,7 +65,8 @@ await db.Set().Where(x => x.Id == id) private static async Task Summarise(AsyncServiceScope scope, params string[] filters) { var db = scope.ServiceProvider.GetRequiredService(); - return (XchangeSummary)await new Summary(db, scope.Superuser()).Handle(new SearchyRequest(filters)); + return (XchangeSummary)await new Summary(db, scope.Superuser(), scope.ServiceProvider.GetRequiredService()) + .Handle(new SearchyRequest(filters)); } private static async Task> Search(AsyncServiceScope scope, params string[] filters) @@ -71,7 +74,7 @@ private static async Task> Search(AsyncServiceScope scope, params s var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser()) - .Handle(new SearchyRequest(filters) { PageSize = 50 }); + .Handle(new SearchyRequest(filters) { PageSize = 50, CountRows = true }); Assert.Equal(response.Result.Count(), response.TotalCount); return response.Result.Select(r => r.Id).ToList(); } @@ -176,6 +179,7 @@ public async Task Summary_counts_every_status_and_ignores_a_status_filter() Assert.Equal(1, summary.Counts.Success); Assert.Equal(1, summary.Counts.BadResponse); Assert.Equal(2, summary.Counts.Failed); + Assert.False(summary.Counting); // No date range, so nothing to divide into buckets. Assert.Empty(summary.Buckets); @@ -187,6 +191,97 @@ public async Task Summary_counts_every_status_and_ignores_a_status_filter() Assert.Equal(1, narrowed.Counts.Failed); } + [Fact] + public async Task A_query_past_its_budget_is_cut_off_and_the_summary_carries_on() + { + await using var scope = fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await db.Database.BeginTransactionAsync(); + + // Stopped on the database when the budget runs out, rather than left to finish. + var watch = Stopwatch.StartNew(); + using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + var slow = await Summary.WithinBudget(db, deadline.Token, + async ct => (int?)await db.Database.SqlQueryRaw("SELECT 1 AS \"Value\" FROM pg_sleep(10)").SingleAsync(ct)); + Assert.Null(slow); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(5), $"Cut off after {watch.Elapsed}."); + + // Whatever is still to count under the same budget isn't started. + Assert.Null(await Summary.WithinBudget(db, deadline.Token, + async ct => (int?)await db.Set().CountAsync(ct))); + + // The transaction still answers: without going back to before the cancelled statement, + // Postgres refuses everything after it. + Assert.NotNull(await Summary.WithinBudget(db, CancellationToken.None, + async ct => (int?)await db.Set().CountAsync(ct))); + } + + [Fact] + public async Task A_count_left_to_the_background_is_given_out_once_it_is_in() + { + var slowCounts = fixture.App.Services.GetRequiredService(); + var key = $"test:{Guid.NewGuid():N}"; + TimeSpan? commandTimeout = null; + async Task Count(BitweenDbContext db, CancellationToken ct) + { + commandTimeout = db.Database.GetCommandTimeout() is { } seconds ? TimeSpan.FromSeconds(seconds) : null; + return await db.Database.SqlQueryRaw("SELECT 42 AS \"Value\" FROM pg_sleep(1)").SingleAsync(ct); + } + + // Never too slow before, so the caller works it out itself. + Assert.False(slowCounts.TryGet(key, Count, out _)); + + // Too slow now: on its way, and nothing to give out yet. + slowCounts.Start(key, Count); + Assert.True(slowCounts.TryGet(key, Count, out var meanwhile)); + Assert.Null(meanwhile); + + var answer = (int?)null; + for (var waited = Stopwatch.StartNew(); answer == null && waited.Elapsed < TimeSpan.FromSeconds(20);) + { + await Task.Delay(100); + slowCounts.TryGet(key, Count, out answer); + } + Assert.Equal(42, answer); + // Not held to a request's 30 seconds. + Assert.Equal(TimeSpan.FromMinutes(10), commandTimeout); + } + + [Fact] + public void A_view_since_a_moment_is_the_same_view_a_little_later_and_a_fixed_range_is_kept_exact() + { + var now = new DateTime(2026, 11, 2, 12, 0, 0, DateTimeKind.Utc); + string Since(DateTime from) => $"StartedOn:6:{from:O}"; + + // "Last 24 hours" asked twice, 15 seconds apart. + Assert.Equal( + Summary.ViewKey(new SearchyRequest([Since(now.AddDays(-1)), "DocumentId:1:5"]), now), + Summary.ViewKey(new SearchyRequest(["DocumentId:1:5", Since(now.AddDays(-1).AddSeconds(15))]), now.AddSeconds(15))); + Assert.NotEqual( + Summary.ViewKey(new SearchyRequest([Since(now.AddDays(-1))]), now), + Summary.ViewKey(new SearchyRequest([Since(now.AddHours(-1))]), now)); + + // With an end, the range doesn't move, so its dates are the view. + var fixedRange = new SearchyRequest([Since(now.AddDays(-2)), $"StartedOn:8:{now.AddDays(-1):O}"]); + Assert.Equal(Summary.ViewKey(fixedRange, now), Summary.ViewKey(fixedRange, now.AddHours(1))); + Assert.DoesNotContain("since", Summary.ViewKey(fixedRange, now)); + } + + [Fact] + public async Task Search_with_count_false_leaves_the_total_out() + { + await using var scope = fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + var seeded = await OneOfEach(db, xs, "Search Without Count Doc"); + + var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser()) + .Handle(new SearchyRequest($"filter=DocumentId:1:{seeded.DocumentId}&count=false") { PageSize = 50 }); + + Assert.Equal(5, response.Result.Count()); + Assert.Equal(0, response.TotalCount); + } + [Fact] public async Task Summary_buckets_a_range_by_status_zero_filled_and_aligned_to_the_clock() { diff --git a/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs new file mode 100644 index 00000000..ca6f132d --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.Designer.cs @@ -0,0 +1,4209 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SW.Bitween.Model; +using SW.Bitween.PgSql; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20261102124651_BadResponseIndex")] + partial class BadResponseIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasColumnName("failed_login_count"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("LastSignInOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in_on"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("MicrosoftIdentity") + .HasColumnType("text") + .HasColumnName("microsoft_identity"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("OidcIdentity") + .HasColumnType("text") + .HasColumnName("oidc_identity"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("email_error"); + + b.Property("EmailedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("emailed_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("Purpose") + .HasColumnType("smallint") + .HasColumnName("purpose"); + + b.HasKey("Id") + .HasName("pk_account_links"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_links_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_account_links_hash"); + + b.ToTable("AccountLinks", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.Property("SupersededOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_on"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("last_used_from"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("revoked_by"); + + b.Property("RevokedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_on"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("shown"); + + b.HasKey("Id") + .HasName("pk_service_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_service_tokens_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_service_tokens_hash"); + + b.ToTable("ServiceTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("base_version"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("files_hash"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("files_json"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("language"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_adapter_draft"); + + b.HasIndex("AdapterId") + .HasDatabaseName("ix_adapter_draft_adapter_id"); + + b.ToTable("adapter_draft", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("action"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("DraftId") + .HasColumnType("integer") + .HasColumnName("draft_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_release"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_release_adapter_id_occurred_on"); + + b.ToTable("adapter_release", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("path"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_source_access"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_source_access_adapter_id_occurred_on"); + + b.ToTable("adapter_source_access", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key_name"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.HasKey("PartnerId", "KeyName") + .HasName("pk_api_key_use"); + + b.ToTable("api_key_use", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Changes") + .HasColumnType("text") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("character varying(36)") + .HasColumnName("correlation_id"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_key"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_name"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Sequence") + .HasColumnType("integer") + .HasColumnName("sequence"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("character varying(10)") + .HasColumnName("state"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_audit_entries"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_entries_correlation_id"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_audit_entries_occurred_on"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn") + .HasDatabaseName("ix_audit_entries_entity_name_entity_key_occurred_on"); + + b.ToTable("AuditEntries", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.BackgroundRequest", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Done") + .HasColumnType("integer") + .HasColumnName("done"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("label"); + + b.Property("Node") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("node"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("owner"); + + b.Property("Page") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("path"); + + b.Property("Result") + .HasColumnType("bytea") + .HasColumnName("result"); + + b.Property("ResultContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("result_content_type"); + + b.Property("ResultDisposition") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_disposition"); + + b.Property("ResultKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("result_key"); + + b.Property("ResultStatus") + .HasColumnType("integer") + .HasColumnName("result_status"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("summary"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Id") + .HasName("pk_background_request"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_background_request_started_on"); + + b.HasIndex("Owner", "StartedOn") + .HasDatabaseName("ix_background_request_owner_started_on"); + + b.ToTable("background_request", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("AcquiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_on"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("owner_node"); + + b.Property("Term") + .HasColumnType("bigint") + .HasColumnName("term"); + + b.HasKey("Id") + .HasName("pk_cluster_lease"); + + b.ToTable("cluster_lease", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("DataSources") + .HasColumnType("boolean") + .HasColumnName("data_sources"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("host"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("integer") + .HasColumnName("outbox_published_last_hour"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("resident_health"); + + b.Property("Runtimes") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtimes"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_cluster_node"); + + b.HasIndex("LastSeenOn") + .HasDatabaseName("ix_cluster_node_last_seen_on"); + + b.ToTable("cluster_node", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("access_token_expires_on"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("authorize_parameters"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("authorize_url"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("authorized_by"); + + b.Property("AuthorizedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("authorized_on"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Grant") + .HasColumnType("integer") + .HasColumnName("grant"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property("RenewingUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("renewing_until"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("scopes"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("token_auth"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("token_url"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_connections"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_connections_name"); + + b.ToTable("Connections", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("instance_key"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("value"); + + b.HasKey("AdapterId", "InstanceKey", "Name") + .HasName("pk_adapter_state"); + + b.ToTable("adapter_state", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .HasColumnType("text") + .HasColumnName("adapter_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CpuLimitSamples") + .HasColumnType("integer") + .HasColumnName("cpu_limit_samples"); + + b.Property("CpuPercentLimit") + .HasColumnType("double precision") + .HasColumnName("cpu_percent_limit"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("integer") + .HasColumnName("deduplication_window_days"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("hard_memory_limit_mb"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("LastHeartbeatOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_heartbeat_on"); + + b.Property("LastKnownState") + .HasColumnType("text") + .HasColumnName("last_known_state"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnedByNode") + .HasColumnType("text") + .HasColumnName("owned_by_node"); + + b.Property("Placement") + .HasColumnType("integer") + .HasColumnName("placement"); + + b.Property>("Properties") + .HasColumnType("hstore") + .HasColumnName("properties"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("text[]") + .HasColumnName("secret_properties"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("soft_memory_limit_mb"); + + b.HasKey("Id") + .HasName("pk_data_source"); + + b.ToTable("data_source", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("CursorColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("cursor_column"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key_column"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sql"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_data_source_statement"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_data_source_statement_work_group_id"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique() + .HasDatabaseName("ix_data_source_statement_data_source_id_name"); + + b.ToTable("data_source_statement", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("id"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("SeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("seen_on"); + + b.Property("XchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_inbound_message"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_inbound_message_data_source_id"); + + b.HasIndex("SeenOn") + .HasDatabaseName("ix_inbound_message_seen_on"); + + b.ToTable("inbound_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.Property("RunFailures") + .HasColumnType("integer") + .HasColumnName("run_failures"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DropDuplicates") + .HasColumnType("boolean") + .HasColumnName("drop_duplicates"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("json") + .HasColumnName("promoted_properties"); + + b.Property("RetiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_on"); + + b.Property("ValidationSchema") + .HasColumnType("text") + .HasColumnName("validation_schema"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Dropped") + .HasColumnType("boolean") + .HasColumnName("dropped"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("original_xchange_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.HasKey("Id") + .HasName("pk_duplicate_arrival"); + + b.HasIndex("DocumentId", "OccurredOn") + .HasDatabaseName("ix_duplicate_arrival_document_id_occurred_on"); + + b.ToTable("duplicate_arrival", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("integer") + .HasColumnName("auth_method"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FailureAnswer") + .HasColumnType("integer") + .HasColumnName("failure_answer"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_audience"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_issuer"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("jwt_partner_claim"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("partner_key_header"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Endpoint") + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property>("EndpointProperties") + .HasColumnType("hstore") + .HasColumnName("endpoint_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_bus_gateway_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.Property("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_idempotency_key"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_idempotency_key_created_on"); + + b.HasIndex("Scope", "Key") + .IsUnique() + .HasDatabaseName("ix_idempotency_key_scope_key"); + + b.ToTable("idempotency_key", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("Role") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Runtime") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("runtime"); + + b.Property("Calls") + .HasColumnType("integer") + .HasColumnName("calls"); + + b.Property("Crashes") + .HasColumnType("integer") + .HasColumnName("crashes"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failures") + .HasColumnType("integer") + .HasColumnName("failures"); + + b.Property("LimitStops") + .HasColumnType("integer") + .HasColumnName("limit_stops"); + + b.Property("Timeouts") + .HasColumnType("integer") + .HasColumnName("timeouts"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime") + .HasName("pk_adapter_stats"); + + b.ToTable("adapter_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("Scope") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("scope"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope_key"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("fingerprint"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("sample"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint") + .HasName("pk_error_stats"); + + b.ToTable("error_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("BadResponse") + .HasColumnType("integer") + .HasColumnName("bad_response"); + + b.Property("BytesIn") + .HasColumnType("bigint") + .HasColumnName("bytes_in"); + + b.Property("BytesOut") + .HasColumnType("bigint") + .HasColumnName("bytes_out"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DbCommands") + .HasColumnType("bigint") + .HasColumnName("db_commands"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failed") + .HasColumnType("integer") + .HasColumnName("failed"); + + b.Property("FailedAtHandler") + .HasColumnType("integer") + .HasColumnName("failed_at_handler"); + + b.Property("FailedAtMapper") + .HasColumnType("integer") + .HasColumnName("failed_at_mapper"); + + b.Property("ManualRetries") + .HasColumnType("integer") + .HasColumnName("manual_retries"); + + b.Property("PayloadParses") + .HasColumnType("bigint") + .HasColumnName("payload_parses"); + + b.Property("Retries") + .HasColumnType("integer") + .HasColumnName("retries"); + + b.Property("Succeeded") + .HasColumnType("integer") + .HasColumnName("succeeded"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId") + .HasName("pk_exchange_stats"); + + b.ToTable("exchange_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("timestamp with time zone") + .HasColumnName("at"); + + b.Property("AckRate") + .HasColumnType("double precision") + .HasColumnName("ack_rate"); + + b.Property("Consumers") + .HasColumnType("integer") + .HasColumnName("consumers"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint") + .HasColumnName("dead_letter_backlog"); + + b.Property("IncomingRate") + .HasColumnType("double precision") + .HasColumnName("incoming_rate"); + + b.Property("QueueDepth") + .HasColumnType("bigint") + .HasColumnName("queue_depth"); + + b.Property("RetryBacklog") + .HasColumnType("bigint") + .HasColumnName("retry_backlog"); + + b.Property("UnhealthyConsumers") + .HasColumnType("integer") + .HasColumnName("unhealthy_consumers"); + + b.HasKey("At") + .HasName("pk_queue_snapshot"); + + b.ToTable("queue_snapshot", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_notification_channel"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_channel_name"); + + b.ToTable("notification_channel", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.PrimitiveCollection("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("detail"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("target"); + + b.HasKey("Id") + .HasName("pk_operator_action"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_operator_action_occurred_on"); + + b.ToTable("operator_action", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ClaimedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_until"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_on"); + + b.HasKey("Id") + .HasName("pk_outbox_message"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_outbox_message_created_on") + .HasFilter("published_on IS NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("outbox_message", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("login_identity"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.HasIndex("LoginIdentity") + .IsUnique() + .HasDatabaseName("ix_partner_login_identity"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.PrimitiveCollection("ExchangeIds") + .HasColumnType("text[]") + .HasColumnName("exchange_ids"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_receive_attempt"); + + b.HasIndex("SubscriptionId", "StartedOn") + .HasDatabaseName("ix_receive_attempt_subscription_id_started_on"); + + b.ToTable("receive_attempt", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("AlertMode") + .HasColumnType("smallint") + .HasColumnName("alert_mode"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_alert_override"); + + b.ToTable("retry_alert_override", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AttemptsUsed") + .HasColumnType("integer") + .HasColumnName("attempts_used"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("exhausted_notified_on"); + + b.Property("LastAttemptOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_on"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_group_usage"); + + b.ToTable("retry_group_usage", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_settings"); + + b.ToTable("Settings", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("AutoPauseAfterFailures") + .HasColumnType("integer") + .HasColumnName("auto_pause_after_failures"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ExpectTraffic") + .HasColumnType("text") + .HasColumnName("expect_traffic"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Notifications") + .HasColumnType("text") + .HasColumnName("notifications"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedAutomatically") + .HasColumnType("boolean") + .HasColumnName("paused_automatically"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("QuietSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("quiet_since"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ReceiverVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("receiver_version"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("RunOnBadResponses") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_responses"); + + b.Property("RunningSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("running_since"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("ValidatorVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("validator_version"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_subscription_data_source_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("saved_by"); + + b.Property("SavedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("saved_on"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("text") + .HasColumnName("snapshot"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_revision"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique() + .HasDatabaseName("ix_subscription_revision_subscription_id_number"); + + b.ToTable("subscription_revision", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("files_prefix"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on") + .HasDatabaseName("ix_xchange_retries_started_on") + .HasFilter("retry_for IS NOT NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex(new[] { "StartedOn" }, "ix_xchange_retries_started_on"), new[] { "Id", "RetryFor" }); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer") + .HasColumnName("channel_id"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Suppressed") + .HasColumnType("boolean") + .HasColumnName("suppressed"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.HasIndex("XchangeId") + .HasDatabaseName("ix_xchange_notification_xchange_id"); + + b.HasIndex("ChannelId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_channel_id_finished_on"); + + b.HasIndex("NotifierId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_notifier_id_finished_on"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.PrimitiveCollection("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("NodeName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node_name"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("retry_blocked_reason"); + + b.Property("RetryGroupId") + .HasColumnType("uuid") + .HasColumnName("retry_group_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("warning"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.HasIndex("RetryGroupId") + .HasDatabaseName("ix_xchange_result_retry_group_id"); + + b.HasIndex("SubscriptionId", "FinishedOn") + .HasDatabaseName("ix_xchange_result_subscription_id_finished_on") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex(new[] { "Id" }, "ix_xchange_result_failed") + .HasDatabaseName("ix_xchange_result_failed") + .HasFilter("NOT success") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("ClaimedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_on"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_retry_claim"); + + b.ToTable("xchange_retry_claim", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_links_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_service_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_data_source_statement_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_data_source_statement_work_group_work_group_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inbound_message_data_source_data_source_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .HasConstraintName("fk_bus_gateway_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("key_prefix"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "ResponseSubscription") + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_revision_subscription_subscription_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_retry_claim_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs new file mode 100644 index 00000000..6ac3e4f3 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261102124651_BadResponseIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + /// The results with a bad response, in an index of their own, so the Bad response tab counts them + /// and the dashboard finds the latest without reading every result. They are a small part of the + /// table, often none of it. Built without stopping writes to it. + /// + public partial class BadResponseIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Outside the transaction, on what can be the largest tables there are: see ConcurrentIndex. + ConcurrentIndex.Create(migrationBuilder, "xchange_result", "ix_xchange_result_bad_response", + "(id) WHERE response_bad"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ConcurrentIndex.Drop(migrationBuilder, "ix_xchange_result_bad_response"); + } + } +} diff --git a/SW.Bitween.Sdk/Model/Xchange.cs b/SW.Bitween.Sdk/Model/Xchange.cs index 7b9bcfb6..e4c1c4ec 100644 --- a/SW.Bitween.Sdk/Model/Xchange.cs +++ b/SW.Bitween.Sdk/Model/Xchange.cs @@ -356,30 +356,40 @@ public class XchangeSummary /// When the matching exchanges started, by status, in fixed buckets of /// each - contiguous and zero-filled from the start of the /// filter's date range to its end. Empty when the filter has no start date: with no range - /// there is nothing to divide into buckets but the whole history. + /// there is nothing to divide into buckets but the whole history. Also empty while they are + /// still being worked out: see . /// public List Buckets { get; set; } = new List(); /// How long each bucket is, in minutes; 0 when there are no buckets. public int BucketMinutes { get; set; } + + /// + /// True while part of this is still being worked out on the server, which takes over from a + /// request for anything slower than a few seconds: a count that is null, or the buckets of a + /// date range, are on their way. Asking again in a little while brings them. + /// + public bool Counting { get; set; } } /// /// Exchanges by status, defined exactly as the list's status filter defines them. A count of - /// 10001 means "more than 10,000": counting stops there, as the search's total does. + /// 10001 means "more than 10,000": counting stops there, as the search's total does. Null + /// means still being counted on the server, which a status rare across a large table can take + /// minutes over: see . /// public class XchangeStatusCounts { /// Still running: no result yet. - public int Processing { get; set; } + public int? Processing { get; set; } /// Finished, and the response (if any) was not marked bad. - public int Success { get; set; } + public int? Success { get; set; } /// Finished, but the response was marked bad. - public int BadResponse { get; set; } + public int? BadResponse { get; set; } - public int Failed { get; set; } + public int? Failed { get; set; } } /// One bucket of : exchanges that started in it, by status. diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 4788cf76..13a0444c 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -755,7 +755,7 @@ export interface ApiClient { ): Promise>; // - exchanges - - searchExchanges(query: ExchangeQuery): Promise>; + searchExchanges(query: ExchangeQuery, opts?: { count?: boolean }): Promise>; getExchangeBreakdown(query: ExchangeQuery): Promise; // - audit trail - diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 8343f127..30199254 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -312,9 +312,13 @@ export const exchangeMethods = { return res.data; }, - async searchExchanges(query: ExchangeQuery): Promise> { + /** + * A page of exchanges and how many match. With `count: false` the total is left out, as 0, so the + * rows come back without waiting for it: the Exchanges page has its total from the status counts. + */ + async searchExchanges(query: ExchangeQuery, { count = true }: { count?: boolean } = {}): Promise> { const [res, partnerNameById] = await Promise.all([ - get>(`/xchanges?${buildExchangeQuery(query)}`), + get>(`/xchanges?${buildExchangeQuery(query)}${count ? "" : "&count=false"}`), partnerNameMap(), ]); return { result: res.result.map((r) => toExchangeRow(r, partnerNameById)), total: res.totalCount }; @@ -330,13 +334,14 @@ export const exchangeMethods = { const res = await get(`/xchanges/summary${params.toString() ? `?${searchyQueryString(params)}` : ""}`); return { counts: { - processing: res.counts?.processing ?? 0, - success: res.counts?.success ?? 0, - badResponse: res.counts?.badResponse ?? 0, - failed: res.counts?.failed ?? 0, + processing: res.counts?.processing ?? null, + success: res.counts?.success ?? null, + badResponse: res.counts?.badResponse ?? null, + failed: res.counts?.failed ?? null, }, buckets: res.buckets ?? [], bucketMinutes: res.bucketMinutes ?? 0, + counting: res.counting ?? false, }; }, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 9e486947..7fae7d9a 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1643,11 +1643,16 @@ export interface ExchangeVolumeBucket { * and, when the filter has a start time, how they spread over that range. */ export interface ExchangeBreakdown { - /** Each capped like the list total: a value past the cap means "more than". */ - counts: Record; + /** + * Each capped like the list total: a value past the cap means "more than". Null while it is still + * being counted on the server, which a status rare across a large table can take minutes over. + */ + counts: Record; buckets: ExchangeVolumeBucket[]; /** The width of one bucket; 0 when there are none. */ bucketMinutes: number; + /** Part of it is still being worked out on the server: a later ask brings what is missing. */ + counting: boolean; } export interface Paged { diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index aaf5326b..f2155bc0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -30,6 +30,7 @@ import { COUNT_CAP, PAGE_SIZE, activeFilterCount, + allCount, filterSignature, hasTimeFilter, idsIn, @@ -47,6 +48,9 @@ import { */ const EXPORT_LIMIT = 50_000; +/** How often the counts and the chart are asked for again while the server is still working them out. */ +const COUNTING_MS = 15_000; + const exchangesCount = (n: number) => (n === 1 ? "1 exchange" : `${n.toLocaleString()} exchanges`); /** Why some of an export's exchanges come without files; said before it downloads. */ @@ -103,9 +107,11 @@ export function ExchangesPage() { // A relative range ("last hour") is worked out each time the list is asked for, so a live list // keeps showing the last hour rather than the hour before the page was opened. + // The rows alone, so they show as soon as they're found: how many match comes with the status + // counts below, which can take far longer to work out on a busy instance. const { data, isLoading, isFetching, isPlaceholderData, dataUpdatedAt, refetch } = useQuery({ queryKey: keys.exchanges.search(viewSearch(searchParams) + `&offset=${query.offset}`), - queryFn: () => api.searchExchanges(readQuery(searchParams)), + queryFn: () => api.searchExchanges(readQuery(searchParams), { count: false }), refetchInterval: refreshMs || false, placeholderData: keepPreviousData, }); @@ -118,12 +124,17 @@ export function ExchangesPage() { // With a time filter, one request answers both. Without one the list covers all time, which // the counts follow, while the chart shows the last 24 hours and says so - a histogram of // "all time" is one bar. + // + // What takes too long to count is finished on the server, so while any of it is still coming + // these ask again every 15 seconds, live refresh or not. const timed = hasTimeFilter(searchParams); const breakdownKey = viewSearch(withParams(searchParams, { status: null })); + const askAgain = (q: { state: { data?: { counting: boolean } } }) => + refreshMs || (q.state.data?.counting ? COUNTING_MS : false); const breakdown = useQuery({ queryKey: keys.exchanges.breakdown(breakdownKey), queryFn: () => api.getExchangeBreakdown(readQuery(searchParams)), - refetchInterval: refreshMs || false, + refetchInterval: askAgain, placeholderData: keepPreviousData, retry: false, }); @@ -132,7 +143,7 @@ export function ExchangesPage() { queryKey: keys.exchanges.breakdown(chartParams ? viewSearch(chartParams) : ""), queryFn: () => api.getExchangeBreakdown(readQuery(chartParams!)), enabled: !!chartParams, - refetchInterval: refreshMs || false, + refetchInterval: askAgain, placeholderData: keepPreviousData, retry: false, }); @@ -204,8 +215,11 @@ export function ExchangesPage() { const filterCount = activeFilterCount(searchParams); const rows = data?.result ?? []; - const total = data?.total ?? 0; - const totalIsCapped = total > COUNT_CAP; + // How many match: the selected status's count, or every status's together - none while they're + // still being counted, or still the last filter's. + const counts = breakdown.isPlaceholderData ? undefined : breakdown.data?.counts; + const total = counts && (query.status ? (counts[query.status] ?? undefined) : allCount(counts)); + const totalIsCapped = total !== undefined && total > COUNT_CAP; // --- the open exchange --- // In the URL, so a link can open one. A link to a single exchange (`?ids=`, as the @@ -251,7 +265,7 @@ export function ExchangesPage() { }; const isSelected = (id: string) => (allMatching ? !excluded.has(id) : selected.has(id)); const allOnPageSelected = rows.length > 0 && rows.every((r) => isSelected(r.id)); - const selectedCount = allMatching ? Math.max(0, total - excluded.size) : selected.size; + const selectedCount = allMatching ? Math.max(0, (total ?? 0) - excluded.size) : selected.size; /** What the retry acts on: the ticked rows, or the filter itself minus the unticked ones. */ const selection: BulkRetrySelection = allMatching ? { matching: query, excludeIds: [...excluded] } : { ids: [...selected] }; @@ -434,7 +448,7 @@ export function ExchangesPage() { {asked && asked.search === viewSearch(searchParams) && ( - setAsked(null)} /> + setAsked(null)} /> )} {volume && volume.buckets.length > 0 && ( @@ -540,13 +554,24 @@ export function ExchangesPage() { {/* - paging - */}
- Showing {query.offset + 1}-{query.offset + rows.length} of{" "} - {totalIsCapped ? ( - - {COUNT_CAP.toLocaleString()}+ + Showing {query.offset + 1}-{query.offset + rows.length} + {total === undefined ? ( + + {" "} + of … ) : ( - total.toLocaleString() + <> + {" "} + of{" "} + {totalIsCapped ? ( + + {COUNT_CAP.toLocaleString()}+ + + ) : ( + total.toLocaleString() + )} + )} @@ -560,9 +585,9 @@ export function ExchangesPage() { - diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx index 45e64760..381af84f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { afterEach, describe, expect, it, vi } from "vitest"; import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; @@ -176,6 +176,36 @@ describe("the exchanges list", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + it("stays up while the next page loads, and what acts on it waits for the number", async () => { + let pageIn!: () => void; + const nextPage = new Promise((resolve) => (pageIn = resolve)); + const { user } = renderApp("/exchanges?status=failed", { + handlers: [ + // Holds the second page, then leaves it to the list's own handler. + http.get(/\/api\/xchanges(\?|$)/, async ({ request }) => { + if (new URL(request.url).searchParams.get("page") === "1") await nextPage; + }), + exchanges({ total: 60 }).handler, + ...filterOptions, + ], + }); + + await selectAllMatching(user); + const selection = () => within(screen.getByText(/everything this filter matches/).parentElement!); + expect(selection().getByText("60")).toBeVisible(); + + // The first page's rows are still up: the footer's total and the selection's wait with them. + await user.click(screen.getByRole("button", { name: "Next" })); + expect(await selection().findByTitle("Still counting how many match")).toBeVisible(); + expect(screen.getAllByTitle("Still counting how many match")).toHaveLength(2); + expect(screen.getByRole("button", { name: "Retry selected…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Export files" })).toBeDisabled(); + + pageIn(); + expect(await selection().findByText("60")).toBeVisible(); + expect(screen.getByRole("button", { name: "Retry selected…" })).toBeEnabled(); + }); + it.each([ { answer: "past what rounds can take, it refuses", @@ -351,6 +381,37 @@ describe("the exchanges list", () => { expect((await tabs()).getByRole("button", { name: "All" })).toBeVisible(); }); + it("asks again while a status is still being counted, with live refresh paused too", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + let counted = false; + renderApp("/exchanges", { + handlers: [ + http.get(apiPath("/xchanges/summary"), () => + HttpResponse.json({ + counts: { processing: counted ? 7 : null, success: 0, badResponse: 0, failed: 3 }, + buckets: [], + bucketMinutes: 0, + counting: !counted, + }), + ), + exchanges({ total: 3 }).handler, + ...filterOptions, + ], + }); + const tabs = within(await screen.findByRole("group", { name: "Status" })); + expect(await tabs.findByRole("button", { name: "Failed 3" })).toBeVisible(); + + // Paused, nothing else asks again: only that the count isn't in yet. + fireEvent.change(screen.getByRole("combobox", { name: "Refresh interval" }), { target: { value: "0" } }); + counted = true; + await act(() => vi.advanceTimersByTimeAsync(15_000)); + expect(await tabs.findByRole("button", { name: "Processing 7" })).toBeVisible(); + } finally { + vi.useRealTimers(); + } + }); + it("can show only the newest attempt of each chain", async () => { const latest = (q: URLSearchParams) => q.getAll("filter").includes("LatestOnly:1:true"); const list = exchanges({ total: (q) => (latest(q) ? 30 : 40) }); diff --git a/docs/api-reference.md b/docs/api-reference.md index 45ea14bc..5b1a6300 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -97,7 +97,7 @@ All need the global values permissions: `global-values.view` to read, `.create`, | Method and path | Permission | Description | |---|---|---| | `GET /api/xchanges` | `exchanges.view` or `dashboard.view`, or signed in with `lookup=true` | Searchy. See the filters below. `totalCount` stops at 10,001; `count=false` leaves it out, as 0, so the rows come back without waiting for it. A lookup returns at most 100 exchange ids, newest first, each with its information type's name. | -| `GET /api/xchanges/summary` | `exchanges.view` or `dashboard.view` | The search's filters, any status filter ignored, as `counts` per status (`processing`, `success`, `badResponse`, `failed`), each capped at 10,001 like the search's total. With a start date, also `buckets` of when they started and `bucketMinutes`. What isn't worked out within 5 seconds is finished in the background, for up to 10 minutes and at most 2 at a time per node, and kept 5 minutes: until then its count is `null` or the buckets are empty, and `counting` is `true`. Asking again brings it. | +| `GET /api/xchanges/summary` | `exchanges.view` or `dashboard.view` | The search's filters, any status filter ignored, as `counts` per status (`processing`, `success`, `badResponse`, `failed`), each capped at 10,001 like the search's total. With a start date, also `buckets` of when they started and `bucketMinutes`. What isn't worked out within 5 seconds is finished in the background, at most 2 at a time per node and each with up to 10 minutes on the database, and kept 5 minutes: until then its count is `null` or the buckets are empty, and `counting` is `true`. Asking again brings it. One nobody has asked about for 2 minutes when its turn comes is dropped until asked again. | | `POST /api/xchangequeries` | `exchanges.view` | Turn a question into an Exchanges page filter with AI: `{ question, timeZone }` returns `{ query, answer, notes, model, sent }`. See [Asking your exchanges](configuration.md#asking-your-exchanges). | | `GET /api/xchanges/statuslist` | `exchanges.view` | Status values for filters | | `GET /api/xchanges/retrytree?id=` | `exchanges.view` | The retry chain any attempt belongs to | diff --git a/docs/operations.md b/docs/operations.md index 795a3415..02351be1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,7 +8,7 @@ The Exchanges page filters by status, subscription, partner, information type, exchange ids, correlation id, a promoted property value and a date range. Pick a promoted property to search inside its value only; any part of the value matches, ignoring case. Filters live in the URL, so a filtered view can be shared as a link. The list reloads itself every 15 seconds by default (adjustable, or paused); **Refresh**, beside the pause button, reloads the list and its counts right away. -Each status above the list shows how many exchanges it holds under the other filters, up to 10,000+. The list doesn't wait for them: its rows show at once, and how many match ("Showing 1-25 of ...") comes with the counts. A count that takes more than a few seconds, such as Processing across millions of exchanges, is finished on the server, for up to 10 minutes, and shows up once it's ready; until then that status shows no number, and the page asks again every 15 seconds. Such a count is kept for 5 minutes, then counted again. The chart works the same way. +Each status above the list shows how many exchanges it holds under the other filters, up to 10,000+. The list doesn't wait for them: its rows show at once, and how many match ("Showing 1-25 of ...") comes with the counts. A count that takes more than a few seconds, such as Processing across millions of exchanges, is finished on the server and shows up once it's ready; until then that status shows no number, and the page asks again every 15 seconds. The server runs 2 such counts at a time, each for up to 10 minutes, so one can wait its turn first; one whose page was closed before its turn is dropped. A finished count is kept for 5 minutes, then counted again. The chart works the same way. - Search by **promoted property** to find a business document, such as an order number. - Search by **correlation id** to see every exchange in one flow, including exchanges created from responses.