Skip to content

Commit 0ced1f6

Browse files
committed
feat: Add project analysis document outlining bugs and performance actions
1 parent d011db8 commit 0ced1f6

1 file changed

Lines changed: 236 additions & 0 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
# Project Analysis: Bugs And Performance Actions
2+
3+
Date: 2026-06-11
4+
5+
Scope: `src/` was reviewed as the owned code. `dependencies/NEventStore` was used only for acceptance-test and contract context.
6+
7+
## Summary
8+
9+
Recommended issue order:
10+
11+
1. Fix cross-bucket contamination in `GetStreamsRequiringSnapshots`.
12+
2. Fix async paged-query infinite loop when `PageSize == 0` and the result set is empty.
13+
3. Fix Oracle `CommitStampStart` recursion.
14+
4. Harden provider duplicate detection and null binary handling.
15+
5. Replace offset/ROW_NUMBER checkpoint paging with keyset paging.
16+
6. Review query resource lifetime for unenumerated synchronous results.
17+
7. Remove SQL Server `SET ROWCOUNT` usage from snapshot/natural paging.
18+
8. Narrow snapshot projections and align indexes with fixed snapshot query.
19+
20+
## 1. Fix Cross-Bucket Snapshot Candidate Query
21+
22+
Priority: P1 correctness and performance
23+
24+
Evidence:
25+
26+
- `src/NEventStore.Persistence.Sql/SqlDialects/CommonSqlStatements.resx:182`
27+
- `GetStreamsRequiringSnapshots` joins snapshots with `ON C.BucketId = @BucketId` but does not filter `Commits` with `WHERE C.BucketId = @BucketId`.
28+
- The same join also omits `S.BucketId = C.BucketId`.
29+
30+
Impact:
31+
32+
- `GetStreamsToSnapshot(bucketId, threshold)` can return streams from other buckets.
33+
- Snapshots from another bucket with the same `StreamId` can change `SnapshotRevision`, hiding or creating snapshot candidates incorrectly.
34+
- The query groups more rows than needed, so large multi-bucket stores pay unnecessary scan/group cost.
35+
36+
Action:
37+
38+
- Add `WHERE C.BucketId = @BucketId`.
39+
- Change the snapshot join to include `S.BucketId = C.BucketId`.
40+
- Keep the existing keyset paging predicate `C.StreamId > @StreamId`.
41+
- Apply the same logical fix to Oracle SQL.
42+
43+
Verification:
44+
45+
- Add sync and async acceptance tests with same `StreamId` in buckets `a` and `b`.
46+
- Assert `GetStreamsToSnapshot("a", ...)` never returns bucket `b` streams.
47+
- Assert a snapshot in bucket `b` does not affect bucket `a` snapshot eligibility.
48+
49+
## 2. Fix Async Paged Query Infinite Loop For Empty Results
50+
51+
Priority: P1 correctness
52+
53+
Evidence:
54+
55+
- `src/NEventStore.Persistence.Sql/SqlDialects/CommonDbStatement.cs:331`
56+
- Loop condition is `while (Dialect.CanPage && recordsRead == PageSize)`.
57+
- `SqlPersistenceEngine` allows `pageSize == 0`, which means infinite page size.
58+
59+
Impact:
60+
61+
- With `PageSize == 0`, async paged reads over an empty result set loop forever for dialects where `CanPage == true`.
62+
- Affects async APIs using `ExecutePagedQueryAsync`, including checkpoint reads and stream reads.
63+
64+
Action:
65+
66+
- Base continuation on the computed local `pageSize`, not the configured `PageSize`.
67+
- Expected shape: continue only when `pageSize > 0 && recordsRead == pageSize`.
68+
69+
Verification:
70+
71+
- Add async tests for empty results with configured page size `0`.
72+
- Cover `GetFromAsync(0, observer, token)` and a bucket-scoped read.
73+
74+
## 3. Fix Oracle `CommitStampStart` Recursion
75+
76+
Priority: P1 correctness
77+
78+
Evidence:
79+
80+
- `src/NEventStore.Persistence.Sql/SqlDialects/OracleNativeDialect.cs:55`
81+
- Getter calls `MakeOracleParameter(CommitStampStart)`, which recursively invokes itself.
82+
83+
Impact:
84+
85+
- Oracle date-range reads that need `CommitStampStart` can stack overflow before executing SQL.
86+
87+
Action:
88+
89+
- Change to `MakeOracleParameter(base.CommitStampStart)`.
90+
91+
Verification:
92+
93+
- Add/enable Oracle coverage for `GetFromTo(bucketId, startDate, endDate)`.
94+
- Add a small unit test directly against `new OracleNativeDialect().CommitStampStart`.
95+
96+
## 4. Harden Duplicate Detection And Null Binary Reads
97+
98+
Priority: P2 correctness
99+
100+
Evidence:
101+
102+
- `src/NEventStore.Persistence.Sql/SqlDialects/MySqlDialect.cs:43`
103+
- MySQL duplicate detection reflects a `Number` property and immediately casts it.
104+
- `src/NEventStore.Persistence.Sql/CommitExtensions.cs:87`
105+
- `GetByteArray` returns `[default]` for `null`/`DBNull.Value`.
106+
107+
Impact:
108+
109+
- MySQL can mask non-provider exceptions with `NullReferenceException` or invalid casts during error handling.
110+
- Nullable `Headers` values can be deserialized from one zero byte instead of returning default, which can fail on legacy rows or manually inserted rows.
111+
112+
Action:
113+
114+
- Guard MySQL `IsDuplicate` for missing or non-int `Number`.
115+
- Prefer provider-specific exception checks where available.
116+
- Return `[]` for `null` and `DBNull.Value` in `GetByteArray`.
117+
118+
Verification:
119+
120+
- Unit-test `MySqlDialect.IsDuplicate` against a generic exception.
121+
- Add a commit materialization test for `Headers = NULL`.
122+
123+
## 5. Replace Offset Checkpoint Paging With Keyset Paging
124+
125+
Priority: P2 performance
126+
127+
Evidence:
128+
129+
- `src/NEventStore.Persistence.Sql/SqlPersistenceEngine.cs:347`
130+
- Checkpoint reads pass a fixed checkpoint token.
131+
- Common SQL uses `LIMIT @Limit OFFSET @Skip`.
132+
- SQL Server transforms checkpoint reads to `ROW_NUMBER()` paging in `MsSqlDialect.CommonTableExpressionPaging`.
133+
134+
Impact:
135+
136+
- Long catch-up reads become increasingly expensive as `@Skip` grows.
137+
- SQL Server recomputes row numbers for every page.
138+
- This is a hot path for polling clients and projections.
139+
140+
Action:
141+
142+
- Page checkpoint reads by last seen `CheckpointNumber` instead of offset.
143+
- Update sync and async paging delegates for checkpoint queries to set `@CheckpointNumber` to the last row.
144+
- For range reads, keep `@ToCheckpointNumber` as the upper bound.
145+
146+
Verification:
147+
148+
- Add paging tests where result count exceeds page size.
149+
- Add benchmark or integration timing for large checkpoint catch-up.
150+
- Confirm ordered, gap-free results during multi-page reads.
151+
152+
## 6. Review Synchronous Query Resource Lifetime
153+
154+
Priority: P2 correctness/operability
155+
156+
Evidence:
157+
158+
- `src/NEventStore.Persistence.Sql/SqlPersistenceEngine.cs:460`
159+
- `ExecuteQuery` opens connection/transaction/statement before returning the lazy enumerable.
160+
- Disposal depends on the returned enumerable being enumerated and disposed.
161+
162+
Impact:
163+
164+
- Callers that create but do not enumerate a result can leak an open connection/command.
165+
- This is easy to miss because most LINQ terminal operations dispose correctly.
166+
167+
Action:
168+
169+
- Consider making sync query methods iterator blocks that open resources on enumeration.
170+
- Alternatively document the disposal contract and add analyzer/test coverage for common paths.
171+
172+
Verification:
173+
174+
- Add a fake connection/statement test proving no connection opens until enumeration starts, if implementation is changed.
175+
- Add a test proving disposal on early termination.
176+
177+
## 7. Remove SQL Server `SET ROWCOUNT` Paging
178+
179+
Priority: P3 correctness/performance
180+
181+
Evidence:
182+
183+
- `src/NEventStore.Persistence.Sql/SqlDialects/MsSqlDialect.cs:21`
184+
- `GetSnapshot` prepends `SET ROWCOUNT 1`.
185+
- `NaturalPaging` uses `SET ROWCOUNT @Limit`.
186+
187+
Impact:
188+
189+
- `SET ROWCOUNT` is session-scoped behavior and easy to misuse.
190+
- Snapshot reads can use `TOP (1)` instead.
191+
- Natural paging can use dialect-specific `TOP (@Limit)` or keyset query shapes.
192+
193+
Action:
194+
195+
- Replace snapshot query with `SELECT TOP (1) ... ORDER BY StreamRevision DESC`.
196+
- Replace natural paging with explicit `TOP (@Limit)` query forms.
197+
198+
Verification:
199+
200+
- SQL Server integration tests for `GetSnapshot` and `GetStreamsToSnapshot`.
201+
- Confirm no rowcount setting affects later commands on the same connection.
202+
203+
## 8. Narrow Snapshot Projection And Index Review
204+
205+
Priority: P3 performance
206+
207+
Evidence:
208+
209+
- `src/NEventStore.Persistence.Sql/SqlDialects/CommonSqlStatements.resx:173`
210+
- `GetSnapshot` uses `SELECT *`.
211+
- Snapshot materialization needs `BucketId`, `StreamId`, `StreamRevision`, and `Payload`.
212+
213+
Impact:
214+
215+
- Low current blast radius because `Snapshots` has only four columns.
216+
- Future schema additions could make this hot path read unnecessary data.
217+
218+
Action:
219+
220+
- Select explicit columns.
221+
- After fixing `GetStreamsRequiringSnapshots`, review whether `IX_Snapshots_Stream_Revision` should include `BucketId` first or be replaced by `(BucketId, StreamId, StreamRevision)` depending on provider plans.
222+
223+
Verification:
224+
225+
- Existing snapshot acceptance tests should pass.
226+
- Compare query plans before/after for SQL Server/PostgreSQL/MySQL/SQLite.
227+
228+
## Validation Plan
229+
230+
For implementation issues created from this document:
231+
232+
1. Add focused acceptance/unit tests first where possible.
233+
2. Run nearest provider tests for the touched dialect.
234+
3. Run `dotnet build ./src/NEventStore.Persistence.Sql.Core.sln -c Release --no-restore /p:ContinuousIntegrationBuild=true`.
235+
4. Run `dotnet test ./src/NEventStore.Persistence.Sql.Core.sln -c Release --no-build` when DB prerequisites are available.
236+

0 commit comments

Comments
 (0)