Skip to content

Commit d7c869a

Browse files
committed
Dynamic table growth and data region shifting support
Implement dynamic table growth in the storage engine by tracking reserved record capacity per table and shifting subsequent tables' data regions when a table exceeds its reserved space. Add schema validation for record size consistency. Introduce a comprehensive test to verify data integrity across insert, update, delete, and reload cycles with table growth. Ensures robust handling of tables exceeding initial capacity without data loss.
1 parent d1512a6 commit d7c869a

2 files changed

Lines changed: 226 additions & 3 deletions

File tree

src/Perigon.MiniDb/StorageManager.cs

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public class TableMetadata
1313
public int RecordCount { get; set; }
1414
public int RecordSize { get; set; }
1515
public long DataStartOffset { get; set; }
16+
public int ReservedRecordCount { get; set; }
1617
/// <summary>
1718
/// The index of this table in the file header metadata section.
1819
/// This ensures consistent offset calculations across reloads.
@@ -88,13 +89,14 @@ private void CreateDatabase(Dictionary<string, Type> tableTypes)
8889
RecordCount = 0,
8990
RecordSize = metadata.RecordSize,
9091
DataStartOffset = currentOffset,
92+
ReservedRecordCount = INCREASE_RECORD_SIZE,
9193
TableIndex = tableIndex
9294
};
9395
_tables[kvp.Key] = tableMetadata;
9496

9597
WriteTableMetadata(writer, tableMetadata);
9698
tableIndex++;
97-
currentOffset += metadata.RecordSize * INCREASE_RECORD_SIZE;
99+
currentOffset += metadata.RecordSize * tableMetadata.ReservedRecordCount;
98100
}
99101

100102
// Freeze the metadata cache after initialization
@@ -117,9 +119,10 @@ private void WriteTableMetadata(BinaryWriter writer, TableMetadata metadata)
117119
writer.Write(metadata.RecordCount);
118120
writer.Write(metadata.RecordSize);
119121
writer.Write(metadata.DataStartOffset);
122+
writer.Write(metadata.ReservedRecordCount);
120123
writer.Write(metadata.TableIndex);
121124

122-
Span<byte> reserved = stackalloc byte[44];
125+
Span<byte> reserved = stackalloc byte[40];
123126
reserved.Clear();
124127
writer.Write(reserved);
125128
}
@@ -149,15 +152,17 @@ private void LoadDatabase()
149152
var recordCount = reader.ReadInt32();
150153
var recordSize = reader.ReadInt32();
151154
var dataStartOffset = reader.ReadInt64();
155+
var reservedRecordCount = reader.ReadInt32();
152156
var tableIndex = reader.ReadInt32();
153-
reader.ReadBytes(44); // Skip reserved
157+
reader.ReadBytes(40); // Skip reserved
154158

155159
_tables[tableName] = new TableMetadata
156160
{
157161
TableName = tableName,
158162
RecordCount = recordCount,
159163
RecordSize = recordSize,
160164
DataStartOffset = dataStartOffset,
165+
ReservedRecordCount = reservedRecordCount > 0 ? reservedRecordCount : INCREASE_RECORD_SIZE,
161166
TableIndex = tableIndex
162167
};
163168
}
@@ -173,6 +178,11 @@ private void LoadDatabase()
173178
return result;
174179

175180
var entityMetadata = GetOrCreateEntityMetadata(typeof(T));
181+
if (tableMetadata.RecordSize != entityMetadata.RecordSize)
182+
{
183+
throw new InvalidDataException(
184+
$"Schema mismatch for table '{tableName}': file RecordSize={tableMetadata.RecordSize}, expected RecordSize={entityMetadata.RecordSize} for entity '{typeof(T).FullName}'.");
185+
}
176186
byte[]? rentedBuffer = null;
177187

