using AIStudio.Settings;
namespace AIStudio.Tools.Services.Indexing;
///
/// Asks for a run of each tracked data source at a fixed interval, and soon again for one whose
/// server was out of reach.
///
///
/// For a kind of data source whose changes nothing reports, e.g. a mailbox on a server, so an indexer
/// has to look again from time to time. The indexer hands its tracking over to this, and the
/// embedding service decides as for every indexer whether anything is tracked at all.
///
/// One timer serves all data sources of an indexer, and they come round together. The queue works
/// through them one after the other anyway. Which data sources are tracked never moves the rounds,
/// so editing the data sources cannot keep putting them off. One added later joins the next round;
/// whoever adds it queues it right away regardless.
///
/// The first round comes shortly after tracking starts rather than a whole interval later, so the
/// index is not that much behind after AI Studio started. Whether a round or a retry is due goes by
/// the clock, cf. IntervalRunSchedule; the timer only asks once per check period. A timer alone
/// would count only the time the computer is awake.
///
/// The time between two rounds.
/// The time from the start of the tracking to the first round.
/// Why the runs are asked for, as the embedding service gets to know.
/// The logger of the embedding service.
internal sealed class IntervalRunRequester(TimeSpan interval, TimeSpan firstRoundDelay, DataSourceEmbeddingRefreshMode refreshMode, ILogger logger) : IDisposable
{
///
/// How often the timer asks what is due. After the computer woke up, a round comes no later than this.
///
private static readonly TimeSpan CHECK_PERIOD = TimeSpan.FromMinutes(1);
private readonly Lock stateLock = new();
private readonly IntervalRunSchedule schedule = new(interval, firstRoundDelay, CHECK_PERIOD);
private Func? requestRun;
private Timer? timer;
///
/// Tracks exactly the given data sources from now on.
///
/// The data sources to track.
/// Asks the embedding service for a run of the data source with the given id.
public void Track(IReadOnlyCollection dataSources, Func requestRunCallback)
{
lock (this.stateLock)
{
this.schedule.Track(dataSources.Select(dataSource => dataSource.Id), DateTimeOffset.UtcNow);
this.requestRun = requestRunCallback;
if (!this.schedule.IsTrackingAny)
{
this.StopTimer();
return;
}
//
// The first check comes when the first round is due, and every further one a check
// period later.
//
this.timer ??= new Timer(this.OnCheckDue, null, firstRoundDelay, CHECK_PERIOD);
}
}
///
/// Stops tracking one data source.
///
/// The id of the data source.
public void Stop(string dataSourceId)
{
lock (this.stateLock)
{
this.schedule.Stop(dataSourceId);
if (!this.schedule.IsTrackingAny)
this.StopTimer();
}
}
///
/// Stops tracking any data source.
///
public void StopAll()
{
lock (this.stateLock)
{
this.schedule.StopAll();
this.StopTimer();
}
}
///
/// Notes that the server of a data source was out of reach, so it is tried again soon.
///
///
/// Only for a network which is down or a host out of reach. A server which answered, even with a
/// refusal, is reached.
///
/// The id of the data source.
public void RecordServerOutOfReach(string dataSourceId)
{
TimeSpan? delay;
lock (this.stateLock)
delay = this.schedule.RecordServerOutOfReach(dataSourceId, DateTimeOffset.UtcNow);
if (delay is { } pause)
logger.LogInformation("The server of data source '{DataSourceId}' was out of reach. It is tried again in {Minutes} minute(s).", dataSourceId, pause.TotalMinutes);
else
logger.LogDebug("The server of data source '{DataSourceId}' was out of reach. No earlier retry is planned for it.", dataSourceId);
}
///
/// Notes that the server of a data source could be reached.
///
/// The id of the data source.
public void RecordServerReached(string dataSourceId)
{
lock (this.stateLock)
this.schedule.RecordServerReached(dataSourceId);
}
///
/// Asks for a run of every tracked data source, which is what one round does.
///
internal async Task RequestRunsAsync()
{
string[] ids;
Func? callback;
lock (this.stateLock)
{
ids = [..this.schedule.DataSourceIds];
callback = this.requestRun;
}
await this.RequestAsync(ids, callback);
}
///
/// Asks for the runs which are due now: a round, or the retries of servers which were out of reach.
///
private async Task RequestDueRunsAsync()
{
(bool IsRound, IReadOnlyList DataSourceIds) due;
Func? callback;
lock (this.stateLock)
{
due = this.schedule.TakeDue(DateTimeOffset.UtcNow);
callback = this.requestRun;
}
if (!due.IsRound)
foreach (var id in due.DataSourceIds)
logger.LogInformation("Asking again for a run of data source '{DataSourceId}', since its server was out of reach.", id);
await this.RequestAsync(due.DataSourceIds, callback);
}
///
/// Asks for a run of each of the given data sources.
///
///
/// A request which fails costs only its own data source the run.
///
private async Task RequestAsync(IReadOnlyList ids, Func? callback)
{
if (callback is null)
return;
foreach (var id in ids)
{
try
{
await callback(id, refreshMode);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not queue the data source '{DataSourceId}' for its regular run.", id);
}
}
}
///
/// Asks for what is due when the timer elapses. RequestAsync catches every failure itself.
///
private void OnCheckDue(object? state) => _ = this.RequestDueRunsAsync();
private void StopTimer()
{
this.timer?.Dispose();
this.timer = null;
}
#region Implementation of IDisposable
public void Dispose() => this.StopAll();
#endregion
}