Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/BLite.Core/Storage/StorageEngine.Memory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -675,9 +675,11 @@ private int GetOrAssignCollectionSlot(string collectionName)
$"Maximum number of per-collection files ({MaxCollections}) reached. "
+ $"Use single-file mode (no CollectionDataDirectory) for more than {MaxCollections} collections.");

// Slot → name first: a thread that finds the slot through _collectionNameToSlot may
// route a page of the collection right away, and must find its file by the slot.
var newSlot = _nextSlotIndex++;
_collectionNameToSlot[collectionName] = newSlot;
_collectionSlotToName![newSlot] = collectionName;
_collectionNameToSlot[collectionName] = newSlot;
SaveCollectionSlots();
return newSlot;
}
Expand All @@ -699,8 +701,8 @@ private void LoadCollectionSlots()
#endif
var name = line.Substring(sep + 1);
if (string.IsNullOrEmpty(name)) continue;
_collectionNameToSlot![name] = slot;
_collectionSlotToName![slot] = name;
_collectionNameToSlot![name] = slot;
if (slot >= _nextSlotIndex)
_nextSlotIndex = slot + 1;
}
Expand Down
8 changes: 5 additions & 3 deletions src/BLite.Core/Storage/StorageEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,11 @@ public sealed partial class StorageEngine : IDisposable
private readonly ConcurrentDictionary<string, Lazy<IPageStorage>>? _collectionFiles;

// Collection slot registry — only populated in multi-file mode
// Maps collection name → slot index (0-63) and slot → name
// Maps collection name → slot index (0-63) and slot → name. Both are written under
// _collectionSlotLock but read without it: every collection page I/O resolves its slot
// (GetPageFile), so both must be safe for readers that run during a write (#312).
private readonly ConcurrentDictionary<string, int>? _collectionNameToSlot;
private readonly Dictionary<int, string>? _collectionSlotToName;
private readonly ConcurrentDictionary<int, string>? _collectionSlotToName;
private int _nextSlotIndex;
private readonly string? _slotsFilePath;
private readonly object _collectionSlotLock = new();
Expand Down Expand Up @@ -309,7 +311,7 @@ public StorageEngine(string databasePath, PageFileConfig config)
Directory.CreateDirectory(config.CollectionDataDirectory);
_collectionFiles = new ConcurrentDictionary<string, Lazy<IPageStorage>>(StringComparer.OrdinalIgnoreCase);
_collectionNameToSlot = new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase);
_collectionSlotToName = new Dictionary<int, string>();
_collectionSlotToName = new ConcurrentDictionary<int, string>();
_slotsFilePath = Path.Combine(config.CollectionDataDirectory, ".slots");
LoadCollectionSlots();
}
Expand Down
68 changes: 68 additions & 0 deletions tests/BLite.Tests/MultiFileStorageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -792,4 +792,72 @@ public async Task ConcurrentAllocateAndRead_NoCrash()
await Task.WhenAll(tasks);
Assert.Empty(errors);
}

/// <summary>
/// Every read or write of a collection page looks its slot up to find the collection file,
/// without the slot lock. Creating collections at the same time must not make that lookup
/// miss: a miss sent the page to the main file ("Page ... is outside the page file", #312).
/// Readers race the lookup against every resize of the slot map, up to
/// <see cref="StorageEngine.MaxCollections"/> collections; one of them also reads the page
/// through the engine, so the end-to-end symptom is covered as well. The slots are assigned
/// without creating the collection files, which keeps a round cheap enough to run many.
/// </summary>
[Fact]
public void CollectionPageRouting_WhileCollectionsAreCreated_FindsTheCollectionFile()
{
const int rounds = 100;
var readers = Math.Max(2, Environment.ProcessorCount - 1);
const System.Reflection.BindingFlags Private = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
var lookup = typeof(StorageEngine).GetMethod("GetCollectionNameBySlot", Private)!;
var assign = typeof(StorageEngine).GetMethod("GetOrAssignCollectionSlot", Private)!;
var errors = new System.Collections.Concurrent.ConcurrentQueue<string>();

for (int round = 0; round < rounds && errors.IsEmpty; round++)
{
var dbPath = Path.Combine(_tempDir, $"slots_{round}.db");
var config = PageFileConfig.Small with { CollectionDataDirectory = Path.Combine(_tempDir, $"slots_{round}") };
using var engine = new StorageEngine(dbPath, config);
var nameBySlot = (Func<int, string?>)lookup.CreateDelegate(typeof(Func<int, string?>), engine);
var assignSlot = (Func<string, int>)assign.CreateDelegate(typeof(Func<string, int>), engine);

var pageId = engine.AllocateCollectionPage("c0");
var data = new byte[engine.PageSize];
data[0] = 0x5A;
engine.WritePageImmediate(pageId, data);

var creating = true;
var threads = Enumerable.Range(0, readers).Select(r => new Thread(() =>
{
var buf = new byte[engine.PageSize];
while (Volatile.Read(ref creating) && errors.IsEmpty)
{
try
{
if (r == 0)
{
engine.ReadPage(pageId, null, buf);
if (buf[0] != 0x5A) errors.Enqueue($"round {round}: page {pageId:X8} read back {buf[0]:X2}");
}
else if (nameBySlot(0) != "c0")
{
errors.Enqueue($"round {round}: slot 0 not found while collections were created");
}
}
catch (Exception ex)
{
errors.Enqueue($"round {round}: {ex.GetType().Name}: {ex.Message}");
}
}
}) { IsBackground = true }).ToList();
threads.ForEach(t => t.Start());

for (int c = 1; c < StorageEngine.MaxCollections; c++)
assignSlot($"c{c}");

Volatile.Write(ref creating, false);
threads.ForEach(t => t.Join());
}

Assert.True(errors.IsEmpty, string.Join("\n", errors.Take(5)));
}
}
Loading