Skip to content

Commit 2c6792c

Browse files
committed
Make the equality-seek index incrementally maintained via a bounded per-Heap mutation journal.
1 parent 36a49a1 commit 2c6792c

8 files changed

Lines changed: 508 additions & 52 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ The `*.Tests` and `*.Tests.EFCore` suites are the authoritative behavior contrac
107107

108108
### Constraints
109109
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate. Inline column-level CHECK predicates may only reference their owning column — peer references raise **Msg 8141** at CREATE TABLE (probe-confirmed verbatim wording). The walker is structural via `Expression.VisitColumnReferences` + `BooleanExpression.VisitOperandExpressions`; coverage is currently limited to common container subclasses (`Reference`, `Parenthesized`, `TwoSidedExpression`, `Cast`, `Length`) — peer refs buried in less-common containers (`DATEPART`, `SUBSTRING`, nested `CASE`, etc.) silently escape the CREATE-TABLE check and surface at INSERT instead. Table-level CHECK has no peer restriction.
110-
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree. Reads get equality-seek acceleration via a read-only lazy hash index (rebuilt on a mutation-generation bump, so writes and their locking are untouched); see indexes.md for the leading-prefix matching, MVCC/lock-plan decline rules, and the per-`Heap` cache.
110+
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree. Reads get equality-seek acceleration via an **incrementally-maintained** per-`Heap` hash index: the first seek builds it from a scan and activates the heap's bounded mutation journal, and later writes append a delta the next seek replays instead of forcing a rebuild (no per-mutation warm-up; rollback / TRUNCATE invalidate it as the safety valve; the write path's locking stays untouched). See indexes.md for leading-prefix matching, MVCC/lock-plan decline rules, the journal mechanics, and the residual-WHERE correctness invariant.
111111
- `FOREIGN KEY`: inline / table-level / named forms; all four referential actions on `ON DELETE` / `ON UPDATE`; enforced at INSERT / UPDATE / DELETE / MERGE; full `sys.foreign_keys` / `sys.foreign_key_columns`. Referential-action, cascade-cycle, PK/UNIQUE-target, and NULL-skip rules + Msg numbers in [`foreign-keys.md`](docs/claude/foreign-keys.md).
112112

113113
### Transactions

