diff --git a/SW.Bitween.Api/Resources/Xchanges/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs
index 3dc89dcc..24666c01 100644
--- a/SW.Bitween.Api/Resources/Xchanges/Search.cs
+++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs
@@ -156,8 +156,12 @@ from delayedRetry in drGroup.DefaultIfEmpty()
// as "10,000+". 36ms drops to 2.3ms unfiltered, 264ms to 25ms on Success. Anything
// filtered narrowly enough to act on still gets an exact number; only views far too
// broad to page through lose it, and 10,000 rows is 400 pages of Next.
- TotalCount = await query.AsNoTracking().Search(searchyRequest.Conditions)
- .Take(CountCap + 1).CountAsync()
+ //
+ // Not at all with count=false, as Searchy's own search does: the Exchanges page asks
+ // for its rows that way so they show at once, and has the total from the summary.
+ TotalCount = searchyRequest.CountRows
+ ? await query.AsNoTracking().Search(searchyRequest.Conditions).Take(CountCap + 1).CountAsync()
+ : 0
};
return searchyResponse;
diff --git a/SW.Bitween.Api/Resources/Xchanges/Summary.cs b/SW.Bitween.Api/Resources/Xchanges/Summary.cs
index 10a807cf..f72fd2f6 100644
--- a/SW.Bitween.Api/Resources/Xchanges/Summary.cs
+++ b/SW.Bitween.Api/Resources/Xchanges/Summary.cs
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Data;
+using System.Globalization;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using SW.Bitween.Model;
@@ -27,11 +29,16 @@ namespace SW.Bitween.Resources.Xchanges;
/// Every special filter the search understands is applied the same way, so a count is never of a
/// different set than the list it sits over.
///
+///
+/// A count or a histogram that takes longer than is finished in the background
+/// by , which keeps it for the next ask: until then it is missing from the
+/// answer, and says so.
+///
///
[HandlerName("summary")]
-public class Summary(BitweenDbContext dbContext, RequestContext requestContext) : ISearchyHandler
+public class Summary(BitweenDbContext dbContext, RequestContext requestContext, SlowCounts slowCounts) : ISearchyHandler
{
- /// The status numbers reads, in the order they are counted.
+ /// The status numbers reads.
private const string Processing = "0", Success = "1", BadResponse = "2", Failed = "3";
public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null)
@@ -47,6 +54,11 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa
foreach (var statusFilter in condition.Filters.Where(f => f.Field == "StatusFilter").ToList())
condition.Filters.Remove(statusFilter);
+ // Taken before FilteredRowsAsync takes the special filters out of the request: a count finished
+ // in the background filters again from this, with a database of its own.
+ var asked = (SearchyRequest)searchyRequest.Clone();
+ var view = ViewKey(searchyRequest, DateTime.UtcNow);
+
var rows = await XchangeFilters.FilteredRowsAsync(dbContext, searchyRequest);
// Read after FilteredRowsAsync, which is what moves the request's dates to UTC. The
@@ -58,15 +70,24 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa
// One capped count per status rather than a single grouped count: each can stop at the
// cap, as the search's total does, where a grouping has to visit every matching row. Four
// short queries over a broad filter cost far less than one exhaustive one.
- var counts = new XchangeStatusCounts
+ //
+ // All four share one budget, so the answer never waits longer than it, however many are slow.
+ // The ones with an index of their own go first; still running, which can't have one, last.
+ var counts = new XchangeStatusCounts();
+ using (var deadline = new CancellationTokenSource(Budget))
{
- Processing = await CountCapped(rows, Processing),
- Success = await CountCapped(rows, Success),
- BadResponse = await CountCapped(rows, BadResponse),
- Failed = await CountCapped(rows, Failed),
- };
+ counts.BadResponse = await Count(BadResponse, deadline.Token);
+ counts.Failed = await Count(Failed, deadline.Token);
+ counts.Success = await Count(Success, deadline.Token);
+ counts.Processing = await Count(Processing, deadline.Token);
+ }
- var summary = new XchangeSummary { Counts = counts };
+ var summary = new XchangeSummary
+ {
+ Counts = counts,
+ Counting = counts.Processing == null || counts.Success == null || counts.BadResponse == null ||
+ counts.Failed == null
+ };
if (rangeStart is not { } start)
return summary;
@@ -75,14 +96,117 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa
return summary;
var bucketMinutes = XchangeHistogram.ChooseBucketMinutes(start, end);
+ using var histogramDeadline = new CancellationTokenSource(Budget);
+ var started = await Answer($"histogram:{bucketMinutes}|{view}", histogramDeadline.Token,
+ ct => StartedCounts(rows, bucketMinutes, ct),
+ async (db, ct) => await StartedCounts(await XchangeFilters.FilteredRowsAsync(db, Copy(asked)), bucketMinutes, ct));
+ if (started == null)
+ {
+ summary.Counting = true;
+ return summary;
+ }
+
summary.BucketMinutes = bucketMinutes;
- summary.Buckets = XchangeHistogram.Fold(start, end, bucketMinutes,
- await StartedCounts(rows, bucketMinutes));
+ summary.Buckets = XchangeHistogram.Fold(start, end, bucketMinutes, started);
return summary;
+
+ Task Count(string status, CancellationToken deadline) =>
+ Answer($"count:{status}|{view}", deadline,
+ ct => CountCapped(rows, status, dbContext, ct),
+ async (db, ct) => await CountCapped(await XchangeFilters.FilteredRowsAsync(db, Copy(asked)), status, db, ct));
+ }
+
+ ///
+ /// The answer keeps for , when it is one that was too
+ /// slow before. Otherwise worked out within the budget, and when that runs
+ /// out, left to to work out : null until then.
+ ///
+ private async Task Answer(string key, CancellationToken deadline, Func> here,
+ Func> later)
+ {
+ if (slowCounts.TryGet(key, later, out var kept))
+ return kept;
+
+ var answer = await WithinBudget(dbContext, deadline, here);
+ if (answer == null)
+ slowCounts.Start(key, later);
+ return answer;
+ }
+
+ private static SearchyRequest Copy(SearchyRequest request) => (SearchyRequest)request.Clone();
+
+ ///
+ /// The filters as text, naming what was counted for . A start with no end -
+ /// what "last 24 hours" sends, a little later on every ask - is named by how far back it reaches,
+ /// to five minutes, so the asks of one page find the one count rather than each starting another.
+ ///
+ internal static string ViewKey(SearchyRequest request, DateTime now) =>
+ string.Join("|", request.Conditions.Select(condition =>
+ {
+ var openEnded = !condition.Filters.Any(f =>
+ f.Field == nameof(XchangeRow.StartedOn) && f.Rule == SearchyRule.LessThanOrEquals);
+ return string.Join("&", condition.Filters
+ .Select(f =>
+ openEnded && f.Field == nameof(XchangeRow.StartedOn) && f.Rule == SearchyRule.GreaterThanOrEquals &&
+ DateTime.TryParse(f.Value?.ToString(), CultureInfo.InvariantCulture,
+ DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var since)
+ ? $"StartedOn:since:{Math.Round((now - since).TotalMinutes / 5)}"
+ : f.ToString())
+ .OrderBy(f => f, StringComparer.Ordinal));
+ }));
+
+ /// How long the counts together, and then the histogram, may take before the rest is left to the background.
+ ///
+ /// A status that is rare across a large table can only be counted by visiting every exchange:
+ /// with no result row to look up, "processing" can't be found any other way. On millions of
+ /// exchanges that ran past the database's own time limit and failed the whole request, taking
+ /// every other tab's count with it.
+ ///
+ internal static readonly TimeSpan Budget = TimeSpan.FromSeconds(5);
+
+ ///
+ /// What returns, or the default - null - when
+ /// passes first. Runs inside the summary's transaction, under a savepoint: a statement cancelled
+ /// part-way leaves Postgres refusing every later one in the transaction until it is rolled back to
+ /// before it.
+ ///
+ internal static async Task WithinBudget(BitweenDbContext dbContext, CancellationToken deadline,
+ Func> query)
+ {
+ const string savepoint = "within_budget";
+ var transaction = dbContext.Database.CurrentTransaction!;
+ await transaction.CreateSavepointAsync(savepoint);
+
+ try
+ {
+ return await query(deadline);
+ }
+ // Whatever the provider throws for a cancelled command: Npgsql an OperationCanceledException,
+ // SqlClient a SqlException. Only once the deadline has passed: an error before it surfaces, and
+ // one after it is taken for the deadline, then met again by the count in the background, which
+ // logs it.
+ catch (Exception) when (deadline.IsCancellationRequested)
+ {
+ await transaction.RollbackToSavepointAsync(savepoint);
+ return default;
+ }
}
- private Task CountCapped(IQueryable rows, string status) =>
- rows.WhereStatus(status, dbContext).Take(Search.CountCap + 1).CountAsync();
+ private static async Task CountCapped(IQueryable rows, string status, BitweenDbContext dbContext,
+ CancellationToken cancellationToken)
+ {
+ var matching = rows.WhereStatus(status, dbContext);
+
+ // Nearly every exchange succeeds, so the newest 10,001 successes are among about the newest
+ // 10,001 exchanges. Unordered, Postgres matched the two tables by id instead, and ids aren't in
+ // the order exchanges were written, so nearly every row was read from a different place on
+ // disk: 12s on 5.8M exchanges, against under a second this way. The rarer statuses stay
+ // unordered, so they keep the plan that reads their own small index.
+ if (status == Success)
+ matching = matching.OrderByDescending(r => r.StartedOn);
+
+ return await matching.Take(Search.CountCap + 1).CountAsync(cancellationToken);
+ }
///
/// The matching exchanges grouped by when they started, to the precision the bucket size
@@ -105,7 +229,7 @@ private Task CountCapped(IQueryable rows, string status) =>
///
///
private static async Task> StartedCounts(IQueryable rows,
- int bucketMinutes)
+ int bucketMinutes, CancellationToken cancellationToken)
{
if (bucketMinutes >= 1440)
return await rows
@@ -119,7 +243,7 @@ private static async Task> StartedCounts(IQueryable= 60)
return await rows
@@ -138,7 +262,7 @@ private static async Task> StartedCounts(IQueryable new
@@ -157,7 +281,7 @@ private static async Task> StartedCounts(IQueryable
diff --git a/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs b/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs
index b4918127..25fbe2a2 100644
--- a/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs
+++ b/SW.Bitween.Api/Services/Retention/RetentionPlanner.cs
@@ -13,7 +13,7 @@ namespace SW.Bitween;
///
/// Works out what the retention settings do - as they stand, or as someone is about to change them - so
/// the settings page can say it before anything is saved: how long files and exchanges are kept, what
-/// the next run removes, and which exchanges are left without their files.
+/// the next run removes, and whether exchanges are kept longer than their files.
///
public class RetentionPlanner(BitweenDbContext dbContext, BitweenOptions options, StorageRetention storageRetention,
StorageAccess storageAccess)
@@ -35,8 +35,8 @@ public async Task Plan(RetentionProposal proposal = null, bool
var now = DateTime.UtcNow;
var files = Retention(prefix, rules);
- // Exchanges already written keep the prefix they were written under, so how many are left
- // without files depends on the prefix in force now, not on a proposed one.
+ // Exchanges already written keep the prefix they were written under, so whether they outlive
+ // their files depends on the prefix in force now, not on a proposed one.
var currentFiles = prefix == options.DocumentPrefix ? files : Retention(options.DocumentPrefix, rules);
var legacyPrefix = options.LegacyDocumentPrefix;
var legacyFiles = !string.IsNullOrEmpty(legacyPrefix) && legacyPrefix != prefix ? Retention(legacyPrefix, rules) : null;
@@ -51,29 +51,10 @@ public async Task Plan(RetentionProposal proposal = null, bool
? await ExchangeRetention.DueBefore(dbContext, now.AddDays(-days)).Take(CountCap).CountAsync()
: null;
- // As things stand now, whatever the proposal: those the next run removes are still listed until then.
- // Each exchange's files follow the rule of the prefix they were written under, which a later change
- // of prefix doesn't move; only those older than the shortest rule can have lost them.
- int? withoutFiles = currentFiles.Days == null ? null : 0;
- if (rules.Problem == null &&
- rules.Lifecycle?.Rules.Where(r => r.Enabled).Select(r => (int?)r.Days).Min() is { } shortest)
- {
- var unrecordedPrefix = options.LegacyDocumentPrefix ?? options.DocumentPrefix;
- var writtenUnder = await dbContext.Set()
- .Where(x => x.StartedOn < now.AddDays(-shortest))
- .Select(x => x.FilesPrefix).Distinct().ToListAsync();
- foreach (var recorded in writtenUnder)
- {
- if (Retention(recorded ?? unrecordedPrefix, rules).Days is not { } filesDays) continue;
- var filesGone = now.AddDays(-filesDays);
- var counted = withoutFiles ?? 0;
- withoutFiles = counted + await dbContext.Set()
- .Where(x => x.FilesPrefix == recorded && x.StartedOn < filesGone)
- .Take(CountCap - counted).CountAsync();
- if (withoutFiles >= CountCap) break;
- }
- }
-
+ // How many exchanges have lost their files isn't counted: which rule applies depends on the
+ // prefix each was written under, and finding those meant reading every exchange older than the
+ // bucket's shortest rule - a day, with the standard temp1/ rule - which took over 30s on 5.8M
+ // exchanges and failed the page. When the oldest started is given instead.
var oldest = await dbContext.Set().OrderBy(x => x.StartedOn)
.Select(x => (DateTime?)x.StartedOn).FirstOrDefaultAsync();
@@ -106,7 +87,6 @@ public async Task Plan(RetentionProposal proposal = null, bool
Cron = cron,
NextRun = nextRun,
DueNow = dueNow,
- WithoutFiles = withoutFiles,
CountCap = CountCap,
Oldest = oldest
}
@@ -145,7 +125,7 @@ private List Notices(RetentionStatus status, PrefixRetention cu
if (currentFiles.Days is { } filesDays && (days == 0 || days > filesDays))
warnings.Add(Warning("OUTLIVE_FILES",
$"Exchanges older than {filesDays} days are listed without their files: they can't be opened " +
- $"or retried. {(exchanges.WithoutFiles == 1 ? "There is 1" : $"There are {Count(exchanges.WithoutFiles, exchanges.CountCap)}")} right now." +
+ "or retried." +
(newOutliveFiles ? $" New exchanges lose theirs after {newFilesDays} days." : "")));
else if (newOutliveFiles)
warnings.Add(Warning("OUTLIVE_FILES",
diff --git a/SW.Bitween.Api/Services/SlowCounts.cs b/SW.Bitween.Api/Services/SlowCounts.cs
new file mode 100644
index 00000000..2479f689
--- /dev/null
+++ b/SW.Bitween.Api/Services/SlowCounts.cs
@@ -0,0 +1,140 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace SW.Bitween;
+
+///
+/// Counts too slow to answer within a request, finished on the server instead and kept for the page
+/// that asks again: the Exchanges page's status counts, its chart, and with them the list's total.
+/// "Processing" across millions of exchanges, or a busy day's chart, takes longer than a request's 30
+/// seconds. The page shows what it has and asks again until the rest is there.
+///
+///
+/// Kept in memory, on the node that counted. In a cluster each node counts a slow view once for itself,
+/// which is still once per node rather than once per refresh.
+///
+public sealed class SlowCounts(IServiceScopeFactory scopeFactory, IHostApplicationLifetime lifetime,
+ ILogger logger)
+{
+ ///
+ /// How long an answer stands before it is counted again. It is still given out while the new one is
+ /// worked out. A failed count is tried again after as long, and gives out nothing meanwhile.
+ ///
+ internal static readonly TimeSpan KeepFor = TimeSpan.FromMinutes(5);
+
+ /// An answer nobody has asked for in this long is dropped.
+ private static readonly TimeSpan ForgetAfter = TimeSpan.FromMinutes(30);
+
+ ///
+ /// A count whose turn comes when nobody has asked for it in this long is left uncounted: its page was
+ /// closed or moved to other filters. A page still waiting asks at least every minute.
+ ///
+ private static readonly TimeSpan StillWanted = TimeSpan.FromMinutes(2);
+
+ /// The time one count gets on the database, where a request's commands get 30 seconds.
+ private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(10);
+
+ ///
+ /// How many count at once on a node. Each is a long read of the largest tables, and a burst of slow
+ /// views mustn't take the database from the exchanges themselves; the rest wait their turn.
+ ///
+ private const int AtOnce = 2;
+
+ private readonly ConcurrentDictionary entries = new();
+ private readonly SemaphoreSlim places = new(AtOnce, AtOnce);
+
+ ///
+ /// Whether is one counted here, given out as - the
+ /// default until the first count of it is in. Counted again in the background once its answer is
+ /// older than .
+ ///
+ public bool TryGet(string key, Func> count, out T answer)
+ {
+ answer = default;
+ if (!entries.TryGetValue(key, out var entry))
+ return false;
+
+ if (entry.Last is { } last)
+ answer = (T)last.Value;
+ Start(key, count);
+ return true;
+ }
+
+ ///
+ /// Counts in the background, for a later to find. Does
+ /// nothing while it is already being counted, or was within .
+ ///
+ public void Start(string key, Func> count)
+ {
+ var now = DateTime.UtcNow;
+ var entry = entries.GetOrAdd(key, _ => new Entry());
+ entry.AskedOn = now;
+ Forget(now);
+
+ if (now - entry.TriedOn < KeepFor || Interlocked.Exchange(ref entry.Counting, 1) == 1)
+ return;
+
+ _ = Task.Run(async () =>
+ {
+ var stopping = lifetime.ApplicationStopping;
+ try
+ {
+ await places.WaitAsync(stopping);
+ try
+ {
+ // Left without trying, so it starts again as soon as its page is back.
+ if (DateTime.UtcNow - entry.AskedOn > StillWanted)
+ return;
+
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ dbContext.Database.SetCommandTimeout(CommandTimeout);
+ entry.Last = new Kept(await count(dbContext, stopping));
+ entry.TriedOn = DateTime.UtcNow;
+ }
+ finally
+ {
+ places.Release();
+ }
+ }
+ catch (OperationCanceledException) when (stopping.IsCancellationRequested)
+ {
+ }
+ catch (Exception ex)
+ {
+ // The last answer can't be brought up to date, so it isn't given out as if it were.
+ entry.Last = null;
+ entry.TriedOn = DateTime.UtcNow;
+ logger.LogWarning(ex, "A count left to finish in the background failed. It is tried again in {Minutes} minutes.",
+ KeepFor.TotalMinutes);
+ }
+ finally
+ {
+ Interlocked.Exchange(ref entry.Counting, 0);
+ }
+ });
+ }
+
+ private void Forget(DateTime now)
+ {
+ foreach (var (key, entry) in entries)
+ if (entry.Counting == 0 && now - entry.AskedOn > ForgetAfter)
+ entries.TryRemove(key, out _);
+ }
+
+ private sealed class Entry
+ {
+ public volatile Kept Last;
+ public int Counting;
+ public DateTime TriedOn;
+ public DateTime AskedOn;
+ }
+
+ private sealed record Kept(object Value);
+}
diff --git a/SW.Bitween.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/ExchangeRetentionTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs
index 1387b566..b97f9225 100644
--- a/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs
+++ b/SW.Bitween.IntegrationTests/Tests/ExchangeRetentionTests.cs
@@ -255,9 +255,6 @@ public async Task The_preview_says_when_exchanges_outlive_their_files_and_archiv
DocumentPrefix = "temp30/docs", ExchangeRetentionDays = 20, ArchiveExchanges = true
});
Assert.DoesNotContain(shorter.Notices, n => n.Code is "OUTLIVE_FILES" or "ARCHIVE_WITHOUT_FILES");
- // How many are without files is as things stand now, whatever the proposal.
- Assert.NotNull(shorter.Exchanges.WithoutFiles);
- Assert.Equal(longer.Exchanges.WithoutFiles, shorter.Exchanges.WithoutFiles);
var forever = await planner.Plan(new RetentionProposal { DocumentPrefix = "docs/kept", ExchangeRetentionDays = 0 });
Assert.Null(forever.Files.Days);
@@ -265,28 +262,26 @@ public async Task The_preview_says_when_exchanges_outlive_their_files_and_archiv
}
[Fact]
- public async Task Exchanges_without_files_are_counted_by_the_prefix_each_was_written_under()
+ public async Task Exchanges_kept_longer_than_their_files_are_warned_of_beside_when_the_oldest_started()
{
var (_, subscription) = await Setup();
- var shortPrefix = $"temp30/{Guid.NewGuid():N}";
- var longPrefix = $"temp365/{Guid.NewGuid():N}";
- _options.DocumentPrefix = longPrefix;
+ _options.DocumentPrefix = $"temp30/{Guid.NewGuid():N}";
+ var old = await CreateExchange(subscription, "{}", new(), succeeded: true);
+ await Backdate(old.Id, 45);
+
await using var scope = fixture.CreateScope();
var planner = ActivatorUtilities.CreateInstance(scope.ServiceProvider,
ExchangeFilesTests.RetentionOver(
- new CloudFilesLifecycleRule { Id = "temp30", Prefix = "temp30/", Days = 30, Enabled = true },
- new CloudFilesLifecycleRule { Id = "temp365", Prefix = "temp365/", Days = 365, Enabled = true }));
- var before = (await planner.Plan()).Exchanges.WithoutFiles;
-
- _options.DocumentPrefix = shortPrefix;
- var underShort = await CreateExchange(subscription, "{}", new(), succeeded: true);
- _options.DocumentPrefix = longPrefix;
- var underLong = await CreateExchange(subscription, "{}", new(), succeeded: true);
- await Backdate(underShort.Id, 45);
- await Backdate(underLong.Id, 45);
-
- // Today's prefix keeps files a year, but the one written under the 30-day prefix has lost its own.
- Assert.Equal(before + 1, (await planner.Plan()).Exchanges.WithoutFiles);
+ new CloudFilesLifecycleRule { Id = "temp30", Prefix = "temp30/", Days = 30, Enabled = true }));
+
+ // Kept for ever, files for 30 days. Other tests leave old exchanges too, so the oldest is the
+ // database's, not necessarily this one.
+ var status = await planner.Plan(new RetentionProposal { ExchangeRetentionDays = 0 });
+ var oldest = await scope.ServiceProvider.GetRequiredService().Set()
+ .MinAsync(x => (DateTime?)x.StartedOn);
+ Assert.Equal(oldest, status.Exchanges.Oldest);
+ Assert.True(status.Exchanges.Oldest <= DateTime.UtcNow.AddDays(-45));
+ Assert.Single(status.Notices, n => n.Code == "OUTLIVE_FILES");
}
[Fact]
diff --git a/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs b/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs
index bcaecc87..4eebb683 100644
--- a/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs
+++ b/SW.Bitween.IntegrationTests/Tests/ExchangeSummaryTests.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -63,7 +65,8 @@ await db.Set().Where(x => x.Id == id)
private static async Task Summarise(AsyncServiceScope scope, params string[] filters)
{
var db = scope.ServiceProvider.GetRequiredService();
- return (XchangeSummary)await new Summary(db, scope.Superuser()).Handle(new SearchyRequest(filters));
+ return (XchangeSummary)await new Summary(db, scope.Superuser(), scope.ServiceProvider.GetRequiredService())
+ .Handle(new SearchyRequest(filters));
}
private static async Task> Search(AsyncServiceScope scope, params string[] filters)
@@ -71,7 +74,7 @@ private static async Task> Search(AsyncServiceScope scope, params s
var db = scope.ServiceProvider.GetRequiredService();
var xs = scope.ServiceProvider.GetRequiredService();
var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser())
- .Handle(new SearchyRequest(filters) { PageSize = 50 });
+ .Handle(new SearchyRequest(filters) { PageSize = 50, CountRows = true });
Assert.Equal(response.Result.Count(), response.TotalCount);
return response.Result.Select(r => r.Id).ToList();
}
@@ -176,6 +179,7 @@ public async Task Summary_counts_every_status_and_ignores_a_status_filter()
Assert.Equal(1, summary.Counts.Success);
Assert.Equal(1, summary.Counts.BadResponse);
Assert.Equal(2, summary.Counts.Failed);
+ Assert.False(summary.Counting);
// No date range, so nothing to divide into buckets.
Assert.Empty(summary.Buckets);
@@ -187,6 +191,97 @@ public async Task Summary_counts_every_status_and_ignores_a_status_filter()
Assert.Equal(1, narrowed.Counts.Failed);
}
+ [Fact]
+ public async Task A_query_past_its_budget_is_cut_off_and_the_summary_carries_on()
+ {
+ await using var scope = fixture.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await using var transaction = await db.Database.BeginTransactionAsync();
+
+ // Stopped on the database when the budget runs out, rather than left to finish.
+ var watch = Stopwatch.StartNew();
+ using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
+ var slow = await Summary.WithinBudget(db, deadline.Token,
+ async ct => (int?)await db.Database.SqlQueryRaw("SELECT 1 AS \"Value\" FROM pg_sleep(10)").SingleAsync(ct));
+ Assert.Null(slow);
+ Assert.True(watch.Elapsed < TimeSpan.FromSeconds(5), $"Cut off after {watch.Elapsed}.");
+
+ // Whatever is still to count under the same budget isn't started.
+ Assert.Null(await Summary.WithinBudget(db, deadline.Token,
+ async ct => (int?)await db.Set().CountAsync(ct)));
+
+ // The transaction still answers: without going back to before the cancelled statement,
+ // Postgres refuses everything after it.
+ Assert.NotNull(await Summary.WithinBudget(db, CancellationToken.None,
+ async ct => (int?)await db.Set().CountAsync(ct)));
+ }
+
+ [Fact]
+ public async Task A_count_left_to_the_background_is_given_out_once_it_is_in()
+ {
+ var slowCounts = fixture.App.Services.GetRequiredService();
+ var key = $"test:{Guid.NewGuid():N}";
+ TimeSpan? commandTimeout = null;
+ async Task Count(BitweenDbContext db, CancellationToken ct)
+ {
+ commandTimeout = db.Database.GetCommandTimeout() is { } seconds ? TimeSpan.FromSeconds(seconds) : null;
+ return await db.Database.SqlQueryRaw("SELECT 42 AS \"Value\" FROM pg_sleep(1)").SingleAsync(ct);
+ }
+
+ // Never too slow before, so the caller works it out itself.
+ Assert.False(slowCounts.TryGet(key, Count, out _));
+
+ // Too slow now: on its way, and nothing to give out yet.
+ slowCounts.Start(key, Count);
+ Assert.True(slowCounts.TryGet(key, Count, out var meanwhile));
+ Assert.Null(meanwhile);
+
+ var answer = (int?)null;
+ for (var waited = Stopwatch.StartNew(); answer == null && waited.Elapsed < TimeSpan.FromSeconds(20);)
+ {
+ await Task.Delay(100);
+ slowCounts.TryGet(key, Count, out answer);
+ }
+ Assert.Equal(42, answer);
+ // Not held to a request's 30 seconds.
+ Assert.Equal(TimeSpan.FromMinutes(10), commandTimeout);
+ }
+
+ [Fact]
+ public void A_view_since_a_moment_is_the_same_view_a_little_later_and_a_fixed_range_is_kept_exact()
+ {
+ var now = new DateTime(2026, 11, 2, 12, 0, 0, DateTimeKind.Utc);
+ string Since(DateTime from) => $"StartedOn:6:{from:O}";
+
+ // "Last 24 hours" asked twice, 15 seconds apart.
+ Assert.Equal(
+ Summary.ViewKey(new SearchyRequest([Since(now.AddDays(-1)), "DocumentId:1:5"]), now),
+ Summary.ViewKey(new SearchyRequest(["DocumentId:1:5", Since(now.AddDays(-1).AddSeconds(15))]), now.AddSeconds(15)));
+ Assert.NotEqual(
+ Summary.ViewKey(new SearchyRequest([Since(now.AddDays(-1))]), now),
+ Summary.ViewKey(new SearchyRequest([Since(now.AddHours(-1))]), now));
+
+ // With an end, the range doesn't move, so its dates are the view.
+ var fixedRange = new SearchyRequest([Since(now.AddDays(-2)), $"StartedOn:8:{now.AddDays(-1):O}"]);
+ Assert.Equal(Summary.ViewKey(fixedRange, now), Summary.ViewKey(fixedRange, now.AddHours(1)));
+ Assert.DoesNotContain("since", Summary.ViewKey(fixedRange, now));
+ }
+
+ [Fact]
+ public async Task Search_with_count_false_leaves_the_total_out()
+ {
+ await using var scope = fixture.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var xs = scope.ServiceProvider.GetRequiredService();
+ var seeded = await OneOfEach(db, xs, "Search Without Count Doc");
+
+ var response = (SearchyResponse)await new Resources.Xchanges.Search(db, xs, scope.Superuser())
+ .Handle(new SearchyRequest($"filter=DocumentId:1:{seeded.DocumentId}&count=false") { PageSize = 50 });
+
+ Assert.Equal(5, response.Result.Count());
+ Assert.Equal(0, response.TotalCount);
+ }
+
[Fact]
public async Task Summary_buckets_a_range_by_status_zero_filled_and_aligned_to_the_clock()
{
diff --git a/SW.Bitween.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