|
| 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