SqlServerSimulator.Tests.Internal/IndexSeekTests.cs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,4 +691,157 @@ public void RepeatableReadHint_Declines()
691691
DoesNotContain("Seek(t)", trace);
692692
HasCount(1, rows);
693693
}
694+
695+
// ---- incremental maintenance (no warm-up): the per-Heap cache applies the
696+
// mutation journal delta instead of rebuilding on every write. CacheReplay /
697+
// CacheBuild trace which path a seek took; CacheBuild means a full scan
698+
// rebuild, CacheReplay means the incremental delta. ----
699+
700+
private static List<object?> ReadRows(SimulatedDbConnection c, string sql)
701+
{
702+
using var cmd = c.CreateCommand();
703+
cmd.CommandText = sql;
704+
using var r = cmd.ExecuteReader();
705+
var rows = new List<object?>();
706+
while (r.Read())
707+
rows.Add(r.IsDBNull(0) ? null : r.GetValue(0));
708+
return rows;
709+
}
710+
711+
// Opens a connection, runs setup, warms the per-Heap seek cache (the warm
712+
// seek builds the entry and activates the journal — untraced, Sink is null),
713+
// runs `mutate`, then captures `probe`'s trace + first-column rows. The
714+
// captured read exercises the incrementally-maintained cache.
715+
private static (List<string> Trace, List<object?> Rows) WarmMutateProbe(string setup, string warm, string mutate, string probe)
716+
{
717+
var c = new Simulation().CreateDbConnection();
718+
c.Open();
719+
Exec(c, setup);
720+
_ = ReadVal(c, warm);
721+
Exec(c, mutate);
722+
IndexSeekDiagnostics.Sink = [];
723+
try
724+
{
725+
return (IndexSeekDiagnostics.Sink, ReadRows(c, probe));
726+
}
727+
finally
728+
{
729+
IndexSeekDiagnostics.Sink = null;
730+
}
731+
}
732+
733+
[TestMethod]
734+
public void InsertAfterWarmup_ReplaysDelta_FindsNewRow()
735+
{
736+
var (trace, rows) = WarmMutateProbe(
737+
TableT, "select val from t where id = 1", "insert t values (4, 40)", "select val from t where id = 4");
738+
Contains("CacheReplay", trace);
739+
DoesNotContain("CacheBuild", trace);
740+
Contains("Seek(t)", trace);
741+
HasCount(1, rows);
742+
AreEqual(40, rows[0]);
743+
}
744+
745+
[TestMethod]
746+
public void UpdateIndexedKeyAfterWarmup_RowLeavesOldBucket()
747+
{
748+
// In-place key change: the row must vanish from the old key's bucket and
749+
// appear under the new one, all via the Update journal event's
750+
// remove-old / add-new.
751+
var (trace, rows) = WarmMutateProbe(
752+
TableT, "select val from t where id = 1", "update t set id = 99 where id = 2", "select val from t where id = 2");
753+
Contains("CacheReplay", trace);
754+
DoesNotContain("CacheBuild", trace);
755+
IsEmpty(rows);
756+
}
757+
758+
[TestMethod]
759+
public void UpdateIndexedKeyAfterWarmup_RowFoundUnderNewKey()
760+
{
761+
var (trace, rows) = WarmMutateProbe(
762+
TableT, "select val from t where id = 1", "update t set id = 99 where id = 2", "select val from t where id = 99");
763+
Contains("CacheReplay", trace);
764+
DoesNotContain("CacheBuild", trace);
765+
HasCount(1, rows);
766+
AreEqual(50, rows[0]);
767+
}
768+
769+
[TestMethod]
770+
public void DeleteAfterWarmup_ReplaysDelta_RowGone()
771+
{
772+
var (trace, rows) = WarmMutateProbe(
773+
TableT, "select val from t where id = 1", "delete from t where id = 2", "select val from t where id = 2");
774+
Contains("CacheReplay", trace);
775+
DoesNotContain("CacheBuild", trace);
776+
IsEmpty(rows);
777+
}
778+
779+
[TestMethod]
780+
public void RolledBackInsert_InvalidatesJournal_RebuildsAndExcludes()
781+
{
782+
// Rollback rewinds the heap by mutating pages directly (no reversing
783+
// journal events), so it invalidates the journal — the next seek must
784+
// rebuild from the rewound state and not surface the rolled-back row.
785+
var c = new Simulation().CreateDbConnection();
786+
c.Open();
787+
Exec(c, TableT);
788+
_ = ReadVal(c, "select val from t where id = 1");
789+
Exec(c, "begin tran");
790+
Exec(c, "insert t values (4, 40)");
791+
Exec(c, "rollback");
792+
IndexSeekDiagnostics.Sink = [];
793+
try
794+
{
795+
var rows = ReadRows(c, "select val from t where id = 4");
796+
Contains("CacheBuild", IndexSeekDiagnostics.Sink);
797+
DoesNotContain("CacheReplay", IndexSeekDiagnostics.Sink);
798+
IsEmpty(rows);
799+
}
800+
finally
801+
{
802+
IndexSeekDiagnostics.Sink = null;
803+
}
804+
}
805+
806+
[TestMethod]
807+
public void TruncateAfterWarmup_InvalidatesJournal_Rebuilds()
808+
{
809+
var (trace, rows) = WarmMutateProbe(
810+
TableT, "select val from t where id = 1", "truncate table t", "select val from t where id = 2");
811+
Contains("CacheBuild", trace);
812+
DoesNotContain("CacheReplay", trace);
813+
IsEmpty(rows);
814+
}
815+
816+
[TestMethod]
817+
public void BulkInsertBeyondJournalCap_FallsBackToRebuild()
818+
{
819+
// The journal is bounded; a single insert that overruns the cap drops the
820+
// oldest events and advances the dropped-through generation past the warm
821+
// cache's, forcing a rebuild — which still returns the correct row.
822+
var (trace, rows) = WarmMutateProbe(
823+
TableT,
824+
"select val from t where id = 1",
825+
"insert t (id, val) select value, value * 10 from generate_series(1000, 1800)",
826+
"select val from t where id = 1500");
827+
Contains("CacheBuild", trace);
828+
Contains("Seek(t)", trace);
829+
HasCount(1, rows);
830+
AreEqual(15000, rows[0]);
831+
}
832+
833+
[TestMethod]
834+
public void InterleavedInsertSeekLoop_NeverStale()
835+
{
836+
// Each insert/seek cycle must see the row just inserted — the regression
837+
// an incrementally-maintained cache risks is a stale read after a write.
838+
var c = new Simulation().CreateDbConnection();
839+
c.Open();
840+
Exec(c, "create table t (id int not null primary key, val int not null)");
841+
for (var i = 1; i <= 50; i++)
842+
{
843+
Exec(c, $"insert t values ({i}, {i * 100})");
844+
AreEqual(i * 100, ReadVal(c, $"select val from t where id = {i}"));
845+
}
846+
}
694847
}

SqlServerSimulator/Parser/IndexSeekDiagnostics.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ internal static class IndexSeekDiagnostics
1616
/// <summary>
1717
/// Per-thread decision log: <c>Seek(table)</c> when a scan narrows to an
1818
/// index seek, <c>Scan(table)</c> when an eligible single-base-table scan
19-
/// keeps its full scan. A test assigns a fresh list, drives a query to
19+
/// keeps its full scan, plus the per-Heap cache's own maintenance trace —
20+
/// <c>CacheBuild</c> when a seek (re)builds an entry from a full scan and
21+
/// <c>CacheReplay</c> when it instead applies the incremental journal delta
22+
/// (the "no warm-up" path). A test assigns a fresh list, drives a query to
2023
/// completion on the same thread (execution is synchronous and in-process),
2124
/// then inspects the entries. Null disables capture.
2225
/// </summary>

0 commit comments

Comments
 (0)