2026-05-31 17:32:42 +01:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Concurrent;
|
|
|
|
|
using System.Collections.Generic;
|
2026-07-20 02:31:10 +01:00
|
|
|
using System.Diagnostics;
|
2026-05-31 17:32:42 +01:00
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Security.Cryptography;
|
2026-07-02 21:00:36 +01:00
|
|
|
using System.Threading;
|
2026-07-20 00:46:38 +01:00
|
|
|
using System.Threading.Tasks;
|
2026-06-27 14:34:53 +01:00
|
|
|
using MessagePack;
|
2026-05-31 17:32:42 +01:00
|
|
|
|
|
|
|
|
namespace GameCult.Caching.MessagePack;
|
|
|
|
|
|
|
|
|
|
public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
|
|
|
|
|
{
|
2026-08-19 00:11:20 +02:00
|
|
|
private const string IndexedFormatVersion = "cultcache.store.v4.directory-content-addressed-pages";
|
2026-05-31 17:32:42 +01:00
|
|
|
private readonly FileInfo _manifestFile;
|
|
|
|
|
private readonly DirectoryInfo _recordDirectory;
|
2026-08-19 00:11:20 +02:00
|
|
|
private readonly object _mutationGate = new();
|
2026-05-31 17:32:42 +01:00
|
|
|
private readonly ConcurrentDictionary<string, bool> _dirtyKeys = new(StringComparer.Ordinal);
|
|
|
|
|
private readonly ConcurrentDictionary<string, bool> _deletedKeys = new(StringComparer.Ordinal);
|
2026-07-20 01:48:56 +01:00
|
|
|
private readonly HashSet<string> _hydratedKeys = new(StringComparer.Ordinal);
|
|
|
|
|
private Dictionary<string, CultPersistedRecord> _durableIndex = new(StringComparer.Ordinal);
|
|
|
|
|
private CultSchemaCatalogEntry[] _durableCatalog = Array.Empty<CultSchemaCatalogEntry>();
|
2026-08-19 00:56:37 +02:00
|
|
|
|
2026-05-31 17:32:42 +01:00
|
|
|
public DirectoryMessagePackBackingStore(string manifestPath, string? recordDirectoryPath = null)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(manifestPath))
|
|
|
|
|
{
|
|
|
|
|
throw new ArgumentException("Manifest path must be non-empty.", nameof(manifestPath));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_manifestFile = new FileInfo(manifestPath);
|
|
|
|
|
_recordDirectory = new DirectoryInfo(recordDirectoryPath ?? DefaultRecordDirectoryPath(manifestPath));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static string DefaultRecordDirectoryPath(string manifestPath) => manifestPath + ".records";
|
|
|
|
|
|
|
|
|
|
public override void PullAll()
|
2026-08-19 00:11:20 +02:00
|
|
|
{
|
|
|
|
|
lock (_mutationGate)
|
|
|
|
|
PullAllCore();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void PullAllCore()
|
2026-05-31 17:32:42 +01:00
|
|
|
{
|
2026-07-20 02:31:10 +01:00
|
|
|
var traceStartup = string.Equals(
|
|
|
|
|
Environment.GetEnvironmentVariable("CULTCACHE_TRACE_STARTUP_PHASES"),
|
|
|
|
|
"1",
|
|
|
|
|
StringComparison.Ordinal);
|
|
|
|
|
var startupPhase = Stopwatch.StartNew();
|
|
|
|
|
void Trace(string phase)
|
|
|
|
|
{
|
|
|
|
|
if (traceStartup)
|
|
|
|
|
Console.WriteLine($"CultCache directory-pull phase {phase} took {startupPhase.Elapsed.TotalMilliseconds:0.###}ms.");
|
|
|
|
|
startupPhase.Restart();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 17:32:42 +01:00
|
|
|
var reports = new List<CultSchemaMigrationReport>();
|
2026-07-18 18:35:39 +01:00
|
|
|
var loaded = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
|
2026-08-19 00:56:37 +02:00
|
|
|
using (AcquireCommitLease())
|
|
|
|
|
{
|
|
|
|
|
var manifest = ReadManifest();
|
|
|
|
|
Trace($"manifest records={manifest.Records.Length}");
|
|
|
|
|
_durableCatalog = manifest.SchemaCatalog;
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
_durableIndex = manifest.Records
|
|
|
|
|
.Where(record => !string.IsNullOrWhiteSpace(record.Key))
|
|
|
|
|
.GroupBy(record => record.Key, StringComparer.Ordinal)
|
|
|
|
|
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
|
|
|
|
|
|
|
|
|
|
LoadRecordPages(
|
|
|
|
|
manifest.Records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(),
|
|
|
|
|
manifest.SchemaCatalog,
|
|
|
|
|
loaded,
|
|
|
|
|
reports);
|
|
|
|
|
Trace($"indexed-pages loaded={loaded.Count}");
|
2026-05-31 17:32:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 18:35:39 +01:00
|
|
|
foreach (var pair in loaded)
|
|
|
|
|
{
|
2026-07-18 23:19:45 +01:00
|
|
|
if (_dirtyKeys.ContainsKey(pair.Key) || _deletedKeys.ContainsKey(pair.Key))
|
|
|
|
|
continue;
|
|
|
|
|
|
2026-07-18 18:35:39 +01:00
|
|
|
if (Entries.TryGetValue(pair.Key, out var existing) &&
|
|
|
|
|
string.Equals(existing.StoredAt, pair.Value.StoredAt, StringComparison.Ordinal) &&
|
|
|
|
|
string.Equals(existing.Descriptor.SchemaId, pair.Value.Descriptor.SchemaId, StringComparison.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Entries[pair.Key] = pair.Value;
|
Delete CultCache wrappers and unused members
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.
The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.
The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.
Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:44:11 +02:00
|
|
|
Loaded?.Invoke(pair.Value);
|
2026-07-18 18:35:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-20 01:48:56 +01:00
|
|
|
foreach (var removedKey in _hydratedKeys.Where(key => !loaded.ContainsKey(key)).ToArray())
|
2026-05-31 17:32:42 +01:00
|
|
|
{
|
2026-07-18 23:19:45 +01:00
|
|
|
if (_dirtyKeys.ContainsKey(removedKey) || _deletedKeys.ContainsKey(removedKey))
|
|
|
|
|
continue;
|
|
|
|
|
|
2026-07-18 18:35:39 +01:00
|
|
|
if (Entries.TryRemove(removedKey, out var removed))
|
Delete CultCache wrappers and unused members
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.
The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.
The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.
Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:44:11 +02:00
|
|
|
Unloaded?.Invoke(removed);
|
2026-07-20 01:48:56 +01:00
|
|
|
_hydratedKeys.Remove(removedKey);
|
2026-05-31 17:32:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-20 01:48:56 +01:00
|
|
|
foreach (var key in loaded.Keys)
|
|
|
|
|
_hydratedKeys.Add(key);
|
|
|
|
|
|
|
|
|
|
SetLastSchemaMigrationReports(reports);
|
|
|
|
|
IsDirty = !_dirtyKeys.IsEmpty || !_deletedKeys.IsEmpty;
|
2026-07-20 02:31:10 +01:00
|
|
|
Trace("publish");
|
2026-07-20 01:48:56 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-20 02:23:42 +01:00
|
|
|
public override bool ContainsDurableRecord(CultRecordKey key)
|
|
|
|
|
{
|
2026-08-19 00:11:20 +02:00
|
|
|
lock (_mutationGate)
|
|
|
|
|
return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key);
|
2026-07-20 02:23:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 17:32:42 +01:00
|
|
|
public override void Push(CultStoredDocument entry)
|
|
|
|
|
{
|
2026-08-19 00:11:20 +02:00
|
|
|
lock (_mutationGate)
|
|
|
|
|
{
|
|
|
|
|
Entries[entry.Key.Value] = entry;
|
|
|
|
|
_dirtyKeys[entry.Key.Value] = true;
|
|
|
|
|
_deletedKeys.TryRemove(entry.Key.Value, out _);
|
|
|
|
|
IsDirty = true;
|
|
|
|
|
}
|
2026-05-31 17:32:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override void Delete(CultStoredDocument entry)
|
|
|
|
|
{
|
2026-08-19 00:11:20 +02:00
|
|
|
lock (_mutationGate)
|
|
|
|
|
{
|
|
|
|
|
Entries.TryRemove(entry.Key.Value, out _);
|
|
|
|
|
_dirtyKeys.TryRemove(entry.Key.Value, out _);
|
|
|
|
|
_deletedKeys[entry.Key.Value] = true;
|
|
|
|
|
IsDirty = true;
|
|
|
|
|
}
|
2026-05-31 17:32:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-08-19 01:36:23 +02:00
|
|
|
public override void CommitBatch(
|
|
|
|
|
IReadOnlyCollection<CultStoredDocument> upserts,
|
Delete CultCache wrappers and unused members
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.
The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.
The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.
Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:44:11 +02:00
|
|
|
IReadOnlyCollection<CultStoredDocument> deletes)
|
2026-08-19 01:36:23 +02:00
|
|
|
{
|
|
|
|
|
lock (_mutationGate)
|
|
|
|
|
{
|
|
|
|
|
var previousEntries = Entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
|
|
|
|
var previousDirtyKeys = _dirtyKeys.Keys.ToArray();
|
|
|
|
|
var previousDeletedKeys = _deletedKeys.Keys.ToArray();
|
|
|
|
|
var wasDirty = IsDirty;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
foreach (var entry in deletes)
|
|
|
|
|
{
|
|
|
|
|
Entries.TryRemove(entry.Key.Value, out _);
|
|
|
|
|
_dirtyKeys.TryRemove(entry.Key.Value, out _);
|
|
|
|
|
_deletedKeys[entry.Key.Value] = true;
|
|
|
|
|
}
|
|
|
|
|
foreach (var entry in upserts)
|
|
|
|
|
{
|
|
|
|
|
Entries[entry.Key.Value] = entry;
|
|
|
|
|
_dirtyKeys[entry.Key.Value] = true;
|
|
|
|
|
_deletedKeys.TryRemove(entry.Key.Value, out _);
|
|
|
|
|
}
|
|
|
|
|
IsDirty = true;
|
Delete CultCache wrappers and unused members
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.
The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.
The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.
Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:44:11 +02:00
|
|
|
PushAllCore();
|
2026-08-19 01:36:23 +02:00
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
Entries.Clear();
|
|
|
|
|
foreach (var pair in previousEntries)
|
|
|
|
|
Entries[pair.Key] = pair.Value;
|
|
|
|
|
_dirtyKeys.Clear();
|
|
|
|
|
foreach (var key in previousDirtyKeys)
|
|
|
|
|
_dirtyKeys[key] = true;
|
|
|
|
|
_deletedKeys.Clear();
|
|
|
|
|
foreach (var key in previousDeletedKeys)
|
|
|
|
|
_deletedKeys[key] = true;
|
|
|
|
|
IsDirty = wasDirty;
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Delete CultCache wrappers and unused members
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.
The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.
The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.
Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:44:11 +02:00
|
|
|
public override void PushAll()
|
2026-08-19 00:11:20 +02:00
|
|
|
{
|
|
|
|
|
lock (_mutationGate)
|
Delete CultCache wrappers and unused members
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.
The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.
The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.
Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:44:11 +02:00
|
|
|
PushAllCore();
|
2026-08-19 00:11:20 +02:00
|
|
|
}
|
|
|
|
|
|
Delete CultCache wrappers and unused members
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.
The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.
The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.
Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:44:11 +02:00
|
|
|
private void PushAllCore()
|
2026-05-31 17:32:42 +01:00
|
|
|
{
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
if (!IsDirty)
|
2026-07-20 02:12:22 +01:00
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 17:32:42 +01:00
|
|
|
Directory.CreateDirectory(_manifestFile.DirectoryName!);
|
|
|
|
|
Directory.CreateDirectory(_recordDirectory.FullName);
|
2026-08-19 00:11:20 +02:00
|
|
|
using var commitLease = AcquireCommitLease();
|
2026-05-31 17:32:42 +01:00
|
|
|
|
2026-07-20 01:48:56 +01:00
|
|
|
var currentManifest = ReadManifest();
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
var currentIndex = currentManifest.Records.ToDictionary(record => record.Key, record => record, StringComparer.Ordinal);
|
2026-07-20 01:48:56 +01:00
|
|
|
foreach (var key in _deletedKeys.Keys)
|
|
|
|
|
currentIndex.Remove(key);
|
|
|
|
|
foreach (var key in _dirtyKeys.Keys)
|
|
|
|
|
{
|
|
|
|
|
if (Entries.TryGetValue(key, out var stored))
|
|
|
|
|
currentIndex[key] = ToIndexRecord(stored);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var catalogCandidates = currentManifest.SchemaCatalog
|
|
|
|
|
.Concat(_durableCatalog)
|
|
|
|
|
.Concat(Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry()))
|
|
|
|
|
.GroupBy(entry => entry.SchemaId, StringComparer.Ordinal)
|
|
|
|
|
.Select(group => group.Last())
|
|
|
|
|
.ToArray();
|
|
|
|
|
var usedSchemaIds = currentIndex.Values.Select(record => record.SchemaId).ToHashSet(StringComparer.Ordinal);
|
|
|
|
|
var targetCatalog = catalogCandidates
|
|
|
|
|
.Where(entry => usedSchemaIds.Contains(entry.SchemaId))
|
|
|
|
|
.OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
|
|
|
|
|
.ToArray();
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
var keysToWrite = _dirtyKeys.Keys
|
2026-07-20 01:48:56 +01:00
|
|
|
.OrderBy(value => value, StringComparer.Ordinal)
|
|
|
|
|
.ToArray();
|
|
|
|
|
foreach (var key in keysToWrite)
|
2026-05-31 17:32:42 +01:00
|
|
|
{
|
|
|
|
|
if (!Entries.TryGetValue(key, out var stored))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var record = ToPersistedRecord(stored, document =>
|
2026-07-20 01:48:56 +01:00
|
|
|
CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
|
|
|
document,
|
|
|
|
|
stored.Descriptor.DocumentType,
|
|
|
|
|
Registry));
|
2026-08-19 00:11:20 +02:00
|
|
|
var pagePayload = CultDocumentMessagePackSerialization.SerializePersistedRecord(record);
|
|
|
|
|
var indexRecord = ToContentAddressedIndexRecord(record, pagePayload);
|
|
|
|
|
currentIndex[key] = indexRecord;
|
2026-07-20 01:48:56 +01:00
|
|
|
WriteFileAtomically(
|
2026-08-19 00:11:20 +02:00
|
|
|
ContentAddressedRecordPath(indexRecord),
|
|
|
|
|
pagePayload);
|
2026-07-20 01:48:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
WriteManifest(targetCatalog, currentIndex.Values
|
|
|
|
|
.OrderBy(record => record.Key, StringComparer.Ordinal)
|
|
|
|
|
.ToArray());
|
2026-07-13 17:18:07 +01:00
|
|
|
|
2026-08-18 23:42:15 +02:00
|
|
|
DeleteUnreferencedRecordPages(currentIndex.Values);
|
|
|
|
|
|
2026-07-20 01:48:56 +01:00
|
|
|
_durableCatalog = targetCatalog;
|
|
|
|
|
_durableIndex = currentIndex;
|
2026-07-13 17:18:07 +01:00
|
|
|
_dirtyKeys.Clear();
|
|
|
|
|
_deletedKeys.Clear();
|
|
|
|
|
MarkFlushSucceeded();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 01:48:56 +01:00
|
|
|
private void WriteManifest(CultSchemaCatalogEntry[] catalog, CultPersistedRecord[] index)
|
2026-07-13 17:18:07 +01:00
|
|
|
{
|
2026-05-31 17:32:42 +01:00
|
|
|
var manifest = new CultPersistedStoreSnapshot
|
|
|
|
|
{
|
2026-07-20 01:48:56 +01:00
|
|
|
FormatVersion = IndexedFormatVersion,
|
2026-07-13 17:18:07 +01:00
|
|
|
SchemaCatalog = catalog,
|
2026-07-20 01:48:56 +01:00
|
|
|
Records = index
|
2026-05-31 17:32:42 +01:00
|
|
|
};
|
|
|
|
|
WriteFileAtomically(_manifestFile.FullName, CultDocumentMessagePackSerialization.SerializeSnapshot(manifest));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 01:48:56 +01:00
|
|
|
private void LoadRecordPages(
|
|
|
|
|
CultPersistedRecord[] records,
|
|
|
|
|
IReadOnlyCollection<CultSchemaCatalogEntry> catalogEntries,
|
|
|
|
|
Dictionary<string, CultStoredDocument> loaded,
|
|
|
|
|
List<CultSchemaMigrationReport> reports)
|
|
|
|
|
{
|
2026-07-20 02:38:17 +01:00
|
|
|
var tracePages = string.Equals(
|
|
|
|
|
Environment.GetEnvironmentVariable("CULTCACHE_TRACE_STARTUP_PHASES"),
|
|
|
|
|
"1",
|
|
|
|
|
StringComparison.Ordinal);
|
2026-07-20 01:48:56 +01:00
|
|
|
var recordReports = new CultSchemaMigrationReport[records.Length];
|
|
|
|
|
var storedRecords = new CultStoredDocument?[records.Length];
|
2026-07-20 02:38:17 +01:00
|
|
|
var pageBytes = tracePages ? new long[records.Length] : Array.Empty<long>();
|
|
|
|
|
var pageElapsedTicks = tracePages ? new long[records.Length] : Array.Empty<long>();
|
2026-07-20 01:48:56 +01:00
|
|
|
Parallel.For(
|
|
|
|
|
0,
|
|
|
|
|
records.Length,
|
|
|
|
|
new ParallelOptions { MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, 8) },
|
|
|
|
|
index =>
|
|
|
|
|
{
|
2026-07-20 02:38:17 +01:00
|
|
|
var started = tracePages ? Stopwatch.GetTimestamp() : 0L;
|
2026-07-20 01:48:56 +01:00
|
|
|
var metadata = records[index];
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
var record = ReadPersistedRecordPage(metadata, out var pagePayload);
|
2026-07-20 01:48:56 +01:00
|
|
|
var catalog = ResolveLegacyUncataloguedRecordCatalog(record, catalogEntries);
|
|
|
|
|
recordReports[index] = Registry.ResolvePersistedSchemaReport(record.SchemaId, catalog);
|
|
|
|
|
storedRecords[index] = ToStoredDocument(
|
|
|
|
|
record,
|
|
|
|
|
catalog,
|
|
|
|
|
(type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry));
|
2026-07-20 02:38:17 +01:00
|
|
|
if (tracePages)
|
|
|
|
|
{
|
|
|
|
|
pageBytes[index] = pagePayload.LongLength;
|
|
|
|
|
pageElapsedTicks[index] = Stopwatch.GetTimestamp() - started;
|
|
|
|
|
}
|
2026-07-20 01:48:56 +01:00
|
|
|
});
|
|
|
|
|
|
2026-07-20 02:38:17 +01:00
|
|
|
if (tracePages)
|
|
|
|
|
{
|
|
|
|
|
var schemaNames = catalogEntries.ToDictionary(entry => entry.SchemaId, entry => entry.SchemaName, StringComparer.Ordinal);
|
|
|
|
|
foreach (var group in records
|
|
|
|
|
.Select((record, index) => new { record.SchemaId, Index = index })
|
|
|
|
|
.Where(item => pageElapsedTicks[item.Index] > 0)
|
|
|
|
|
.GroupBy(item => item.SchemaId, StringComparer.Ordinal)
|
|
|
|
|
.Select(group => new
|
|
|
|
|
{
|
|
|
|
|
SchemaId = group.Key,
|
|
|
|
|
Count = group.Count(),
|
|
|
|
|
Bytes = group.Sum(item => pageBytes[item.Index]),
|
|
|
|
|
TotalMs = group.Sum(item => pageElapsedTicks[item.Index]) * 1000d / Stopwatch.Frequency,
|
|
|
|
|
MaxMs = group.Max(item => pageElapsedTicks[item.Index]) * 1000d / Stopwatch.Frequency
|
|
|
|
|
})
|
|
|
|
|
.OrderByDescending(group => group.TotalMs)
|
|
|
|
|
.Take(10))
|
|
|
|
|
{
|
|
|
|
|
var schemaName = schemaNames.TryGetValue(group.SchemaId, out var name) ? name : group.SchemaId;
|
|
|
|
|
Console.WriteLine(
|
|
|
|
|
$"CultCache directory-page schema={schemaName} count={group.Count} bytes={group.Bytes} " +
|
|
|
|
|
$"cumulative={group.TotalMs:0.###}ms max={group.MaxMs:0.###}ms.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 01:48:56 +01:00
|
|
|
for (var index = 0; index < storedRecords.Length; index++)
|
|
|
|
|
{
|
|
|
|
|
var stored = storedRecords[index];
|
|
|
|
|
if (stored == null)
|
|
|
|
|
continue;
|
|
|
|
|
reports.Add(recordReports[index]);
|
|
|
|
|
loaded[stored.Key.Value] = stored;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static CultPersistedRecord ToIndexRecord(CultStoredDocument stored) => new()
|
|
|
|
|
{
|
|
|
|
|
Key = stored.Key.Value,
|
|
|
|
|
SchemaId = stored.Descriptor.SchemaId,
|
|
|
|
|
StoredAt = stored.StoredAt,
|
|
|
|
|
Payload = Array.Empty<byte>()
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-19 00:11:20 +02:00
|
|
|
private static CultPersistedRecord ToContentAddressedIndexRecord(
|
|
|
|
|
CultPersistedRecord record,
|
|
|
|
|
byte[] pagePayload) => new()
|
|
|
|
|
{
|
|
|
|
|
Key = record.Key,
|
|
|
|
|
SchemaId = record.SchemaId,
|
|
|
|
|
StoredAt = record.StoredAt,
|
|
|
|
|
Payload = HashPayload(pagePayload)
|
|
|
|
|
};
|
|
|
|
|
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
// A missing manifest is an empty store. An existing manifest in any other format is refused.
|
2026-05-31 17:32:42 +01:00
|
|
|
private CultPersistedStoreSnapshot ReadManifest()
|
|
|
|
|
{
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
if (!File.Exists(_manifestFile.FullName))
|
2026-05-31 17:32:42 +01:00
|
|
|
{
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
return new CultPersistedStoreSnapshot
|
2026-05-31 17:32:42 +01:00
|
|
|
{
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
FormatVersion = IndexedFormatVersion,
|
|
|
|
|
SchemaCatalog = Array.Empty<CultSchemaCatalogEntry>(),
|
|
|
|
|
Records = Array.Empty<CultPersistedRecord>()
|
|
|
|
|
};
|
2026-05-31 17:32:42 +01:00
|
|
|
}
|
|
|
|
|
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(ReadAllBytesShared(_manifestFile.FullName));
|
|
|
|
|
if (!string.Equals(snapshot.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal))
|
2026-05-31 17:32:42 +01:00
|
|
|
{
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Directory store {_manifestFile.FullName} is {snapshot.FormatVersion}; only {IndexedFormatVersion} is readable.");
|
2026-05-31 17:32:42 +01:00
|
|
|
}
|
|
|
|
|
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
return snapshot;
|
2026-05-31 17:32:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-13 17:18:07 +01:00
|
|
|
// Explicit compatibility path for records written before catalog precommit existed.
|
|
|
|
|
private IReadOnlyCollection<CultSchemaCatalogEntry> ResolveLegacyUncataloguedRecordCatalog(
|
2026-06-27 14:34:53 +01:00
|
|
|
CultPersistedRecord record,
|
|
|
|
|
IReadOnlyCollection<CultSchemaCatalogEntry> manifestCatalog)
|
|
|
|
|
{
|
|
|
|
|
if (manifestCatalog.Any(entry => string.Equals(entry.SchemaId, record.SchemaId, StringComparison.Ordinal)))
|
|
|
|
|
{
|
|
|
|
|
return manifestCatalog;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var schemaVersion = TryReadPayloadSchemaVersion(record.Payload);
|
|
|
|
|
if (string.IsNullOrWhiteSpace(schemaVersion))
|
|
|
|
|
{
|
|
|
|
|
return manifestCatalog;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var schemaName = InferSchemaName(schemaVersion);
|
|
|
|
|
if (string.IsNullOrWhiteSpace(schemaName))
|
|
|
|
|
{
|
|
|
|
|
return manifestCatalog;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return manifestCatalog
|
|
|
|
|
.Append(new CultSchemaCatalogEntry
|
|
|
|
|
{
|
|
|
|
|
SchemaId = record.SchemaId,
|
|
|
|
|
SchemaName = schemaName,
|
|
|
|
|
SchemaVersion = schemaVersion,
|
|
|
|
|
ContentHash = record.SchemaId,
|
|
|
|
|
CanonicalSchemaJson = "",
|
|
|
|
|
CompatibleSchemaIds = Array.Empty<string>(),
|
|
|
|
|
Members = Array.Empty<CultSchemaMemberCatalogEntry>()
|
|
|
|
|
})
|
|
|
|
|
.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string TryReadPayloadSchemaVersion(byte[] payload)
|
|
|
|
|
{
|
|
|
|
|
if (payload == null || payload.Length == 0)
|
|
|
|
|
{
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var reader = new MessagePackReader(payload);
|
|
|
|
|
if (reader.NextMessagePackType != MessagePackType.Array)
|
|
|
|
|
{
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (reader.ReadArrayHeader() <= 0)
|
|
|
|
|
{
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return reader.NextMessagePackType == MessagePackType.String
|
|
|
|
|
? reader.ReadString() ?? ""
|
|
|
|
|
: "";
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string InferSchemaName(string schemaVersion)
|
|
|
|
|
{
|
|
|
|
|
var versionMarker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal);
|
|
|
|
|
return versionMarker > 0 && versionMarker + 2 < schemaVersion.Length && char.IsDigit(schemaVersion[versionMarker + 2])
|
|
|
|
|
? schemaVersion.Substring(0, versionMarker)
|
|
|
|
|
: "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 00:11:20 +02:00
|
|
|
private string ContentAddressedRecordPath(CultPersistedRecord metadata)
|
|
|
|
|
{
|
|
|
|
|
if (metadata.Payload == null || metadata.Payload.Length != 32)
|
|
|
|
|
throw new InvalidDataException($"Record '{metadata.Key}' has no committed SHA-256 page identity.");
|
|
|
|
|
var hash = BitConverter.ToString(metadata.Payload).Replace("-", string.Empty).ToLowerInvariant();
|
|
|
|
|
return Path.Combine(_recordDirectory.FullName, $"{hash}.msgpack");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static byte[] HashPayload(byte[] payload)
|
|
|
|
|
{
|
|
|
|
|
using var sha256 = SHA256.Create();
|
|
|
|
|
return sha256.ComputeHash(payload);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 23:42:15 +02:00
|
|
|
private void DeleteUnreferencedRecordPages(IEnumerable<CultPersistedRecord> durableRecords)
|
|
|
|
|
{
|
|
|
|
|
var referenced = durableRecords
|
2026-08-19 00:11:20 +02:00
|
|
|
.Select(ContentAddressedRecordPath)
|
2026-08-18 23:42:15 +02:00
|
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
foreach (var file in _recordDirectory.EnumerateFiles("*.msgpack"))
|
|
|
|
|
{
|
|
|
|
|
if (referenced.Contains(file.FullName))
|
|
|
|
|
continue;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
File.Delete(file.FullName);
|
|
|
|
|
}
|
|
|
|
|
catch (IOException)
|
|
|
|
|
{
|
|
|
|
|
// An orphan is harmless: no committed manifest references it.
|
|
|
|
|
}
|
|
|
|
|
catch (UnauthorizedAccessException)
|
|
|
|
|
{
|
|
|
|
|
// Cleanup is best effort and never changes the committed generation.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-31 17:32:42 +01:00
|
|
|
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
private CultPersistedRecord ReadPersistedRecordPage(CultPersistedRecord metadata, out byte[] pagePayload)
|
2026-08-19 02:17:38 +02:00
|
|
|
{
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
var path = ContentAddressedRecordPath(metadata);
|
2026-08-19 02:17:38 +02:00
|
|
|
if (!File.Exists(path))
|
|
|
|
|
throw new InvalidDataException($"Committed record page '{path}' is missing from the selected manifest generation.");
|
|
|
|
|
pagePayload = ReadAllBytesShared(path);
|
Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.
A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.
Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
|
|
|
if (!HashPayload(pagePayload).SequenceEqual(metadata.Payload))
|
2026-08-19 02:17:38 +02:00
|
|
|
throw new InvalidDataException($"Record page '{path}' does not match its committed content hash.");
|
|
|
|
|
var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload);
|
|
|
|
|
if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal))
|
|
|
|
|
throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'.");
|
|
|
|
|
return record;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 17:32:42 +01:00
|
|
|
private static void WriteFileAtomically(string path, byte[] payload)
|
|
|
|
|
{
|
|
|
|
|
var directory = Path.GetDirectoryName(path)!;
|
|
|
|
|
Directory.CreateDirectory(directory);
|
|
|
|
|
var tempPath = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using (var stream = new FileStream(
|
|
|
|
|
tempPath,
|
|
|
|
|
FileMode.CreateNew,
|
|
|
|
|
FileAccess.Write,
|
|
|
|
|
FileShare.None,
|
|
|
|
|
bufferSize: 4096,
|
|
|
|
|
FileOptions.WriteThrough))
|
|
|
|
|
{
|
|
|
|
|
stream.Write(payload, 0, payload.Length);
|
|
|
|
|
stream.Flush(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (File.Exists(path))
|
|
|
|
|
{
|
2026-07-02 21:00:36 +01:00
|
|
|
ReplaceExistingFile(tempPath, path);
|
2026-05-31 17:32:42 +01:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
File.Move(tempPath, path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (File.Exists(tempPath))
|
|
|
|
|
{
|
|
|
|
|
File.Delete(tempPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-02 21:00:36 +01:00
|
|
|
|
2026-07-18 18:35:39 +01:00
|
|
|
private static byte[] ReadAllBytesShared(string path)
|
|
|
|
|
{
|
|
|
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
|
|
|
using var buffer = new MemoryStream();
|
|
|
|
|
stream.CopyTo(buffer);
|
|
|
|
|
return buffer.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 00:11:20 +02:00
|
|
|
private FileStream AcquireCommitLease()
|
|
|
|
|
{
|
2026-08-19 00:56:37 +02:00
|
|
|
Directory.CreateDirectory(_recordDirectory.FullName);
|
2026-08-19 00:11:20 +02:00
|
|
|
var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock");
|
|
|
|
|
var started = Stopwatch.StartNew();
|
|
|
|
|
while (true)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
return new FileStream(
|
|
|
|
|
lockPath,
|
|
|
|
|
FileMode.OpenOrCreate,
|
|
|
|
|
FileAccess.ReadWrite,
|
|
|
|
|
FileShare.None,
|
|
|
|
|
bufferSize: 1,
|
|
|
|
|
FileOptions.WriteThrough);
|
|
|
|
|
}
|
|
|
|
|
catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
|
|
|
|
|
{
|
|
|
|
|
Thread.Sleep(10);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 21:00:36 +01:00
|
|
|
private static void ReplaceExistingFile(string sourcePath, string destinationPath)
|
|
|
|
|
{
|
|
|
|
|
for (var attempt = 0; attempt < 3; attempt++)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
File.Replace(sourcePath, destinationPath, null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
catch (IOException) when (attempt < 2)
|
|
|
|
|
{
|
|
|
|
|
Thread.Sleep(10 * (attempt + 1));
|
|
|
|
|
}
|
|
|
|
|
catch (UnauthorizedAccessException) when (attempt < 2)
|
|
|
|
|
{
|
|
|
|
|
Thread.Sleep(10 * (attempt + 1));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 23:42:15 +02:00
|
|
|
File.Replace(sourcePath, destinationPath, null);
|
2026-07-02 21:00:36 +01:00
|
|
|
}
|
2026-05-31 17:32:42 +01:00
|
|
|
}
|