2026-05-08 15:37:34 +00:00
using System.Collections.Concurrent ;
2026-05-13 16:13:34 +00:00
using System.Diagnostics.CodeAnalysis ;
2026-08-03 15:53:31 +00:00
using System.Threading ;
2026-05-08 15:37:34 +00:00
using System.Threading.Channels ;
using AIStudio.Provider ;
using AIStudio.Settings ;
using AIStudio.Settings.DataModel ;
using AIStudio.Tools.Databases ;
2026-07-28 13:25:10 +00:00
using AIStudio.Tools.Databases.EmbeddingState ;
2026-05-27 18:02:43 +00:00
using AIStudio.Tools.Databases.VectorStore ;
2026-05-08 15:37:34 +00:00
using AIStudio.Tools.PluginSystem ;
namespace AIStudio.Tools.Services ;
2026-05-27 18:02:43 +00:00
public sealed partial class DataSourceEmbeddingService ( SettingsManager settingsManager , RustService rustService , DatabaseClientProvider databaseClientProvider , ILogger < DataSourceEmbeddingService > logger )
: BackgroundService
2026-05-08 15:37:34 +00:00
{
2026-08-03 15:53:31 +00:00
private readonly Channel < DataSourceEmbeddingQueueItem > queue = Channel . CreateUnbounded < DataSourceEmbeddingQueueItem > ( ) ;
2026-05-08 15:37:34 +00:00
private readonly ConcurrentDictionary < string , byte > queuedIds = new ( StringComparer . OrdinalIgnoreCase ) ;
2026-07-28 13:52:59 +00:00
private readonly ConcurrentDictionary < string , byte > runningIds = new ( StringComparer . OrdinalIgnoreCase ) ;
private readonly ConcurrentDictionary < string , byte > pendingQueueIds = new ( StringComparer . OrdinalIgnoreCase ) ;
2026-08-03 15:53:31 +00:00
private readonly ConcurrentDictionary < string , DataSourceRunControl > activeRuns = new ( StringComparer . OrdinalIgnoreCase ) ;
2026-05-08 15:37:34 +00:00
private readonly ConcurrentDictionary < string , DataSourceEmbeddingStatus > statuses = new ( StringComparer . OrdinalIgnoreCase ) ;
2026-07-28 13:52:59 +00:00
private readonly object queueStateLock = new ( ) ;
2026-08-03 15:53:31 +00:00
private int startupHashCheckStarted ;
private int startupHashCheckCompleted ;
2026-05-08 15:37:34 +00:00
private static string TB ( string fallbackEN ) = > I18N . I . T ( fallbackEN , typeof ( DataSourceEmbeddingService ) . Namespace , nameof ( DataSourceEmbeddingService ) ) ;
2026-07-28 13:52:59 +00:00
private enum DataSourceQueueRequestResult
{
QUEUED ,
ALREADY_QUEUED ,
RUNNING ,
RUNNING_MARKED_PENDING ,
}
2026-08-03 15:53:31 +00:00
private enum DataSourceEmbeddingRefreshMode
{
STARTUP_HASH_CHECK ,
HASH_CHECK ,
WATCHER_HASH_CHECK ,
MANUAL_RETRY ,
}
private sealed record DataSourceEmbeddingQueueItem ( string DataSourceId , DataSourceEmbeddingRefreshMode RefreshMode ) ;
private sealed record DataSourceRunControl ( CancellationTokenSource TokenSource , TaskCompletionSource < object? > Completion ) ;
2026-05-08 15:37:34 +00:00
public IReadOnlyList < DataSourceEmbeddingStatus > GetStatuses ( )
{
return this . statuses . Values
. OrderBy ( status = > status . SortOrder )
. ThenBy ( status = > status . DataSourceName , StringComparer . OrdinalIgnoreCase )
. ToList ( ) ;
}
public DataSourceEmbeddingOverview GetOverview ( )
{
2026-05-13 16:13:34 +00:00
var orderedStatuses = this . GetStatuses ( ) ;
2026-05-08 15:37:34 +00:00
var activeStatus = orderedStatuses
. FirstOrDefault ( status = > status . State is DataSourceEmbeddingState . QUEUED or DataSourceEmbeddingState . RUNNING ) ;
if ( activeStatus is not null )
{
var total = Math . Max ( activeStatus . TotalFiles , 1 ) ;
return new (
true ,
activeStatus . State ,
activeStatus . IndexedFiles ,
total ,
2026-05-13 16:13:34 +00:00
activeStatus . FailedFiles ) ;
2026-05-08 15:37:34 +00:00
}
var failedStatus = orderedStatuses
. FirstOrDefault ( status = > status . State is DataSourceEmbeddingState . FAILED | | status . FailedFiles > 0 ) ;
if ( failedStatus is not null )
2026-05-13 16:13:34 +00:00
return new ( true , DataSourceEmbeddingState . FAILED , failedStatus . IndexedFiles , failedStatus . TotalFiles , failedStatus . FailedFiles ) ;
2026-05-08 15:37:34 +00:00
2026-05-13 16:13:34 +00:00
return new ( false , DataSourceEmbeddingState . COMPLETED , 0 , 0 , 0 ) ;
2026-05-08 15:37:34 +00:00
}
public Task QueueAllInternalDataSourcesAsync ( )
2026-07-28 13:52:59 +00:00
{
return this . QueueAllInternalDataSourcesAsync ( true ) ;
}
private Task QueueAllInternalDataSourcesAsync ( bool queueAfterCurrentRun )
2026-05-08 15:37:34 +00:00
{
this . RefreshWatchers ( ) ;
2026-08-03 15:53:31 +00:00
var supportedDataSources = settingsManager . ConfigurationData . DataSources
2026-05-08 15:37:34 +00:00
. Where ( this . IsSupportedInternalDataSource )
2026-08-03 15:53:31 +00:00
. ToList ( ) ;
logger . LogInformation (
"Queueing {DataSourceCount} supported internal data source(s) for background embedding hash checks. QueueAfterCurrentRun={QueueAfterCurrentRun}." ,
supportedDataSources . Count ,
queueAfterCurrentRun ) ;
var tasks = supportedDataSources . Select ( dataSource = > this . QueueDataSourceAsync ( dataSource , queueAfterCurrentRun , DataSourceEmbeddingRefreshMode . HASH_CHECK ) ) ;
2026-05-08 15:37:34 +00:00
return Task . WhenAll ( tasks ) ;
}
2026-05-13 16:13:34 +00:00
public Task QueueAllInternalDataSourcesIfAutomaticRefreshAsync ( )
{
2026-05-27 18:02:43 +00:00
if ( ! settingsManager . ConfigurationData . DataSourceIndexing . AutomaticRefresh )
2026-05-13 16:13:34 +00:00
{
this . RefreshWatchers ( ) ;
return Task . CompletedTask ;
}
2026-08-03 15:53:31 +00:00
logger . LogDebug ( "Automatic startup embedding hash check is handled by the background service. Ignoring duplicate startup queue request." ) ;
return Task . CompletedTask ;
2026-05-13 16:13:34 +00:00
}
public void RefreshAutomaticWatchers ( )
{
2026-08-03 15:53:31 +00:00
if ( ! settingsManager . ConfigurationData . DataSourceIndexing . AutomaticRefresh )
{
Volatile . Write ( ref this . startupHashCheckCompleted , 0 ) ;
Interlocked . Exchange ( ref this . startupHashCheckStarted , 0 ) ;
this . RemoveAllWatchers ( ) ;
return ;
}
if ( Volatile . Read ( ref this . startupHashCheckCompleted ) = = 0 )
{
_ = Task . Run ( async ( ) = >
{
try
{
await this . RunInitialDataSourceHashCheckAsync ( CancellationToken . None ) ;
}
catch ( Exception exception )
{
logger . LogWarning ( exception , "Failed to run the initial data source hash check after automatic refresh was enabled." ) ;
}
} ) ;
return ;
}
2026-05-13 16:13:34 +00:00
this . RefreshWatchers ( ) ;
}
2026-07-28 18:22:37 +00:00
public bool CanRefreshDataSource ( IDataSource dataSource )
{
return this . IsSupportedInternalDataSource ( dataSource ) ;
}
public bool CanRefreshDataSource ( string dataSourceId )
{
return this . TryGetConfiguredDataSource ( dataSourceId , out var dataSource ) & &
this . CanRefreshDataSource ( dataSource ) ;
}
2026-08-03 15:53:31 +00:00
public async Task < bool > ShouldLockDataSourceIdentityAsync ( string dataSourceId , CancellationToken token = default )
{
var embeddingState = await databaseClientProvider . GetEmbeddingStateAsync ( token ) ;
if ( ! embeddingState . IsAvailable )
{
logger . LogWarning ( "Locking identity settings for data source '{DataSourceId}' because the embedding state database '{DatabaseName}' is unavailable." , dataSourceId , embeddingState . Name ) ;
return true ;
}
var manifest = await embeddingState . GetManifestAsync ( dataSourceId , token ) ;
return ! string . IsNullOrWhiteSpace ( manifest . EmbeddingProviderId )
| | ! string . IsNullOrWhiteSpace ( manifest . EmbeddingSignature )
| | ! string . IsNullOrWhiteSpace ( manifest . SourceHash )
| | manifest . VectorSize > 0
| | manifest . Files . Count > 0 ;
}
2026-07-28 13:52:59 +00:00
public Task QueueDataSourceAsync ( IDataSource dataSource )
{
2026-08-03 15:53:31 +00:00
return this . QueueDataSourceAsync ( dataSource , true , DataSourceEmbeddingRefreshMode . HASH_CHECK ) ;
2026-07-28 13:52:59 +00:00
}
2026-07-28 18:22:37 +00:00
public Task QueueDataSourceAsync ( string dataSourceId )
{
return this . TryGetConfiguredDataSource ( dataSourceId , out var dataSource )
? this . QueueDataSourceAsync ( dataSource )
: Task . CompletedTask ;
}
2026-08-03 15:53:31 +00:00
public Task RetryDataSourceAsync ( string dataSourceId )
{
return this . TryGetConfiguredDataSource ( dataSourceId , out var dataSource )
? this . QueueDataSourceAsync ( dataSource , true , DataSourceEmbeddingRefreshMode . MANUAL_RETRY )
: Task . CompletedTask ;
}
private async Task QueueDataSourceAsync ( IDataSource dataSource , bool queueAfterCurrentRun , DataSourceEmbeddingRefreshMode refreshMode )
2026-05-08 15:37:34 +00:00
{
if ( ! this . IsSupportedInternalDataSource ( dataSource ) )
return ;
this . RefreshWatchers ( ) ;
2026-08-03 15:53:31 +00:00
logger . LogDebug ( "Refreshed watcher state for data source '{DataSourceName}' ({DataSourceId})." , dataSource . Name , dataSource . Id ) ;
2026-07-28 13:52:59 +00:00
var queueRequestResult = this . TryReserveDataSourceQueueSlot ( dataSource . Id , queueAfterCurrentRun ) ;
switch ( queueRequestResult )
{
case DataSourceQueueRequestResult . ALREADY_QUEUED :
logger . LogDebug ( "Data source '{DataSourceName}' ({DataSourceId}) is already queued for background embeddings. Ignoring duplicate queue request." , dataSource . Name , dataSource . Id ) ;
return ;
2026-05-08 15:37:34 +00:00
2026-07-28 13:52:59 +00:00
case DataSourceQueueRequestResult . RUNNING :
logger . LogDebug ( "Data source '{DataSourceName}' ({DataSourceId}) is already being embedded. Ignoring duplicate queue request." , dataSource . Name , dataSource . Id ) ;
return ;
case DataSourceQueueRequestResult . RUNNING_MARKED_PENDING :
logger . LogDebug ( "Data source '{DataSourceName}' ({DataSourceId}) is already being embedded. Scheduled one follow-up embedding run." , dataSource . Name , dataSource . Id ) ;
return ;
}
2026-08-03 15:53:31 +00:00
logger . LogInformation (
"Queueing data source '{DataSourceName}' ({DataSourceId}) for background embedding hash check. RefreshMode={RefreshMode}." ,
dataSource . Name ,
dataSource . Id ,
refreshMode ) ;
2026-05-08 15:37:34 +00:00
if ( ! this . statuses . TryGetValue ( dataSource . Id , out var currentStatus ) | | currentStatus . State is not DataSourceEmbeddingState . RUNNING )
2026-07-28 18:22:37 +00:00
{
this . UpsertStatus ( this . CreateStatus (
dataSource ,
DataSourceEmbeddingState . QUEUED ,
currentStatus ? . TotalFiles ? ? 0 ,
currentStatus ? . IndexedFiles ? ? 0 ,
currentStatus ? . FailedFiles ? ? 0 ,
failures : currentStatus ? . Failures ? ? [ ] ) ) ;
}
2026-05-27 18:02:43 +00:00
logger . LogDebug ( "Upserting status for data source '{DataSourceName}' ({DataSourceId})." , dataSource . Name , dataSource . Id ) ;
2026-08-03 15:53:31 +00:00
await this . queue . Writer . WriteAsync ( new DataSourceEmbeddingQueueItem ( dataSource . Id , refreshMode ) ) ;
2026-05-27 18:02:43 +00:00
logger . LogDebug ( "Queued data source '{DataSourceName}' ({DataSourceId})." , dataSource . Name , dataSource . Id ) ;
2026-05-08 15:37:34 +00:00
}
public async Task RemoveDataSourceAsync ( IDataSource dataSource )
{
if ( ! this . IsSupportedInternalDataSource ( dataSource ) )
return ;
this . RemoveWatcher ( dataSource . Id ) ;
2026-08-03 15:53:31 +00:00
var activeRun = this . CancelActiveDataSourceRun ( dataSource ) ;
this . ClearQueuedDataSourceState ( dataSource . Id ) ;
this . statuses . TryRemove ( dataSource . Id , out _ ) ;
if ( activeRun is not null )
{
logger . LogInformation (
"Waiting for the active embedding run for deleted data source '{DataSourceName}' ({DataSourceId}) to stop before deleting persisted embeddings." ,
dataSource . Name ,
dataSource . Id ) ;
await activeRun . Completion . Task ;
}
2026-05-08 15:37:34 +00:00
this . statuses . TryRemove ( dataSource . Id , out _ ) ;
2026-07-28 13:25:10 +00:00
await this . ResetPersistedStateAsync ( dataSource . Name , dataSource . Id , null , null , CancellationToken . None ) ;
2026-08-03 15:53:31 +00:00
this . statuses . TryRemove ( dataSource . Id , out _ ) ;
2026-05-08 15:37:34 +00:00
this . PublishStatusChanged ( ) ;
}
protected override async Task ExecuteAsync ( CancellationToken stoppingToken )
{
await this . WaitForInitialSettingsAndBootstrapAsync ( stoppingToken ) ;
while ( ! stoppingToken . IsCancellationRequested )
{
2026-08-03 15:53:31 +00:00
var queueItem = await this . queue . Reader . ReadAsync ( stoppingToken ) ;
var dataSourceId = queueItem . DataSourceId ;
2026-07-28 13:52:59 +00:00
this . MarkDataSourceRunStarted ( dataSourceId ) ;
2026-05-08 15:37:34 +00:00
2026-07-28 13:52:59 +00:00
IDataSource ? dataSource = null ;
2026-05-08 15:37:34 +00:00
try
{
2026-07-28 13:52:59 +00:00
dataSource = settingsManager . ConfigurationData . DataSources
. FirstOrDefault ( source = > source . Id . Equals ( dataSourceId , StringComparison . OrdinalIgnoreCase ) ) ;
if ( dataSource is null | | ! this . IsSupportedInternalDataSource ( dataSource ) )
continue ;
2026-08-03 15:53:31 +00:00
await this . ProcessDataSourceRunAsync ( dataSource , queueItem . RefreshMode , stoppingToken ) ;
2026-05-08 15:37:34 +00:00
}
catch ( OperationCanceledException ) when ( stoppingToken . IsCancellationRequested )
{
break ;
}
catch ( Exception exception )
{
2026-07-28 13:52:59 +00:00
if ( dataSource is null )
{
logger . LogError ( exception , "Background embedding failed for data source '{DataSourceId}'." , dataSourceId ) ;
}
else
{
logger . LogError ( exception , "Background embedding failed for data source '{DataSourceName}' ({DataSourceId})." , dataSource . Name , dataSource . Id ) ;
this . UpsertStatus ( this . GetFallbackStatus ( dataSource , exception . Message ) ) ;
}
}
finally
{
await this . QueuePendingDataSourceRunAsync ( dataSourceId , stoppingToken ) ;
2026-05-08 15:37:34 +00:00
}
}
}
public override void Dispose ( )
{
2026-05-13 16:13:34 +00:00
this . DisposeWatchers ( ) ;
2026-05-08 15:37:34 +00:00
base . Dispose ( ) ;
}
2026-08-03 15:53:31 +00:00
private async Task ProcessDataSourceRunAsync ( IDataSource dataSource , DataSourceEmbeddingRefreshMode refreshMode , CancellationToken parentToken )
2026-05-08 15:37:34 +00:00
{
2026-08-03 15:53:31 +00:00
if ( ! this . TryGetConfiguredDataSource ( dataSource . Id , out var configuredDataSource ) | |
! this . IsSupportedInternalDataSource ( configuredDataSource ) )
{
logger . LogDebug (
"Skipping embedding run for data source '{DataSourceName}' ({DataSourceId}) because it is no longer configured. RefreshMode={RefreshMode}." ,
dataSource . Name ,
dataSource . Id ,
refreshMode ) ;
return ;
}
dataSource = configuredDataSource ;
var runTokenSource = CancellationTokenSource . CreateLinkedTokenSource ( parentToken ) ;
var runControl = new DataSourceRunControl (
runTokenSource ,
new TaskCompletionSource < object? > ( TaskCreationOptions . RunContinuationsAsynchronously ) ) ;
if ( ! this . activeRuns . TryAdd ( dataSource . Id , runControl ) )
{
runTokenSource . Dispose ( ) ;
logger . LogDebug (
"Data source '{DataSourceName}' ({DataSourceId}) already has an active embedding run. Skipping duplicate process request. RefreshMode={RefreshMode}." ,
dataSource . Name ,
dataSource . Id ,
refreshMode ) ;
return ;
}
try
{
await this . ProcessDataSourceAsync ( dataSource , refreshMode , runTokenSource . Token ) ;
}
catch ( OperationCanceledException ) when ( ! parentToken . IsCancellationRequested & & runTokenSource . IsCancellationRequested )
{
logger . LogInformation (
"Stopped background embeddings for data source '{DataSourceName}' ({DataSourceId}) because the data source was removed or canceled. RefreshMode={RefreshMode}." ,
dataSource . Name ,
dataSource . Id ,
refreshMode ) ;
}
finally
{
this . activeRuns . TryRemove ( dataSource . Id , out _ ) ;
runControl . Completion . TrySetResult ( null ) ;
runTokenSource . Dispose ( ) ;
}
}
private async Task ProcessDataSourceAsync ( IDataSource dataSource , DataSourceEmbeddingRefreshMode refreshMode , CancellationToken token )
{
logger . LogInformation (
"Starting background embedding hash check for data source '{DataSourceName}' ({DataSourceId}). RefreshMode={RefreshMode}." ,
dataSource . Name ,
dataSource . Id ,
refreshMode ) ;
token . ThrowIfCancellationRequested ( ) ;
2026-05-27 18:02:43 +00:00
var vectorStore = await databaseClientProvider . GetVectorStoreAsync ( token ) ;
2026-07-28 13:25:10 +00:00
var embeddingState = await databaseClientProvider . GetEmbeddingStateAsync ( token ) ;
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-05-08 15:37:34 +00:00
2026-05-27 18:02:43 +00:00
if ( ! vectorStore . IsAvailable )
2026-05-08 15:37:34 +00:00
{
2026-05-27 18:02:43 +00:00
logger . LogWarning (
2026-05-08 15:37:34 +00:00
"Skipping background embeddings for data source '{DataSourceName}' ({DataSourceId}) because the database client '{DatabaseName}' is unavailable." ,
dataSource . Name ,
dataSource . Id ,
2026-05-27 18:02:43 +00:00
vectorStore . Name ) ;
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-05-08 15:37:34 +00:00
this . UpsertStatus ( this . GetFallbackStatus ( dataSource , "The vector database is not available." ) ) ;
return ;
}
2026-07-28 13:25:10 +00:00
if ( ! embeddingState . IsAvailable )
{
logger . LogWarning (
"Skipping background embeddings for data source '{DataSourceName}' ({DataSourceId}) because the database client '{DatabaseName}' is unavailable." ,
dataSource . Name ,
dataSource . Id ,
embeddingState . Name ) ;
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-07-28 13:25:10 +00:00
this . UpsertStatus ( this . GetFallbackStatus ( dataSource , "The SQLite embedding state database is not available." ) ) ;
return ;
}
2026-05-13 16:13:34 +00:00
if ( ! this . TryResolveEmbeddingProvider ( dataSource , out var embeddingProvider ) )
2026-05-08 15:37:34 +00:00
{
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-05-08 15:37:34 +00:00
this . UpsertStatus ( this . GetFallbackStatus ( dataSource , "The selected embedding provider is not available." ) ) ;
return ;
}
2026-06-10 15:07:34 +00:00
2026-05-08 15:37:34 +00:00
2026-05-27 18:02:43 +00:00
logger . LogInformation (
2026-05-08 15:37:34 +00:00
"Using embedding provider '{EmbeddingProviderId}' with model '{EmbeddingModelId}' for data source '{DataSourceName}' ({DataSourceId})." ,
embeddingProvider . Id ,
embeddingProvider . Model . Id ,
dataSource . Name ,
dataSource . Id ) ;
2026-06-10 15:07:34 +00:00
var collectionName = this . GetCollectionName ( dataSource . Name , dataSource . Id ) ;
2026-07-28 13:25:10 +00:00
var manifest = await this . EnsureCompatibleManifestAsync ( dataSource , embeddingProvider , collectionName , vectorStore , embeddingState , token ) ;
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-05-08 15:37:34 +00:00
var inputFiles = this . GetInputFiles ( dataSource ) ;
var indexedFiles = inputFiles . Files ;
var totalFiles = indexedFiles . Count + inputFiles . FailedFiles ;
2026-05-27 18:02:43 +00:00
logger . LogInformation (
2026-05-08 15:37:34 +00:00
"Prepared data source '{DataSourceName}' ({DataSourceId}) for embedding. AccessibleFiles={AccessibleFiles}, FailedFiles={FailedFiles}, Collection='{CollectionName}'." ,
dataSource . Name ,
dataSource . Id ,
indexedFiles . Count ,
inputFiles . FailedFiles ,
2026-05-13 16:13:34 +00:00
collectionName ) ;
2026-05-08 15:37:34 +00:00
2026-08-03 15:53:31 +00:00
var metadataSnapshot = this . BuildDataSourceMetadataSnapshot ( dataSource , indexedFiles ) ;
var removedMissingFiles = await this . RemoveMissingFileEmbeddingsAsync ( vectorStore , embeddingState , dataSource , collectionName , manifest , indexedFiles , token ) ;
token . ThrowIfCancellationRequested ( ) ;
logger . LogInformation (
"Compared data source hash for '{DataSourceName}' ({DataSourceId}). StoredSourceHashPrefix={StoredSourceHashPrefix}, CurrentSourceHashPrefix={CurrentSourceHashPrefix}, StoredFileRecords={StoredFileRecords}, CurrentFiles={CurrentFiles}, RemovedMissingFiles={RemovedMissingFiles}." ,
dataSource . Name ,
dataSource . Id ,
ShortHash ( manifest . SourceHash ) ,
ShortHash ( metadataSnapshot . SourceHash ) ,
manifest . Files . Count ,
indexedFiles . Count ,
removedMissingFiles ) ;
if ( this . CanSkipDataSourceByHash ( manifest , metadataSnapshot , indexedFiles ) )
{
logger . LogInformation (
"Skipping data source '{DataSourceName}' ({DataSourceId}) because the persisted data source hash and all persisted file hashes match. RefreshMode={RefreshMode}." ,
dataSource . Name ,
dataSource . Id ,
refreshMode ) ;
token . ThrowIfCancellationRequested ( ) ;
await embeddingState . UpdateDataSourceHashAsync ( dataSource . Id , metadataSnapshot . SourceHash , token ) ;
this . UpsertStatus ( this . CreateCompletedStatus ( dataSource , totalFiles , indexedFiles . Count , inputFiles . FailedFiles , inputFiles . LastError , inputFiles . Failures ) ) ;
return ;
}
2026-05-08 15:37:34 +00:00
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-05-13 16:13:34 +00:00
this . UpsertStatus ( this . CreateStatus (
dataSource ,
2026-05-08 15:37:34 +00:00
DataSourceEmbeddingState . RUNNING ,
totalFiles ,
0 ,
inputFiles . FailedFiles ,
2026-07-28 18:22:37 +00:00
lastError : inputFiles . LastError ,
failures : inputFiles . Failures ) ) ;
2026-05-08 15:37:34 +00:00
var provider = embeddingProvider . CreateProvider ( ) ;
var skippedFiles = 0 ;
var completedFiles = 0 ;
2026-08-03 15:53:31 +00:00
var newFiles = 0 ;
var changedFiles = 0 ;
2026-05-08 15:37:34 +00:00
var failedFiles = inputFiles . FailedFiles ;
var lastError = inputFiles . LastError ;
2026-07-28 18:22:37 +00:00
var failureDetails = inputFiles . Failures . ToList ( ) ;
2026-05-08 15:37:34 +00:00
foreach ( var file in indexedFiles )
{
token . ThrowIfCancellationRequested ( ) ;
2026-08-03 15:53:31 +00:00
var fingerprint = metadataSnapshot . FileHashes [ file . FullName ] ;
2026-05-08 15:37:34 +00:00
if ( manifest . Files . TryGetValue ( file . FullName , out var existingRecord ) & &
string . Equals ( existingRecord . Fingerprint , fingerprint , StringComparison . Ordinal ) )
{
2026-05-27 18:02:43 +00:00
logger . LogDebug (
2026-08-03 15:53:31 +00:00
"Skipping unchanged file '{FilePath}' for data source '{DataSourceName}' ({DataSourceId}) because the persisted metadata hash matches. MetadataHashPrefix={MetadataHashPrefix}, LastWriteUtc={LastWriteUtc:O}, FileSize={FileSize}." ,
2026-05-08 15:37:34 +00:00
file . FullName ,
dataSource . Name ,
2026-08-03 15:53:31 +00:00
dataSource . Id ,
ShortHash ( fingerprint ) ,
file . LastWriteTimeUtc ,
file . Length ) ;
2026-05-08 15:37:34 +00:00
skippedFiles + + ;
2026-07-28 18:22:37 +00:00
this . UpsertStatus ( this . CreateStatus ( dataSource , DataSourceEmbeddingState . RUNNING , totalFiles , skippedFiles + completedFiles , failedFiles , lastError : lastError , failures : failureDetails ) ) ;
2026-05-08 15:37:34 +00:00
continue ;
}
2026-07-28 18:22:37 +00:00
this . UpsertStatus ( this . CreateStatus ( dataSource , DataSourceEmbeddingState . RUNNING , totalFiles , skippedFiles + completedFiles , failedFiles , file . Name , lastError , failureDetails ) ) ;
2026-05-08 15:37:34 +00:00
try
{
2026-05-27 18:02:43 +00:00
logger . LogInformation (
2026-08-03 15:53:31 +00:00
"Embedding file '{FilePath}' for data source '{DataSourceName}' ({DataSourceId}) because {EmbeddingReason}. CurrentMetadataHashPrefix={CurrentMetadataHashPrefix}. Progress={CompletedFiles}/{TotalFiles}." ,
2026-05-08 15:37:34 +00:00
file . FullName ,
dataSource . Name ,
dataSource . Id ,
2026-08-03 15:53:31 +00:00
GetFileEmbeddingReason ( file , fingerprint , existingRecord ) ,
ShortHash ( fingerprint ) ,
2026-05-08 15:37:34 +00:00
skippedFiles + completedFiles + 1 ,
totalFiles ) ;
var startedAtUtc = DateTime . UtcNow ;
2026-07-28 13:25:10 +00:00
var chunkCount = await this . IndexOneFileAsync ( embeddingState , vectorStore , dataSource , file , fingerprint , embeddingProvider , provider , manifest , token ) ;
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-07-28 13:25:10 +00:00
var embeddedAtUtc = DateTime . UtcNow ;
var record = new EmbeddedFileRecord (
2026-05-08 15:37:34 +00:00
fingerprint ,
file . Length ,
file . LastWriteTimeUtc ,
2026-07-28 13:25:10 +00:00
embeddedAtUtc ,
2026-05-08 15:37:34 +00:00
chunkCount ) ;
2026-07-28 13:25:10 +00:00
await embeddingState . UpsertFileAsync (
dataSource . Id ,
new EmbeddingStateFile (
file . FullName ,
file . Name ,
this . TryGetRelativePath ( dataSource , file ) ,
fingerprint ,
file . Length ,
file . LastWriteTimeUtc ,
embeddedAtUtc ,
chunkCount ) ,
token ) ;
manifest . Files [ file . FullName ] = record ;
2026-05-08 15:37:34 +00:00
completedFiles + + ;
2026-08-03 15:53:31 +00:00
if ( existingRecord is null )
newFiles + + ;
else
changedFiles + + ;
2026-05-27 18:02:43 +00:00
logger . LogInformation (
2026-05-08 15:37:34 +00:00
"Embedded file '{FilePath}' for data source '{DataSourceName}' ({DataSourceId}) successfully. Chunks={ChunkCount}, DurationMs={DurationMs}." ,
file . FullName ,
dataSource . Name ,
dataSource . Id ,
chunkCount ,
( DateTime . UtcNow - startedAtUtc ) . TotalMilliseconds ) ;
}
2026-08-03 15:53:31 +00:00
catch ( OperationCanceledException ) when ( token . IsCancellationRequested )
{
throw ;
}
2026-05-08 15:37:34 +00:00
catch ( Exception exception )
{
failedFiles + + ;
lastError = exception . Message ;
2026-07-28 18:22:37 +00:00
failureDetails . Add ( new DataSourceEmbeddingFailure ( file . FullName , exception . Message ) ) ;
2026-05-08 15:37:34 +00:00
manifest . Files . Remove ( file . FullName ) ;
2026-05-27 18:02:43 +00:00
await this . DeleteFilePointsAsync ( vectorStore , collectionName , file . FullName , token ) ;
2026-07-28 13:25:10 +00:00
await embeddingState . DeleteFileAsync ( dataSource . Id , file . FullName , token ) ;
2026-05-08 15:37:34 +00:00
2026-05-27 18:02:43 +00:00
logger . LogWarning ( exception , "Failed to embed file '{FilePath}' for data source '{DataSourceName}'." , file . FullName , dataSource . Name ) ;
2026-07-28 18:22:37 +00:00
this . UpsertStatus ( this . CreateStatus ( dataSource , DataSourceEmbeddingState . RUNNING , totalFiles , skippedFiles + completedFiles , failedFiles , file . Name , exception . Message , failureDetails ) ) ;
2026-05-08 15:37:34 +00:00
}
}
2026-08-03 15:53:31 +00:00
manifest . SourceHash = metadataSnapshot . SourceHash ;
token . ThrowIfCancellationRequested ( ) ;
await embeddingState . UpdateDataSourceHashAsync ( dataSource . Id , metadataSnapshot . SourceHash , token ) ;
token . ThrowIfCancellationRequested ( ) ;
2026-07-28 18:22:37 +00:00
this . UpsertStatus ( this . CreateCompletedStatus ( dataSource , totalFiles , skippedFiles + completedFiles , failedFiles , lastError , failureDetails ) ) ;
2026-05-27 18:02:43 +00:00
logger . LogInformation (
2026-08-03 15:53:31 +00:00
"Finished background embeddings for data source '{DataSourceName}' ({DataSourceId}). RefreshMode={RefreshMode}, Embedded={EmbeddedFiles}, New={NewFiles}, Changed={ChangedFiles}, Skipped={SkippedFiles}, RemovedMissing={RemovedMissingFiles}, Failed={FailedFiles}, Total={TotalFiles}, SourceHashPrefix={SourceHashPrefix}." ,
2026-05-08 15:37:34 +00:00
dataSource . Name ,
dataSource . Id ,
2026-08-03 15:53:31 +00:00
refreshMode ,
completedFiles ,
newFiles ,
changedFiles ,
skippedFiles ,
removedMissingFiles ,
2026-05-08 15:37:34 +00:00
failedFiles ,
2026-08-03 15:53:31 +00:00
totalFiles ,
ShortHash ( metadataSnapshot . SourceHash ) ) ;
2026-05-08 15:37:34 +00:00
}
private async Task < int > IndexOneFileAsync (
2026-07-28 13:25:10 +00:00
EmbeddingStateClient embeddingState ,
2026-06-10 15:07:34 +00:00
VectorStoreClient vectorStore ,
2026-05-08 15:37:34 +00:00
IDataSource dataSource ,
FileInfo file ,
string fingerprint ,
EmbeddingProvider embeddingProvider ,
IProvider provider ,
DataSourceEmbeddingManifest manifest ,
CancellationToken token )
{
2026-06-10 15:07:34 +00:00
var collectionName = this . GetCollectionName ( dataSource . Name , dataSource . Id ) ;
2026-05-27 18:02:43 +00:00
logger . LogDebug (
2026-05-08 15:37:34 +00:00
"Resetting stored embeddings for file '{FilePath}' in collection '{CollectionName}' before re-indexing." ,
file . FullName ,
collectionName ) ;
2026-05-27 18:02:43 +00:00
await this . DeleteFilePointsAsync ( vectorStore , collectionName , file . FullName , token ) ;
2026-05-08 15:37:34 +00:00
2026-07-29 16:47:59 +00:00
var embeddingBatchSize = embeddingProvider . EffectiveEmbeddingBatchSize ;
var batch = new List < ( string Text , int ChunkIndex ) > ( embeddingBatchSize ) ;
2026-05-08 15:37:34 +00:00
var totalChunkCount = 0 ;
2026-07-29 16:47:59 +00:00
await foreach ( var chunk in this . StreamEmbeddingChunksAsync ( file . FullName , dataSource , embeddingProvider , token ) )
2026-05-08 15:37:34 +00:00
{
batch . Add ( ( chunk , totalChunkCount ) ) ;
totalChunkCount + + ;
2026-07-29 16:47:59 +00:00
if ( batch . Count > = embeddingBatchSize )
2026-07-28 13:25:10 +00:00
await this . FlushBatchAsync ( embeddingState , vectorStore , dataSource , file , fingerprint , embeddingProvider , provider , manifest , collectionName , batch , token ) ;
2026-05-08 15:37:34 +00:00
}
if ( batch . Count > 0 )
2026-07-28 13:25:10 +00:00
await this . FlushBatchAsync ( embeddingState , vectorStore , dataSource , file , fingerprint , embeddingProvider , provider , manifest , collectionName , batch , token ) ;
2026-05-08 15:37:34 +00:00
if ( totalChunkCount = = 0 )
throw new InvalidOperationException ( $"The file '{file.Name}' did not yield any text chunks." ) ;
2026-05-27 18:02:43 +00:00
logger . LogDebug (
2026-05-08 15:37:34 +00:00
"Generated {ChunkCount} chunks for file '{FilePath}' in data source '{DataSourceName}' ({DataSourceId})." ,
totalChunkCount ,
file . FullName ,
dataSource . Name ,
dataSource . Id ) ;
return totalChunkCount ;
}
private async Task FlushBatchAsync (
2026-07-28 13:25:10 +00:00
EmbeddingStateClient embeddingState ,
2026-06-10 15:07:34 +00:00
VectorStoreClient vectorStore ,
2026-05-08 15:37:34 +00:00
IDataSource dataSource ,
FileInfo file ,
string fingerprint ,
EmbeddingProvider embeddingProvider ,
IProvider provider ,
DataSourceEmbeddingManifest manifest ,
string collectionName ,
List < ( string Text , int ChunkIndex ) > batch ,
CancellationToken token )
{
2026-05-27 18:02:43 +00:00
logger . LogDebug (
2026-05-08 15:37:34 +00:00
"Requesting embeddings for batch of {ChunkCount} chunks from file '{FilePath}' in data source '{DataSourceName}' ({DataSourceId})." ,
batch . Count ,
file . FullName ,
dataSource . Name ,
dataSource . Id ) ;
var texts = batch . Select ( item = > item . Text ) . ToList ( ) ;
2026-07-28 15:36:06 +00:00
IReadOnlyList < IReadOnlyList < float > > vectors ;
try
{
vectors = await provider . EmbedTextAsync ( embeddingProvider . Model , settingsManager , token , texts ) ;
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-07-28 15:36:06 +00:00
}
catch ( OperationCanceledException ) when ( token . IsCancellationRequested )
{
throw ;
}
catch ( Exception exception )
{
throw new InvalidOperationException ( $"The embedding provider failed to embed {batch.Count} chunk(s) for file '{file.Name}'. Provider message: {exception.Message}" , exception ) ;
}
2026-05-08 15:37:34 +00:00
if ( vectors . Count ! = batch . Count )
throw new InvalidOperationException ( $"The embedding provider returned {vectors.Count} vectors for {batch.Count} text chunks." ) ;
var vectorSize = vectors . FirstOrDefault ( ) ? . Count ? ? 0 ;
if ( vectorSize < = 0 )
throw new InvalidOperationException ( "The embedding provider returned an empty vector." ) ;
if ( manifest . VectorSize > 0 & & manifest . VectorSize ! = vectorSize )
throw new InvalidOperationException ( $"The embedding vector size changed from {manifest.VectorSize} to {vectorSize}. Please re-save the data source to trigger a clean re-index." ) ;
if ( manifest . VectorSize = = 0 )
{
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-05-08 15:37:34 +00:00
manifest . VectorSize = vectorSize ;
2026-05-27 18:02:43 +00:00
await this . EnsureCollectionExistsAsync ( vectorStore , collectionName , vectorSize , token ) ;
2026-07-28 13:25:10 +00:00
await embeddingState . UpdateVectorSizeAsync ( dataSource . Id , vectorSize , token ) ;
2026-05-27 18:02:43 +00:00
logger . LogInformation (
2026-05-08 15:37:34 +00:00
"Created embedding collection '{CollectionName}' with vector size {VectorSize} for data source '{DataSourceName}' ({DataSourceId})." ,
collectionName ,
vectorSize ,
dataSource . Name ,
dataSource . Id ) ;
}
2026-08-03 15:53:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
2026-05-08 15:37:34 +00:00
await this . UpsertPointsAsync (
2026-05-27 18:02:43 +00:00
vectorStore ,
2026-05-08 15:37:34 +00:00
collectionName ,
dataSource ,
file ,
fingerprint ,
batch ,
vectors ,
this . TryGetRelativePath ( dataSource , file ) ,
token ) ;
2026-05-27 18:02:43 +00:00
logger . LogDebug (
2026-05-08 15:37:34 +00:00
"Stored {ChunkCount} embedded chunks for file '{FilePath}' in collection '{CollectionName}'." ,
batch . Count ,
file . FullName ,
collectionName ) ;
batch . Clear ( ) ;
}
2026-06-10 15:07:34 +00:00
private async Task EnsureCollectionExistsAsync ( VectorStoreClient vectorStore , string collectionName , int vectorSize , CancellationToken token )
2026-05-08 15:37:34 +00:00
{
2026-05-27 18:02:43 +00:00
await vectorStore . EnsureVectorStoreExists ( collectionName , vectorSize , token ) ;
2026-05-08 15:37:34 +00:00
}
private async Task UpsertPointsAsync (
2026-06-10 15:07:34 +00:00
VectorStoreClient vectorStore ,
2026-05-08 15:37:34 +00:00
string collectionName ,
IDataSource dataSource ,
FileInfo file ,
string fingerprint ,
IReadOnlyList < ( string Text , int ChunkIndex ) > batch ,
IReadOnlyList < IReadOnlyList < float > > vectors ,
string relativePath ,
CancellationToken token )
{
var embeddedAtUtc = DateTime . UtcNow ;
2026-05-27 18:02:43 +00:00
var points = batch . Select ( ( item , index ) = > new VectorStoragePoint (
2026-05-08 15:37:34 +00:00
this . CreatePointId ( dataSource . Id , fingerprint , item . ChunkIndex ) ,
vectors [ index ] ,
dataSource . Id ,
dataSource . Name ,
dataSource . Type . ToString ( ) ,
file . FullName ,
file . Name ,
relativePath ,
item . ChunkIndex ,
item . Text ,
fingerprint ,
file . LastWriteTimeUtc ,
embeddedAtUtc ) ) . ToList ( ) ;
2026-05-27 18:02:43 +00:00
await vectorStore . InsertEmbedding ( collectionName , points , token ) ;
2026-05-08 15:37:34 +00:00
}
2026-06-10 15:07:34 +00:00
private async Task DeleteFilePointsAsync ( VectorStoreClient vectorStore , string collectionName , string filePath , CancellationToken token )
2026-05-08 15:37:34 +00:00
{
2026-05-27 18:02:43 +00:00
await vectorStore . DeleteEmbeddingByFile ( collectionName , filePath , token ) ;
2026-05-08 15:37:34 +00:00
}
2026-06-10 15:07:34 +00:00
private async Task DeleteCollectionAsync ( string collectionName , VectorStoreClient ? vectorStore , CancellationToken token )
2026-05-08 15:37:34 +00:00
{
2026-05-27 18:02:43 +00:00
vectorStore ? ? = await databaseClientProvider . GetVectorStoreAsync ( token ) ;
if ( ! vectorStore . IsAvailable )
{
logger . LogWarning ( "Could not delete embedding collection '{CollectionName}' because the vector store '{VectorStoreName}' is unavailable." , collectionName , vectorStore . Name ) ;
return ;
}
await vectorStore . DeleteVectorStore ( collectionName , token ) ;
2026-05-08 15:37:34 +00:00
}
private async Task WaitForInitialSettingsAndBootstrapAsync ( CancellationToken token )
{
while ( ! token . IsCancellationRequested )
{
2026-05-27 18:02:43 +00:00
if ( settingsManager . HasCompletedInitialSettingsLoad
2026-05-08 15:37:34 +00:00
& & ! string . IsNullOrWhiteSpace ( SettingsManager . ConfigDirectory )
& & ! string . IsNullOrWhiteSpace ( SettingsManager . DataDirectory ) )
{
break ;
}
await Task . Delay ( 250 , token ) ;
}
token . ThrowIfCancellationRequested ( ) ;
2026-08-03 15:53:31 +00:00
logger . LogInformation ( "Embedding background service is ready. Running the initial persisted hash check before activating file watchers." ) ;
await this . RunInitialDataSourceHashCheckAsync ( token ) ;
}
private async Task RunInitialDataSourceHashCheckAsync ( CancellationToken token )
{
if ( ! settingsManager . ConfigurationData . DataSourceIndexing . AutomaticRefresh )
{
logger . LogInformation ( "Automatic local data source refresh is disabled. Startup hash checks and file watchers are disabled." ) ;
this . RemoveAllWatchers ( ) ;
return ;
}
if ( Interlocked . Exchange ( ref this . startupHashCheckStarted , 1 ) = = 1 )
return ;
this . RemoveAllWatchers ( ) ;
var supportedDataSources = settingsManager . ConfigurationData . DataSources
. Where ( this . IsSupportedInternalDataSource )
. ToList ( ) ;
logger . LogInformation (
"Starting initial persisted hash check for {DataSourceCount} supported internal data source(s). Incomplete or failed embedding state will be retried during this pass. File watchers will be activated after this check completes." ,
supportedDataSources . Count ) ;
foreach ( var dataSource in supportedDataSources )
{
token . ThrowIfCancellationRequested ( ) ;
try
{
await this . ProcessDataSourceRunAsync ( dataSource , DataSourceEmbeddingRefreshMode . STARTUP_HASH_CHECK , token ) ;
}
catch ( OperationCanceledException ) when ( token . IsCancellationRequested )
{
throw ;
}
catch ( Exception exception )
{
logger . LogError ( exception , "Initial embedding hash check failed for data source '{DataSourceName}' ({DataSourceId})." , dataSource . Name , dataSource . Id ) ;
this . UpsertStatus ( this . GetFallbackStatus ( dataSource , exception . Message ) ) ;
}
}
if ( ! settingsManager . ConfigurationData . DataSourceIndexing . AutomaticRefresh )
{
Volatile . Write ( ref this . startupHashCheckCompleted , 0 ) ;
Interlocked . Exchange ( ref this . startupHashCheckStarted , 0 ) ;
logger . LogInformation ( "Automatic local data source refresh was disabled before the initial hash check completed. File watchers remain inactive." ) ;
this . RemoveAllWatchers ( ) ;
return ;
}
Volatile . Write ( ref this . startupHashCheckCompleted , 1 ) ;
logger . LogInformation ( "Completed initial persisted hash check. Activating file watchers for automatic local data source refresh." ) ;
this . RefreshWatchers ( ) ;
2026-05-08 15:37:34 +00:00
}
2026-05-13 16:13:34 +00:00
private bool IsSupportedInternalDataSource ( IDataSource dataSource )
2026-05-08 15:37:34 +00:00
{
2026-05-13 16:13:34 +00:00
return dataSource is DataSourceLocalDirectory or DataSourceLocalFile ;
2026-05-08 15:37:34 +00:00
}
2026-07-28 18:22:37 +00:00
private bool TryGetConfiguredDataSource ( string dataSourceId , [ NotNullWhen ( true ) ] out IDataSource ? dataSource )
{
dataSource = settingsManager . ConfigurationData . DataSources
. FirstOrDefault ( source = > source . Id . Equals ( dataSourceId , StringComparison . OrdinalIgnoreCase ) ) ;
return dataSource is not null ;
}
2026-05-13 16:13:34 +00:00
private bool TryResolveEmbeddingProvider ( IDataSource dataSource , [ NotNullWhen ( true ) ] out EmbeddingProvider ? embeddingProvider )
2026-05-08 15:37:34 +00:00
{
2026-05-27 18:02:43 +00:00
embeddingProvider = settingsManager . ConfigurationData . EmbeddingProviders . FirstOrDefault ( provider = >
2026-05-13 16:13:34 +00:00
dataSource is IInternalDataSource internalDataSource & &
provider . Id . Equals ( internalDataSource . EmbeddingId , StringComparison . OrdinalIgnoreCase ) ) ;
2026-05-08 15:37:34 +00:00
2026-05-13 16:13:34 +00:00
return embeddingProvider ! = default & & embeddingProvider . UsedLLMProvider is not LLMProviders . NONE ;
2026-05-08 15:37:34 +00:00
}
2026-07-28 13:25:10 +00:00
private async Task < DataSourceEmbeddingManifest > EnsureCompatibleManifestAsync (
IDataSource dataSource ,
EmbeddingProvider embeddingProvider ,
string collectionName ,
VectorStoreClient vectorStore ,
EmbeddingStateClient embeddingState ,
CancellationToken token )
2026-05-08 15:37:34 +00:00
{
2026-07-29 16:47:59 +00:00
var chunkingOptions = this . GetChunkingOptions ( dataSource , embeddingProvider ) ;
var embeddingSignature = this . BuildEmbeddingSignature ( dataSource , embeddingProvider , chunkingOptions ) ;
2026-07-28 13:25:10 +00:00
var manifest = await embeddingState . GetManifestAsync ( dataSource . Id , token ) ;
2026-05-08 15:37:34 +00:00
2026-08-03 15:53:31 +00:00
logger . LogInformation (
"Loaded persisted embedding manifest for data source '{DataSourceName}' ({DataSourceId}). StoredFiles={StoredFiles}, StoredSourceHashPrefix={StoredSourceHashPrefix}, StoredSignaturePrefix={StoredSignaturePrefix}, CurrentSignaturePrefix={CurrentSignaturePrefix}." ,
dataSource . Name ,
dataSource . Id ,
manifest . Files . Count ,
ShortHash ( manifest . SourceHash ) ,
ShortHash ( manifest . EmbeddingSignature ) ,
ShortHash ( embeddingSignature ) ) ;
2026-05-13 16:13:34 +00:00
if ( ! string . Equals ( manifest . EmbeddingSignature , embeddingSignature , StringComparison . Ordinal ) )
2026-05-08 15:37:34 +00:00
{
2026-05-27 18:02:43 +00:00
logger . LogInformation (
2026-05-13 16:13:34 +00:00
"Embedding configuration changed for data source '{DataSourceName}' ({DataSourceId}). Resetting persisted state and collection '{CollectionName}'." ,
dataSource . Name ,
dataSource . Id ,
collectionName ) ;
2026-07-29 16:47:59 +00:00
logger . LogDebug (
"Embedding signature mismatch for data source '{DataSourceName}' ({DataSourceId}). StoredSignature='{StoredEmbeddingSignature}', CurrentSignature='{CurrentEmbeddingSignature}'." ,
dataSource . Name ,
dataSource . Id ,
manifest . EmbeddingSignature ,
embeddingSignature ) ;
2026-07-28 13:25:10 +00:00
await this . ResetPersistedStateAsync ( dataSource . Name , dataSource . Id , vectorStore , embeddingState , token ) ;
manifest = await embeddingState . GetManifestAsync ( dataSource . Id , token ) ;
2026-05-08 15:37:34 +00:00
}
2026-05-13 16:13:34 +00:00
if ( ! string . Equals ( manifest . EmbeddingProviderId , embeddingProvider . Id , StringComparison . OrdinalIgnoreCase ) | |
! string . Equals ( manifest . EmbeddingSignature , embeddingSignature , StringComparison . Ordinal ) )
2026-05-08 15:37:34 +00:00
{
2026-05-13 16:13:34 +00:00
manifest . EmbeddingProviderId = embeddingProvider . Id ;
manifest . EmbeddingSignature = embeddingSignature ;
2026-05-08 15:37:34 +00:00
}
2026-07-28 13:25:10 +00:00
await embeddingState . UpsertDataSourceAsync (
dataSource . Id ,
dataSource . Name ,
dataSource . Type . ToString ( ) ,
manifest . EmbeddingProviderId ,
manifest . EmbeddingSignature ,
2026-08-03 15:53:31 +00:00
manifest . SourceHash ,
2026-07-28 13:25:10 +00:00
manifest . VectorSize ,
token ) ;
2026-05-13 16:13:34 +00:00
return manifest ;
2026-05-08 15:37:34 +00:00
}
2026-08-03 15:53:31 +00:00
private async Task < int > RemoveMissingFileEmbeddingsAsync (
2026-06-10 15:07:34 +00:00
VectorStoreClient vectorStore ,
2026-07-28 13:25:10 +00:00
EmbeddingStateClient embeddingState ,
2026-05-13 16:13:34 +00:00
IDataSource dataSource ,
string collectionName ,
DataSourceEmbeddingManifest manifest ,
IReadOnlyCollection < FileInfo > indexedFiles ,
CancellationToken token )
2026-05-08 15:37:34 +00:00
{
2026-05-13 16:13:34 +00:00
var existingPaths = indexedFiles
. Select ( file = > file . FullName )
. ToHashSet ( StringComparer . OrdinalIgnoreCase ) ;
2026-05-08 15:37:34 +00:00
2026-08-03 15:53:31 +00:00
var removedFiles = 0 ;
2026-05-13 16:13:34 +00:00
foreach ( var removedFilePath in manifest . Files . Keys . Except ( existingPaths , StringComparer . OrdinalIgnoreCase ) . ToList ( ) )
2026-05-08 15:37:34 +00:00
{
2026-05-27 18:02:43 +00:00
await this . DeleteFilePointsAsync ( vectorStore , collectionName , removedFilePath , token ) ;
2026-07-28 13:25:10 +00:00
await embeddingState . DeleteFileAsync ( dataSource . Id , removedFilePath , token ) ;
2026-05-13 16:13:34 +00:00
manifest . Files . Remove ( removedFilePath ) ;
2026-08-03 15:53:31 +00:00
removedFiles + + ;
2026-05-27 18:02:43 +00:00
logger . LogInformation (
2026-05-13 16:13:34 +00:00
"Removed stale embeddings for deleted file '{FilePath}' from data source '{DataSourceName}' ({DataSourceId})." ,
removedFilePath ,
dataSource . Name ,
dataSource . Id ) ;
2026-05-08 15:37:34 +00:00
}
2026-08-03 15:53:31 +00:00
return removedFiles ;
}
private bool CanSkipDataSourceByHash ( DataSourceEmbeddingManifest manifest , DataSourceMetadataSnapshot metadataSnapshot , IReadOnlyCollection < FileInfo > indexedFiles )
{
if ( ! string . Equals ( manifest . SourceHash , metadataSnapshot . SourceHash , StringComparison . Ordinal ) )
return false ;
if ( manifest . Files . Count ! = indexedFiles . Count )
return false ;
foreach ( var file in indexedFiles )
{
if ( ! metadataSnapshot . FileHashes . TryGetValue ( file . FullName , out var currentHash ) )
return false ;
if ( ! manifest . Files . TryGetValue ( file . FullName , out var existingRecord ) )
return false ;
if ( ! string . Equals ( existingRecord . Fingerprint , currentHash , StringComparison . Ordinal ) )
return false ;
}
return true ;
}
private static string GetFileEmbeddingReason ( FileInfo file , string currentHash , EmbeddedFileRecord ? existingRecord )
{
if ( existingRecord is null )
return "no stored file hash exists" ;
var reasons = new List < string > ( ) ;
if ( ! string . Equals ( existingRecord . Fingerprint , currentHash , StringComparison . Ordinal ) )
reasons . Add ( $"stored hash {ShortHash(existingRecord.Fingerprint)} differs from current hash {ShortHash(currentHash)}" ) ;
if ( existingRecord . FileSize ! = file . Length )
reasons . Add ( $"file size changed from {existingRecord.FileSize} to {file.Length} bytes" ) ;
if ( existingRecord . LastWriteUtc ! = file . LastWriteTimeUtc )
reasons . Add ( $"last modified time changed from {existingRecord.LastWriteUtc:O} to {file.LastWriteTimeUtc:O}" ) ;
return reasons . Count = = 0
? "the file hash changed"
: string . Join ( "; " , reasons ) ;
}
private static string ShortHash ( string value )
{
if ( string . IsNullOrWhiteSpace ( value ) )
return "<empty>" ;
return value . Length < = 12 ? value : value [ . . 12 ] ;
2026-05-08 15:37:34 +00:00
}
2026-05-13 16:13:34 +00:00
private DataSourceEmbeddingStatus CreateStatus (
IDataSource dataSource ,
DataSourceEmbeddingState state ,
int totalFiles ,
int indexedFiles ,
int failedFiles ,
string currentFile = "" ,
2026-07-28 18:22:37 +00:00
string lastError = "" ,
IReadOnlyList < DataSourceEmbeddingFailure > ? failures = null )
2026-05-08 15:37:34 +00:00
{
2026-05-13 16:13:34 +00:00
return new DataSourceEmbeddingStatus (
dataSource . Id ,
dataSource . Name ,
dataSource . Type ,
state ,
totalFiles ,
indexedFiles ,
failedFiles ,
currentFile ,
2026-07-28 18:22:37 +00:00
lastError ,
failures ? . ToList ( ) ? ? [ ] ) ;
2026-05-08 15:37:34 +00:00
}
2026-07-28 18:22:37 +00:00
private DataSourceEmbeddingStatus CreateCompletedStatus ( IDataSource dataSource , int totalFiles , int indexedFiles , int failedFiles , string lastError , IReadOnlyList < DataSourceEmbeddingFailure > ? failures = null )
2026-05-08 15:37:34 +00:00
{
2026-05-13 16:13:34 +00:00
return this . CreateStatus (
dataSource ,
failedFiles > 0 ? DataSourceEmbeddingState . FAILED : DataSourceEmbeddingState . COMPLETED ,
totalFiles ,
indexedFiles ,
failedFiles ,
lastError : failedFiles > 0
? string . IsNullOrWhiteSpace ( lastError )
? "Some files could not be embedded. See the logs for details."
: lastError
2026-07-28 18:22:37 +00:00
: string . Empty ,
failures : failures ) ;
2026-05-08 15:37:34 +00:00
}
private DataSourceEmbeddingStatus GetFallbackStatus ( IDataSource dataSource , string errorMessage )
{
2026-07-28 18:22:37 +00:00
return this . CreateStatus (
dataSource ,
DataSourceEmbeddingState . FAILED ,
0 ,
0 ,
1 ,
lastError : errorMessage ,
failures : [ new DataSourceEmbeddingFailure ( dataSource . Name , errorMessage ) ] ) ;
2026-05-08 15:37:34 +00:00
}
2026-07-28 13:52:59 +00:00
private DataSourceQueueRequestResult TryReserveDataSourceQueueSlot ( string dataSourceId , bool queueAfterCurrentRun )
{
lock ( this . queueStateLock )
{
if ( this . runningIds . ContainsKey ( dataSourceId ) )
{
if ( queueAfterCurrentRun & & this . pendingQueueIds . TryAdd ( dataSourceId , 0 ) )
return DataSourceQueueRequestResult . RUNNING_MARKED_PENDING ;
return DataSourceQueueRequestResult . RUNNING ;
}
if ( ! this . queuedIds . TryAdd ( dataSourceId , 0 ) )
return DataSourceQueueRequestResult . ALREADY_QUEUED ;
return DataSourceQueueRequestResult . QUEUED ;
}
}
private void MarkDataSourceRunStarted ( string dataSourceId )
{
lock ( this . queueStateLock )
{
this . queuedIds . TryRemove ( dataSourceId , out _ ) ;
this . runningIds . TryAdd ( dataSourceId , 0 ) ;
}
}
private bool TryCompleteDataSourceRun ( string dataSourceId , bool allowPendingRequeue )
{
lock ( this . queueStateLock )
{
this . runningIds . TryRemove ( dataSourceId , out _ ) ;
if ( ! this . pendingQueueIds . TryRemove ( dataSourceId , out _ ) )
return false ;
return allowPendingRequeue & & this . queuedIds . TryAdd ( dataSourceId , 0 ) ;
}
}
private void ReleaseQueuedDataSourceRun ( string dataSourceId )
{
lock ( this . queueStateLock )
{
this . queuedIds . TryRemove ( dataSourceId , out _ ) ;
}
}
2026-08-03 15:53:31 +00:00
private void ClearQueuedDataSourceState ( string dataSourceId )
{
lock ( this . queueStateLock )
{
this . queuedIds . TryRemove ( dataSourceId , out _ ) ;
this . pendingQueueIds . TryRemove ( dataSourceId , out _ ) ;
}
}
private DataSourceRunControl ? CancelActiveDataSourceRun ( IDataSource dataSource )
{
if ( ! this . activeRuns . TryGetValue ( dataSource . Id , out var activeRun ) )
return null ;
logger . LogInformation (
"Canceling active embedding run for deleted data source '{DataSourceName}' ({DataSourceId})." ,
dataSource . Name ,
dataSource . Id ) ;
try
{
activeRun . TokenSource . Cancel ( ) ;
}
catch ( ObjectDisposedException )
{
return null ;
}
return activeRun ;
}
2026-07-28 13:52:59 +00:00
private async Task QueuePendingDataSourceRunAsync ( string dataSourceId , CancellationToken token )
{
var dataSource = token . IsCancellationRequested
? null
: settingsManager . ConfigurationData . DataSources
. FirstOrDefault ( source = > source . Id . Equals ( dataSourceId , StringComparison . OrdinalIgnoreCase ) ) ;
if ( ! this . TryCompleteDataSourceRun ( dataSourceId , dataSource is not null & & this . IsSupportedInternalDataSource ( dataSource ) ) )
return ;
if ( dataSource is null )
{
this . ReleaseQueuedDataSourceRun ( dataSourceId ) ;
return ;
}
logger . LogInformation ( "Queueing one follow-up embedding run for data source '{DataSourceName}' ({DataSourceId}) after changes arrived during the previous run." , dataSource . Name , dataSource . Id ) ;
this . statuses . TryGetValue ( dataSource . Id , out var currentStatus ) ;
this . UpsertStatus ( this . CreateStatus (
dataSource ,
DataSourceEmbeddingState . QUEUED ,
currentStatus ? . TotalFiles ? ? 0 ,
currentStatus ? . IndexedFiles ? ? 0 ,
currentStatus ? . FailedFiles ? ? 0 ,
2026-07-28 18:22:37 +00:00
lastError : currentStatus ? . LastError ? ? string . Empty ,
failures : currentStatus ? . Failures ? ? [ ] ) ) ;
2026-07-28 13:52:59 +00:00
try
{
2026-08-03 15:53:31 +00:00
await this . queue . Writer . WriteAsync ( new DataSourceEmbeddingQueueItem ( dataSourceId , DataSourceEmbeddingRefreshMode . HASH_CHECK ) , token ) ;
2026-07-28 13:52:59 +00:00
}
catch ( OperationCanceledException ) when ( token . IsCancellationRequested )
{
this . ReleaseQueuedDataSourceRun ( dataSourceId ) ;
}
}
2026-05-08 15:37:34 +00:00
private void UpsertStatus ( DataSourceEmbeddingStatus status )
{
this . statuses [ status . DataSourceId ] = status ;
this . PublishStatusChanged ( ) ;
}
private void PublishStatusChanged ( )
{
_ = MessageBus . INSTANCE . SendMessage ( null , Event . RAG_EMBEDDING_STATUS_CHANGED , true ) ;
}
}