178188
try
@@ -222,13 +232,19 @@ private async Task SaveChangesInternalAsync<T>(string tableName, List<T> added,
222232
{
223233
var tableMetadata = _tables[tableName];
224234
var entityMetadata = GetOrCreateEntityMetadata(typeof(T));
235+
if (tableMetadata.RecordSize != entityMetadata.RecordSize)
236+
{
237+
throw new InvalidDataException(
238+
$"Schema mismatch for table '{tableName}': file RecordSize={tableMetadata.RecordSize}, expected RecordSize={entityMetadata.RecordSize} for entity '{typeof(T).FullName}'.");
239+
}
225240

226241
await using var file = new FileStream(_filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read,
227242
bufferSize: 4096, useAsync: true);
228243

229244
// Handle added records
230245
foreach (var entity in added)
231246
{
247+
await EnsureCapacityForAppendAsync(tableName, file, cancellationToken);
232248
var buffer = SerializeRecord(entity, entityMetadata);
233249
file.Seek(tableMetadata.DataStartOffset + (tableMetadata.RecordCount * tableMetadata.RecordSize), SeekOrigin.Begin);
234250
await file.WriteAsync(buffer, cancellationToken);
@@ -274,6 +290,85 @@ private async Task SaveChangesInternalAsync<T>(string tableName, List<T> added,
274290
await file.FlushAsync(cancellationToken);
275291
}
276292

293+
private async Task EnsureCapacityForAppendAsync(string tableName, FileStream file, CancellationToken cancellationToken)
294+
{
295+
var tableMetadata = _tables[tableName];
296+
if (tableMetadata.RecordCount < tableMetadata.ReservedRecordCount)
297+
{
298+
return;
299+
}
300+
301+
int growByRecords = Math.Max(INCREASE_RECORD_SIZE, tableMetadata.ReservedRecordCount);
302+
long growByBytes = (long)growByRecords * tableMetadata.RecordSize;
303+
304+
var ordered = _tables.Values
305+
.OrderBy(t => t.DataStartOffset)
306+
.ToArray();
307+
308+
int tableIndex = Array.FindIndex(ordered, t => string.Equals(t.TableName, tableName, StringComparison.Ordinal));
309+
if (tableIndex < 0)
310+
{
311+
throw new InvalidOperationException($"Table '{tableName}' not found.");
312+
}
313+
314+
// Shift subsequent tables (data + offsets) backwards from end to avoid overwrite.
315+
for (int i = ordered.Length - 1; i > tableIndex; i--)
316+
{
317+
var t = ordered[i];
318+
if (t.RecordCount <= 0)
319+
{
320+
t.DataStartOffset += growByBytes;
321+
continue;
322+
}
323+
324+
long srcStart = t.DataStartOffset;
325+
long bytesToMove = (long)t.RecordCount * t.RecordSize;
326+
long srcEndExclusive = srcStart + bytesToMove;
327+
long destStart = srcStart + growByBytes;
328+
329+
await MoveRegionAsync(file, srcStart, srcEndExclusive, destStart, cancellationToken);
330+
t.DataStartOffset = destStart;
331+
}
332+
333+
tableMetadata.ReservedRecordCount += growByRecords;
334+
335+
foreach (var t in ordered)
336+
{
337+
await UpdateTableMetadataAsync(t.TableName, file, cancellationToken);
338+
}
339+
}
340+
341+
private static async Task MoveRegionAsync(FileStream file, long srcStart, long srcEndExclusive, long destStart, CancellationToken cancellationToken)
342+
{
343+
const int bufferSize = 64 * 1024;
344+
byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
345+
try
346+
{
347+
long remaining = srcEndExclusive - srcStart;
348+
while (remaining > 0)
349+
{
350+
int toRead = (int)Math.Min(buffer.Length, remaining);
351+
long readPos = srcStart + (remaining - toRead);
352+
long writePos = destStart + (remaining - toRead);
353+
354+
file.Seek(readPos, SeekOrigin.Begin);
355+
int read = await file.ReadAsync(buffer.AsMemory(0, toRead), cancellationToken);
356+
if (read != toRead)
357+
{
358+
throw new EndOfStreamException($"Unexpected EOF while moving data region. Expected {toRead} bytes, got {read}.");
359+
}
360+
361+
file.Seek(writePos, SeekOrigin.Begin);
362+
await file.WriteAsync(buffer.AsMemory(0, toRead), cancellationToken);
363+
remaining -= toRead;
364+
}
365+
}
366+
finally
367+
{
368+
ArrayPool<byte>.Shared.Return(buffer);
369+
}
370+
}
371+
277372
private async Task UpdateTableMetadataAsync(string tableName, FileStream file, CancellationToken cancellationToken = default)
278373
{
279374
var tableMetadata = _tables[tableName];

tests/Perigon.MiniDb.Tests/ComplexDataAccessTests.cs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,134 @@ public async ValueTask DisposeAsync()
205205
}
206206
}
207207

208+
[Fact]
209+
public async Task WhenGrowingTablesThenDataRemainsConsistentAcrossInsertUpdateDeleteAndReload()
210+
{
211+
// Cycle 1: Insert enough records to exceed initial reserved capacity (10) for multiple tables
212+
var db1 = new ComplexDbContext();
213+
await using (db1)
214+
{
215+
for (int i = 0; i < 25; i++)
216+
{
217+
db1.Solutions.Add(new Solution
218+
{
219+
Name = $"Solution-{i}",
220+
DisplayName = $"Solution Display {i}",
221+
Path = $"C:\\Solutions\\Solution-{i}\\Deep\\Path",
222+
Version = "1.0.0",
223+
SolutionType = (SolutionType)((i % 4) + 1),
224+
ConfigJsonString = JsonSerializer.Serialize(new { index = i, value = new string('X', 100) })
225+
});
226+
227+
db1.Projects.Add(new Project
228+
{
229+
ProjectName = $"Project-{i}",
230+
ProjectPath = $"C:\\Projects\\Project-{i}",
231+
FrameworkVersion = "net10.0",
232+
ProjectType = (ProjectType)((i % 4) + 1),
233+
Status = ProjectStatus.Active,
234+
CreatedAt = DateTime.UtcNow,
235+
SolutionId = (i % 25) + 1
236+
});
237+
238+
db1.Configurations.Add(new AppConfiguration
239+
{
240+
ConfigKey = $"Key-{i}",
241+
ConfigValue = new string('V', 100),
242+
ConfigType = (ConfigType)((i % 4) + 1),
243+
IsEncrypted = i % 2 == 0,
244+
UpdatedAt = DateTime.UtcNow
245+
});
246+
247+
db1.ApiDocumentations.Add(new ApiDocumentation
248+
{
249+
ApiName = $"Api-{i}",
250+
Endpoint = $"/api/{i}",
251+
MethodType = (ApiMethodType)((i % 5) + 1),
252+
JsonSchema = JsonSerializer.Serialize(new { type = "object", i }),
253+
IsPublished = true,
254+
CreatedAt = DateTime.UtcNow,
255+
ProjectId = (i % 25) + 1
256+
});
257+
}
258+
259+
await db1.SaveChangesAsync();
260+
Assert.Equal(25, db1.Solutions.Count);
261+
Assert.Equal(25, db1.Projects.Count);
262+
Assert.Equal(25, db1.Configurations.Count);
263+
Assert.Equal(25, db1.ApiDocumentations.Count);
264+
}
265+
266+
await ComplexDbContext.ReleaseSharedCacheAsync(_testDbPath);
267+
await Task.Delay(10);
268+
269+
// Cycle 2: Update some records and delete some records
270+
var db2 = new ComplexDbContext();
271+
await using (db2)
272+
{
273+
// Update first 5 in each table
274+
foreach (var s in db2.Solutions.OrderBy(s => s.Id).Take(5))
275+
{
276+
s.DisplayName = $"Updated-{s.DisplayName}";
277+
db2.Solutions.Update(s);
278+
}
279+
280+
foreach (var p in db2.Projects.OrderBy(p => p.Id).Take(5))
281+
{
282+
p.Status = ProjectStatus.Archived;
283+
db2.Projects.Update(p);
284+
}
285+
286+
foreach (var c in db2.Configurations.OrderBy(c => c.Id).Take(5))
287+
{
288+
c.ConfigValue = "Updated";
289+
db2.Configurations.Update(c);
290+
}
291+
292+
foreach (var a in db2.ApiDocumentations.OrderBy(a => a.Id).Take(5))
293+
{
294+
a.IsPublished = false;
295+
db2.ApiDocumentations.Update(a);
296+
}
297+
298+
// Delete last 7 in each table
299+
foreach (var s in db2.Solutions.OrderByDescending(s => s.Id).Take(7).ToArray())
300+
db2.Solutions.Remove(s);
301+
foreach (var p in db2.Projects.OrderByDescending(p => p.Id).Take(7).ToArray())
302+
db2.Projects.Remove(p);
303+
foreach (var c in db2.Configurations.OrderByDescending(c => c.Id).Take(7).ToArray())
304+
db2.Configurations.Remove(c);
305+
foreach (var a in db2.ApiDocumentations.OrderByDescending(a => a.Id).Take(7).ToArray())
306+
db2.ApiDocumentations.Remove(a);
307+
308+
await db2.SaveChangesAsync();
309+
}
310+
311+
await ComplexDbContext.ReleaseSharedCacheAsync(_testDbPath);
312+
await Task.Delay(10);
313+
314+
// Cycle 3: Reload and verify counts and Id uniqueness are correct
315+
var db3 = new ComplexDbContext();
316+
await using (db3)
317+
{
318+
Assert.Equal(18, db3.Solutions.Count);
319+
Assert.Equal(18, db3.Projects.Count);
320+
Assert.Equal(18, db3.Configurations.Count);
321+
Assert.Equal(18, db3.ApiDocumentations.Count);
322+
323+
Assert.Equal(db3.Solutions.Count, db3.Solutions.Select(x => x.Id).Distinct().Count());
324+
Assert.Equal(db3.Projects.Count, db3.Projects.Select(x => x.Id).Distinct().Count());
325+
Assert.Equal(db3.Configurations.Count, db3.Configurations.Select(x => x.Id).Distinct().Count());
326+
Assert.Equal(db3.ApiDocumentations.Count, db3.ApiDocumentations.Select(x => x.Id).Distinct().Count());
327+
328+
// Spot-check that updates persisted
329+
Assert.All(db3.Solutions.OrderBy(s => s.Id).Take(5), s => Assert.StartsWith("Updated-", s.DisplayName));
330+
Assert.All(db3.Projects.OrderBy(p => p.Id).Take(5), p => Assert.Equal(ProjectStatus.Archived, p.Status));
331+
Assert.All(db3.Configurations.OrderBy(c => c.Id).Take(5), c => Assert.Equal("Updated", c.ConfigValue));
332+
Assert.All(db3.ApiDocumentations.OrderBy(a => a.Id).Take(5), a => Assert.False(a.IsPublished));
333+
}
334+
}
335+
208336
[Fact]
209337
public async Task CanAddAndRetrieveSingleSolutionWithEnum()
210338
{

0 commit comments

Comments
 (0)