FileVectorIndex persists one binary file per model under index/ with atomic tmp+rename writes; FileDocumentCatalog persists a single catalog.json, rebuilding the link graph via WikiLinks.Extract on every upsert. Both start empty and report health Ok with a reset detail if their file on disk is corrupt, rather than crashing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
99 lines
3.7 KiB
C#
99 lines
3.7 KiB
C#
using Microsoft.Extensions.Options;
|
|
using Wiki.Adapters.FileIndex;
|
|
using Wiki.Core.Model;
|
|
using Wiki.Core.Ports;
|
|
|
|
namespace Wiki.Contracts.FileIndex;
|
|
|
|
/// <summary>Behaviour specific to the file-backed adapters that the shared ports contracts don't exercise: atomicity and resilience to corrupt state on disk.</summary>
|
|
public sealed class FileIndexUnitTests : IDisposable
|
|
{
|
|
private readonly List<string> _directories = [];
|
|
|
|
private string NewDirectory()
|
|
{
|
|
var directory = Path.Combine(Path.GetTempPath(), "wiki-tests", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(directory);
|
|
_directories.Add(directory);
|
|
return directory;
|
|
}
|
|
|
|
private static IOptions<FileIndexOptions> OptionsFor(string directory) => Options.Create(new FileIndexOptions { Directory = directory });
|
|
|
|
private static float[] Unit(params float[] v)
|
|
{
|
|
var n = MathF.Sqrt(v.Sum(x => x * x));
|
|
return v.Select(x => x / n).ToArray();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VectorIndexFlushLeavesNoTempFileBehind()
|
|
{
|
|
var directory = NewDirectory();
|
|
var index = new FileVectorIndex(OptionsFor(directory));
|
|
await index.UpsertAsync([new Embedding("m", "a", "h1", Unit(1, 0, 0))], CancellationToken.None);
|
|
await index.FlushAsync(CancellationToken.None);
|
|
|
|
var indexDirectory = Path.Combine(directory, "index");
|
|
Assert.Empty(Directory.GetFiles(indexDirectory, "*.tmp"));
|
|
Assert.NotEmpty(Directory.GetFiles(indexDirectory, "*.bin"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VectorIndexCorruptFileStartsEmptyAndHealthMentionsReset()
|
|
{
|
|
var directory = NewDirectory();
|
|
var indexDirectory = Path.Combine(directory, "index");
|
|
Directory.CreateDirectory(indexDirectory);
|
|
await File.WriteAllBytesAsync(Path.Combine(indexDirectory, "broken.bin"), [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
|
|
|
|
var index = new FileVectorIndex(OptionsFor(directory));
|
|
var state = await index.DescribeAsync("test-model", CancellationToken.None);
|
|
Assert.Equal(0, state.Count);
|
|
|
|
var health = await index.CheckAsync(CancellationToken.None);
|
|
Assert.True(health.Ok);
|
|
Assert.NotNull(health.Detail);
|
|
Assert.Contains("reset", health.Detail, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CatalogFlushLeavesNoTempFileBehind()
|
|
{
|
|
var directory = NewDirectory();
|
|
var catalog = new FileDocumentCatalog(OptionsFor(directory));
|
|
await catalog.SetStateAsync(new CatalogState("abc", DateTimeOffset.UtcNow), CancellationToken.None);
|
|
await catalog.FlushAsync(CancellationToken.None);
|
|
|
|
Assert.Empty(Directory.GetFiles(directory, "*.tmp"));
|
|
Assert.True(File.Exists(Path.Combine(directory, "catalog.json")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CatalogCorruptFileStartsEmptyAndHealthMentionsReset()
|
|
{
|
|
var directory = NewDirectory();
|
|
await File.WriteAllTextAsync(Path.Combine(directory, "catalog.json"), "{ not valid json");
|
|
|
|
var catalog = new FileDocumentCatalog(OptionsFor(directory));
|
|
var state = await catalog.GetStateAsync(CancellationToken.None);
|
|
Assert.Null(state.Commit);
|
|
Assert.Empty(await catalog.ListAsync(DocumentKind.Wiki, CancellationToken.None));
|
|
|
|
var health = await catalog.CheckAsync(CancellationToken.None);
|
|
Assert.True(health.Ok);
|
|
Assert.NotNull(health.Detail);
|
|
Assert.Contains("reset", health.Detail, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var directory in _directories)
|
|
{
|
|
try { Directory.Delete(directory, recursive: true); }
|
|
catch (IOException) { }
|
|
catch (UnauthorizedAccessException) { }
|
|
}
|
|
}
|
|
}
|