using AIStudio.Settings.DataModel; namespace AIStudio.Tools.Services; /// /// Keeps what the servers of ERI data sources answered to the check of their security requirements. /// /// /// One message asks this several times: Semantic Search checks the data sources when it is /// offered and again before every search, and the classic RAG process checks them with every /// message. Each check signs in, and a server which limits sign-ins or sessions would start /// refusing. A check which is still running is joined rather than started a second time.

/// The server's operator may change the requirements at any time, so an answer is kept for one /// minute only. That covers the rounds of one answer, while a change still takes effect soon. A /// server which did not answer is asked again sooner: it may well be back, and until then, its /// data source is not searched anyway. A changed configuration, e.g., another server or another /// account, asks anew right away.

/// The minute holds for an answer which lets the data reach any provider as well, and that is a /// deliberate trade-off. When an operator opened a server by mistake and corrects it, every AI /// Studio follows within that minute rather than with its next check: until then, a chat with a /// cloud provider may still search the server. Nothing outlasts the running app, though, so no /// answer, however open, sticks for longer.

/// Whenever a check starts, the expired ones are dropped, so that data sources which were deleted /// or changed do not pile up. ///
/// Signs in to the server of a data source and asks for its requirements. /// Tells the time the checks are started at. internal sealed class ERISecurityCheckCache(Func> fetch, TimeProvider timeProvider) { /// /// How long the answer of an ERI server to the check of its security requirements is used. /// private static readonly TimeSpan ANSWER_LIFETIME = TimeSpan.FromMinutes(1); /// /// How long an ERI server which did not answer the check is not asked again, counted from the start of the check. /// private static readonly TimeSpan NO_ANSWER_LIFETIME = TimeSpan.FromSeconds(15); /// /// A check of an ERI server, which may still be running, together with the configuration it was started with. /// private readonly record struct SecurityCheck(DataSourceERI_V1 DataSource, Task Result, DateTimeOffset StartedAt); private readonly Lock checksLock = new(); private readonly Dictionary checks = new(StringComparer.Ordinal); /// /// What the server of an ERI data source requires, asked anew only once the last answer expired. /// /// The ERI data source. /// Whether the server answered, and its requirements when it did. public Task GetAsync(DataSourceERI_V1 source) { lock (this.checksLock) { var now = timeProvider.GetUtcNow(); if (this.checks.TryGetValue(source.Id, out var known) && known.DataSource.Equals(source) && IsValid(known.Result, now - known.StartedAt)) return known.Result; this.RemoveExpired(now); var check = new SecurityCheck(source, fetch(source), now); this.checks[source.Id] = check; return check.Result; } } /// /// How many checks are kept right now, expired ones included. /// internal int Count { get { lock (this.checksLock) return this.checks.Count; } } /// /// Whether a check of an ERI server may still be used instead of asking the server again. /// /// The check, which may still be running. /// How long ago the check was started. /// True when the check is still running or its answer has not expired yet. internal static bool IsValid(Task result, TimeSpan age) { if (!result.IsCompleted) return true; if (!result.IsCompletedSuccessfully) return false; return age < (result.Result.IsReachable ? ANSWER_LIFETIME : NO_ANSWER_LIFETIME); } private void RemoveExpired(DateTimeOffset now) { var expiredIds = this.checks .Where(pair => !IsValid(pair.Value.Result, now - pair.Value.StartedAt)) .Select(pair => pair.Key) .ToList(); foreach (var id in expiredIds) this.checks.Remove(id); } }