-
Notifications
You must be signed in to change notification settings - Fork 679
.pr_agent_accepted_suggestions
| PR 5678 (2026-07-15) |
[reliability] FakeLifetime CTS not disposed
FakeLifetime CTS not disposed
`FakeLifetime` creates a `CancellationTokenSource` (`_stopping`) but never disposes it, which can leak registrations/resources and violates CTS lifecycle ownership expectations. Even in tests, this can accumulate across runs and mask disposal issues.FakeLifetime creates a CancellationTokenSource but never disposes it.
PR compliance requires the creator of a CancellationTokenSource to manage its lifecycle (cancel/dispose). In this test helper, _stopping is canceled but not disposed.
- src/KurrentDB.Core.XUnit.Tests/Services/Transport/Grpc/GrpcStreamingShutdownMiddlewareTests.cs[113-119]
[reliability] Client streaming not cancelled
Client streaming not cancelled
GrpcStreamingShutdownMiddleware only links ApplicationStopping for ServerStreaming and DuplexStreaming calls, so MethodType.ClientStreaming RPCs bypass shutdown cancellation and can remain active during graceful shutdown. This leaves a class of streaming calls (e.g., Streams.Append) able to delay Kestrel draining if the client keeps the request stream open.GrpcStreamingShutdownMiddleware cancels only server-streaming and duplex-streaming gRPC calls on IHostApplicationLifetime.ApplicationStopping. Client-streaming calls still use the original HttpContext.RequestAborted, so they may remain in-flight during shutdown if the client holds the request stream open.
The codebase defines at least one client-streaming RPC (Streams.Append). The PR goal is to cancel open streaming gRPC calls during shutdown so Kestrel drains promptly.
- src/KurrentDB.Core/Services/Transport/Grpc/GrpcStreamingShutdownMiddleware.cs[27-33]
- src/KurrentDB.Core.XUnit.Tests/Services/Transport/Grpc/GrpcStreamingShutdownMiddlewareTests.cs[18-55]
| PR 5667 (2026-07-06) |
[correctness] API V2 ignores config
API V2 ignores config
The new GrpcOptions.CompressionLevel setting is only applied in ClusterVNodeStartup, but API V2 gRPC services still enable gzip compression using the default CompressionLevel.Optimal via WithGrpcService/WithCompression. As a result, setting CompressionLevel=NoCompression will not actually disable response compression for API V2 endpoints.The PR introduces GrpcOptions.CompressionLevel and uses it to control response compression in ClusterVNodeStartup, but API V2 gRPC wiring still unconditionally enables gzip compression with default CompressionLevel.Optimal. This makes the new setting behave inconsistently across gRPC endpoints.
KurrentDB.Plugins.Api.V2.ApiV2Plugin registers services with WithGrpcService, which calls options.WithCompression() without consulting ClusterVNodeOptions.Grpc.CompressionLevel.
- src/KurrentDB.Api.V2/Infrastructure/DependencyInjection/GrpcServerBuilderExtensions.cs[22-26]
- src/KurrentDB.Api.V2/Infrastructure/Grpc/Compression/GrpcCompressionExtensions.cs[11-26]
- src/KurrentDB.Plugins.Api.V2/ApiV2Plugin.cs[24-57]
- src/KurrentDB.Core/ClusterVNodeStartup.cs[291-316]
Make API V2 service compression honor ClusterVNodeOptions.Grpc.CompressionLevel:
- Option A (localized): In
ApiV2Plugin.ConfigureServices, addservices.Configure<GrpcServiceOptions<IndexesService>>((sp, options) => { ... })and similarly forStreamsService, readingsp.GetRequiredService<ClusterVNodeOptions>().Grpc.CompressionLeveland applying:-
options.WithoutCompression()whenNoCompression -
options.WithCompression(level)otherwise
-
- Option B (more general): Refactor
WithGrpcServiceto not force compression by default, or to configure compression via an overload that can accessClusterVNodeOptions.
Ensure the gzip provider remains registered if you still need request decompression when response compression is disabled.
| PR 5665 (2026-07-01) |
[reliability] `CallbackEnvelope` used with `TCS`
`CallbackEnvelope` used with `TCS`
The new gRPC `TruncateParked` handler awaits a bus reply using `CallbackEnvelope` + `TaskCompletionSource`, which can run continuations on the bus dispatch thread and risks deadlocks/unsafe reentrancy. This violates the required `TcsEnvelope` pattern for awaiting bus responses.The new gRPC TruncateParked implementation publishes a bus message using CallbackEnvelope and manually waits via TaskCompletionSource, which violates the repository’s safe envelope pattern.
The codebase provides TcsEnvelope<T> which uses RunContinuationsAsynchronously and avoids continuations running on the bus thread.
- src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.TruncateParked.cs[19-49]
[maintainability] Unnamed boolean argument `true`
Unnamed boolean argument `true`
`BeginMarkParkedMessagesReprocessed(truncateBefore, null, true)` passes a boolean literal positionally, obscuring the meaning at the call site. This reduces readability and violates the requirement to use named boolean arguments.A boolean literal is passed positionally, making it unclear what the true value represents.
The method parameter is named updateOldestParkedMessage, so the call site should use a named argument for clarity.
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscription.cs[589-590]
[correctness] Oldest parked timestamp cleared
Oldest parked timestamp cleared
PersistentSubscription.TruncateParkedMessages always calls BeginMarkParkedMessagesReprocessed(truncateBefore, null, true), which clears the cached oldest parked timestamp even when truncating only up to stopAt (leaving parked messages). This makes PersistentSubscriptionStats report OldestParkedMessage as 0/empty despite remaining parked messages.TruncateParkedMessages clears the cached oldest parked message timestamp by calling BeginMarkParkedMessagesReprocessed(..., null, true) unconditionally. When truncating only part of the parked stream, parked messages still remain but OldestParkedMessage stats become incorrect.
-
PersistentSubscriptionStatscomputesOldestParkedMessagefromMessageParker.GetOldestParkedMessage. -
PersistentSubscriptionMessageParker.BeginMarkParkedMessagesReprocessedassigns_oldestParkedMessage = timestampwhenupdateOldestParkedMessageis true.
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscription.cs[580-593]
- Only clear oldest timestamp when truncating all parked messages.
- For partial truncation, recompute the oldest remaining parked timestamp (e.g., after writing the new truncate metadata, trigger a stats refresh via
BeginLoadStats, or introduce a parker API to read the first remaining event timestamp fromtruncateBefore).
[reliability] No cache refresh on NoStream
No cache refresh on NoStream
PersistentSubscription.TruncateParkedMessages returns early when BeginReadEndSequence yields null (NoStream), so it performs no cache refresh/update. If the parked stream was deleted externally, the in-memory cached parked stats (count/oldest) can remain stale after calling the new API.When the parked stream does not exist (BeginReadEndSequence returns null), TruncateParkedMessages exits without updating any cached stats. This undermines the intent of the API for cases where the parked stream was removed externally.
-
BeginReadEndSequencereturnsnullonReadStreamResult.NoStream. - The parker’s cached
ParkedMessageCountdepends on cached_lastTruncateBeforeand_lastParkedEventNumber. -
BeginLoadStatsis the mechanism that resets these cached values based on the current stream state.
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscription.cs[580-593]
- In the
!end.HasValuebranch, trigger aMessageParker.BeginLoadStats(...)to resync cached stats (or introduce a more targeted reset method on the parker).
[observability] Truncate inflates replay metric
Truncate inflates replay metric
TruncateParkedMessages calls BeginReadEndSequence, which increments the parker’s _parkedMessageReplays counter, so truncation requests will be counted as parked-message replays in stats/metrics. This skews ParkedMessageReplays observability.BeginReadEndSequence increments _parkedMessageReplays, but TruncateParkedMessages now uses it for truncation, causing truncate calls to inflate the replay counter.
-
PersistentSubscriptionStatsexposesParkedMessageReplays.
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscription.cs[580-593]
- Options: add a new parker method for reading end sequence without incrementing the replay counter; or move the increment to the replay-specific path only and keep
BeginReadEndSequence“pure”.
| PR 5664 (2026-07-01) |
[reliability] `CallbackEnvelope` used with `TaskCompletionSource`
`CallbackEnvelope` used with `TaskCompletionSource`
The new gRPC `TruncateParked` handler publishes a bus message using `CallbackEnvelope` and awaits completion via a `TaskCompletionSource`, which the checklist disallows due to continuation execution risks on the bus dispatch thread. This should use `TcsEnvelope` for awaiting bus responses.TruncateParked uses CallbackEnvelope + TaskCompletionSource to await a bus response, which is disallowed by the checklist.
The code currently creates a TaskCompletionSource<TruncateParkedResp> and publishes ClientMessage.TruncateParkedMessages with new CallbackEnvelope(...), then completes the TCS from the callback. The approved pattern is to use TcsEnvelope<T> (which uses RunContinuationsAsynchronously) instead of wiring callbacks manually.
- src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.TruncateParked.cs[19-79]
[correctness] Oldest timestamp cleared
Oldest timestamp cleared
PersistentSubscription.TruncateParkedMessages passes (timestamp: null, updateOldestParkedMessage: true) to BeginMarkParkedMessagesReprocessed, which overwrites the cached oldest-parked-message timestamp with null even when parked messages still remain after a partial truncate. This makes OldestParkedMessage in persistent subscription stats/metrics incorrect.PersistentSubscription.TruncateParkedMessages calls BeginMarkParkedMessagesReprocessed(truncateBefore, null, true). In PersistentSubscriptionMessageParker, updateOldestParkedMessage == true causes _oldestParkedMessage to be set to the provided timestamp; with null this clears the cached value even when the parked stream still has events after truncation.
This breaks OldestParkedMessage reporting for partial truncations (e.g., truncating up to event #10 should set oldest to the first remaining event’s timestamp, not null).
- The parker’s “oldest parked message” is exposed via
PersistentSubscriptionStatsand exported as a metric. - Truncation currently does not read the first remaining parked event to compute the new oldest timestamp.
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscription.cs[580-592]
- src/KurrentDB.Core/Services/PersistentSubscription/IPersistentSubscriptionMessageParker.cs[1-30]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionMessageParker.cs[129-246]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStats.cs[80-125]
- Add a parker API specifically for truncation that can update both
truncateBeforeand the oldest timestamp correctly (e.g., write metadata then read-forward from the new truncate point to find the first remaining event timestamp, or detect empty stream). - Alternatively, extend
BeginMarkParkedMessagesReprocessedto support a completion callback and then refresh stats (BeginLoadStats) after the metadata write completes so_oldestParkedMessageis recomputed reliably.
[maintainability] Positional `true` boolean argument
Positional `true` boolean argument
The new truncation logic calls `BeginMarkParkedMessagesReprocessed(..., true)` using a positional boolean argument, which reduces clarity and violates the named-boolean call-site policy. This increases the risk of incorrectly passing `true/false` as the API evolves.A boolean parameter is passed positionally as true, obscuring intent.
In TruncateParkedMessages, the call to _settings.MessageParker.BeginMarkParkedMessagesReprocessed(...) includes a trailing true argument without naming, making it unclear what behavior is being enabled.
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscription.cs[585-591]
[observability] Replay metric inflated
Replay metric inflated
TruncateParkedMessages calls BeginReadEndSequence to find the last parked event number, but BeginReadEndSequence increments the ParkedMessageReplays counter. This causes truncation requests to be counted as replays, inflating the parked replay metric.PersistentSubscription.TruncateParkedMessages uses IPersistentSubscriptionMessageParker.BeginReadEndSequence(...) to determine the end of the parked stream. In PersistentSubscriptionMessageParker, BeginReadEndSequence increments _parkedMessageReplays, so truncation operations incorrectly increase the ParkedMessageReplays metric.
ParkedMessageReplays is exported in subscription stats/metrics and is intended to represent replay activity, not administrative truncations.
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscription.cs[580-592]
- src/KurrentDB.Core/Services/PersistentSubscription/IPersistentSubscriptionMessageParker.cs[1-30]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionMessageParker.cs[129-152]
- Introduce a separate parker method for “read last parked event number” that does not increment
ParkedMessageReplays, and use it fromTruncateParkedMessages. - Or add a flag/overload to
BeginReadEndSequence(e.g.,countAsReplay: bool) and passfalsefor truncation.
| PR 5643 (2026-06-17) |
[maintainability] `DeleteAsync` uses bare booleans
`DeleteAsync` uses bare booleans
The call to `ProjectionsService.DeleteAsync` passes multiple `true` literals positionally, making intent unclear and increasing the risk of parameter-order mistakes. Compliance requires named boolean arguments at call sites for readability and correctness.A call site passes multiple boolean literals positionally (e.g., true, true, true), which obscures what each flag means.
The compliance policy requires named boolean arguments to prevent misinterpretation and future bugs when signatures evolve.
- src/KurrentDB/Components/Projections/ProjectionDetail.razor.cs[155-156]
- src/KurrentDB/Components/Projections/ProjectionsService.cs[97-101]
- src/KurrentDB.Components.Tests/ProjectionsServiceAuthorizationTests.cs[82-85]
[maintainability] `EngineVersion` defaults to `1`
`EngineVersion` defaults to `1`
The PR introduces raw numeric literals like `1` for projection engine version defaults/fallbacks instead of using `ProjectionConstants.EngineV1`/`EngineDefault`. This violates the magic-number policy for versions and makes future changes/error-checking harder.Engine version is represented using the magic number 1 in new/modified code, despite an existing named constant.
Projection engine versions are a well-known sentinel/version value and should use ProjectionConstants.EngineV1 (or ProjectionConstants.EngineDefault) rather than raw numeric literals.
- src/KurrentDB.Projections.Shared/Services/ProjectionStatistics.cs[78-79]
- src/KurrentDB.Projections.Management/Services/Management/ManagedProjection.cs[320-323]
- src/KurrentDB/Components/Projections/ProjectionConfigDialog.razor[45-47]
- src/KurrentDB/Components/Projections/ProjectionDetail.razor.cs[180-185]
| PR 5639 (2026-06-09) |
[maintainability] Unnamed `requireLeader` boolean
Unnamed `requireLeader` boolean
`ClientMessage.WriteEvents.ForSingleEvent` is called with an unnamed boolean literal for `requireLeader`, reducing clarity and violating the named-boolean-arguments convention. This makes it harder to audit leadership semantics at the call site.A boolean literal is passed positionally to ForSingleEvent(...) where the parameter is requireLeader, violating the convention that boolean arguments should be named at call sites.
Naming the argument (e.g., requireLeader: true) improves readability and prevents mistakes when signatures change.
- src/KurrentDB.Projections.Management/Services/Management/ManagedProjection.cs[745-747]
[correctness] Unhandled RecordTooLarge RPCs
Unhandled RecordTooLarge RPCs
ManagedProjection now replies ProjectionManagementMessage.RecordTooLarge when a definition write would exceed TFConsts.EffectiveMaxLogRecordSize. Several projection gRPC endpoints (e.g., Enable) still treat this reply as an UnknownMessage and return StatusCode.Unknown instead of a meaningful status (like InvalidArgument).ManagedProjection can now reply with ProjectionManagementMessage.RecordTooLarge, but gRPC handlers other than Create/Update (e.g., Enable) still only handle Updated/NotFound and fall back to UnknownMessage, surfacing the new error as StatusCode.Unknown.
-
Enable/Resetset_pendingWritePersistedState = trueand use_lastReplyEnvelope, so they can traverseWriteStartOrLoadStopped()and hitFailPersistedStateWrite(). -
FailPersistedStateWrite()repliesRecordTooLarge.
- src/KurrentDB.Projections.Management/Services/Management/ManagedProjection.cs[714-760]
- src/KurrentDB.Projections.Management/Services/Management/ManagedProjection.cs[410-428]
- src/KurrentDB.Projections.Management/Services/Management/ManagedProjection.cs[542-550]
- src/KurrentDB.Projections.Management/Services/Grpc/ProjectionManagement.Enable.cs[38-49]
- src/KurrentDB.Projections.Management/Services/Grpc/ProjectionManagement.Disable.cs[40-51]
- src/KurrentDB.Projections.Management/Services/Grpc/ProjectionManagement.Reset.cs[38-49]
- src/KurrentDB.Projections.Management/Services/Grpc/ProjectionManagement.Delete.cs[41-52]
- Mirror the
Create/Updatepattern across the other RPC handlers:- Add
case ProjectionManagementMessage.RecordTooLarge tooLarge: ...RecordTooLarge(tooLarge.Reason). - Add
case ProjectionManagementMessage.OperationFailed failed: ...OperationFailed(failed.Reason).
- Add
- Consider factoring a shared helper to map
ProjectionManagementMessage.OperationFailedsubtypes consistently across all endpoints.
[correctness] gRPC errors lose semantics
gRPC errors lose semantics
ProjectionManagement.Create/Update map all ProjectionManagementMessage.OperationFailed subtypes (e.g., Conflict/NotAuthorized) to StatusCode.FailedPrecondition via OperationFailed(), so clients cannot distinguish AlreadyExists vs PermissionDenied-style failures. This can break client error handling that branches on gRPC status codes.Create/Update treat any ProjectionManagementMessage.OperationFailed (including Conflict and NotAuthorized) as StatusCode.FailedPrecondition, collapsing distinct error semantics into one status code.
-
Conflictis emitted for duplicate projection names. -
NotAuthorizedis emitted byRunAs.ValidateRunAs. - Both derive from
OperationFailedand are currently mapped through a single helper.
- src/KurrentDB.Projections.Management/Services/Grpc/ProjectionManagement.Create.cs[78-93]
- src/KurrentDB.Projections.Management/Services/Grpc/ProjectionManagement.Update.cs[48-65]
- src/KurrentDB.Projections.Management/Services/Grpc/ProjectionManagement.cs[38-55]
- Add explicit switch cases in
Create/Update:-
ProjectionManagementMessage.Conflict->new RpcException(new Status(StatusCode.AlreadyExists, reason))(or another agreed code). -
ProjectionManagementMessage.NotAuthorized->new RpcException(new Status(StatusCode.PermissionDenied, reason)). - Keep the existing
RecordTooLargemapping toInvalidArgument.
-
- Optionally keep
OperationFailedasFailedPreconditiononly for the truly-generic cases.
| PR 5628 (2026-05-13) |
[maintainability] License notice version pinned
License notice version pinned
tools/license-notices/generate-notice.ps1 overrides SharpCompress as version 0.30.1 via overridden-packages.json, so NOTICE.md (which is packed into the shipped KurrentDB artifact) will report SharpCompress as 0.30.1 regardless of any SharpCompress version change introduced by this dependency upgrade. This risks shipping inaccurate third‑party attribution and makes it harder to validate the CVE remediation outcome from this PR.The repo’s NOTICE generation hardcodes SharpCompress as 0.30.1 via tools/license-notices/overridden-packages.json, and NOTICE.md is packed into the main KurrentDB artifact. After updating package versions in this PR, the shipped/generated NOTICE cannot accurately reflect any SharpCompress version change resulting from the new dependency graph.
-
generate-notice.ps1callsnuget-licensewith--override-package-information overridden-packages.json, which includesSharpCompresswith a fixed version. -
NOTICE.mdcurrently listsSharpCompress 0.30.1and is packed into the output artifact.
- src/Directory.Packages.props[50-61]
- tools/license-notices/generate-notice.ps1[4-21]
- tools/license-notices/overridden-packages.json[57-61]
- NOTICE.md[146-149]
- src/KurrentDB/KurrentDB.csproj[50-58]
- Re-resolve dependencies (restore) with the updated package set.
- Regenerate
NOTICE.mdusingtools/license-notices/generate-notice.ps1. - Update/remove the
SharpCompressentry inoverridden-packages.jsonso it matches the resolved dependency version (or so it is no longer forcibly pinned if it can be automatically detected). - Commit the updated
NOTICE.md(and override file changes, if any).
| PR 5625 (2026-05-12) |
[maintainability] Stale license override version
Stale license override version
The repo pins SharpCompress to 0.48.0, but the license-notice override file still hard-codes SharpCompress 0.30.1, so regenerating NOTICE.md via the provided script can produce inconsistent/outdated notice metadata. This undermines repeatability of the license-notice generation process and can reintroduce stale NOTICE output later.tools/license-notices/overridden-packages.json still contains an override entry for SharpCompress version 0.30.1, but this PR pins SharpCompress to 0.48.0. The NOTICE generation script always supplies this override file, so future NOTICE regenerations can drift or become incorrect.
- SharpCompress is now pinned centrally to
0.48.0. - The NOTICE generation script passes
--override-package-information overridden-packages.json.
- tools/license-notices/overridden-packages.json[36-42]
- tools/license-notices/generate-notice.ps1[1-12]
- src/Directory.Packages.props[147-148]
- Update the SharpCompress override entry to version
0.48.0or remove the SharpCompress override entry entirely if it’s no longer needed for that version. - (Optional but recommended) Re-run
tools/license-notices/generate-notice.ps1to confirm NOTICE.md remains stable and correct after the override change.
| PR 5622 (2026-05-08) |
[maintainability] Stale NOTICE/license override
Stale NOTICE/license override
Snappier is pinned to 1.3.1, but the repo’s committed license disclosure artifacts still record Snappier as 1.0.0, making the published NOTICE/license metadata inconsistent with the dependency actually restored. This can break internal compliance expectations and/or cause incorrect license reporting for releases built from this branch.Snappier was upgraded/pinned, but the repository’s license disclosure artifacts still list the old Snappier version.
-
src/Directory.Packages.propsnow pinsSnappierto1.3.1. -
NOTICE.mdandtools/license-notices/overridden-packages.jsonstill referenceSnappier1.0.0. -
tools/license-notices/generate-notice.ps1generatesNOTICE.mdusingoverridden-packages.json.
- src/Directory.Packages.props[149-151]
- NOTICE.md[145-148]
- tools/license-notices/overridden-packages.json[42-52]
- tools/license-notices/generate-notice.ps1[1-22]
- Update (or remove, if no longer needed) the
Snappierentry intools/license-notices/overridden-packages.jsonso it matches the version being restored (1.3.1). - Re-run
tools/license-notices/generate-notice.ps1and commit the updatedNOTICE.mdso it reflects the upgraded dependency set.
| PR 5608 (2026-04-30) |
[correctness] Misleading date-range example
Misleading date-range example
The date-range example computes `append_time` via `epoch_ms(created_at)` even though `created_at` is documented as `INT64`, then filters using string dates on the computed alias, which is inconsistent and likely to produce errors or surprising results when run.The date-range query example is inconsistent with the documented type of created_at (INT64) and mixes an epoch conversion with date-string filtering on a derived alias.
This is an “Examples” code block; readers will copy/paste it. Prefer an example that clearly converts created_at to a timestamp (or filters on created_at directly) and uses standard SQL comments.
- docs/server/features/queries/flightsql.md[33-53]
[correctness] Invalid JSON operator usage
Invalid JSON operator usage
The complex example uses `data::json->>'Address'->>'Country'`, which applies a JSON operator (`->>`) to the text result of a prior `->>`, and the user-defined index examples use `data->>` without casting even though `data` is documented as `VARCHAR`.The examples should consistently cast data to JSON before using ->/->> operators, and the nested JSON access should use correct navigation (e.g., -> for object access then ->> for text extraction).
The same page says data is VARCHAR and must be cast to JSON to use arrow navigation operators.
- docs/server/features/queries/flightsql.md[33-63]
- docs/server/features/queries/flightsql.md[76-90]
[maintainability] Missing 'to' in sentence
Missing 'to' in sentence
The Introduction sentence is missing a word (“required to query”), which reduces clarity for readers configuring FlightSQL authentication requirements.The sentence “Authentication ... is required query ...” is missing “to”, making it ungrammatical.
This is user-facing documentation and is likely to be copy/pasted or read quickly; fixing the grammar improves comprehension.
- docs/server/features/queries/flightsql.md[18-22]
| PR 5607 (2026-04-29) |
[correctness] Invalid OAuth key syntax
Invalid OAuth key syntax
The new OAuth setting is documented as `OAuth::DisableCodeChallengeMethodsSupportedValidation`, but the docs’ configuration conventions use YAML nesting (`OAuth:`) and `__` for nesting in env/CLI; `::` is inconsistent and will likely mislead users into setting a non-functional key.The docs introduce a new OAuth setting using OAuth::DisableCodeChallengeMethodsSupportedValidation, but the documentation’s established configuration patterns use YAML nesting (OAuth:) and __ for nesting in env/CLI. The :: syntax is inconsistent and likely wrong.
In the OAuth docs, configuration is shown under an OAuth: section and options are listed as direct property names (e.g., DisableCodeChallengeMethodsSupportedValidation). The configuration guide documents nesting via __.
- docs/server/quick-start/whatsnew.md[64-73]
- docs/server/security/user-authentication.md[366-415]
- docs/server/configuration/configuration.md[86-98]
| PR 5606 (2026-04-29) |
[reliability] Bearer token crashes auth
Bearer token crashes auth
BasicHttpAuthenticationProvider now accepts the "Bearer" scheme and then calls Convert.FromBase64String without handling invalid input, so a non-base64 Bearer token can throw and fail the request with an unhandled exception instead of returning 401/Unauthenticated. This exception is not caught by AuthenticationMiddleware (it only suppresses cancellation), so it propagates out of the request pipeline.BasicHttpAuthenticationProvider now treats Authorization: Bearer <token> as a base64-encoded username:password. If <token> is not valid base64 (e.g., JWT/opaque bearer tokens, malformed input), Convert.FromBase64String throws and the request fails with an unhandled exception.
This is newly more likely after the PR because the provider now accepts the Bearer scheme, but still uses a non-Try* base64 decode. Authentication middleware only suppresses cancellation exceptions, so format errors will propagate.
- src/KurrentDB.Core/Services/Transport/Http/Authentication/BasicHttpAuthenticationProvider.cs[14-46]
- src/KurrentDB.Core/Services/Transport/Http/AuthenticationMiddleware.cs[28-37]
- Update
TryDecodeCredentialto:- return
falsewhenvalueis null/empty - catch
FormatException(and possiblyArgumentNullException) from base64 decoding, returningfalse - optionally use
Convert.TryFromBase64String(...)to avoid exceptions entirely
- return
- (Optional but recommended) add/extend tests to cover:
-
Authorization: Bearer not_base64should not throw; it should returnfalse(allowing other providers/anonymous handling) -
Authorization: Bearer <valid-base64-user:pass>should authenticate equivalently to Basic
-
[correctness] Handshake response can stall
Handshake response can stall
FlightSqlServer.Handshake reads until the request stream completes before writing any response, so clients that send a handshake request but don’t complete/half-close the stream promptly may see the handshake response delayed or never received until cancellation/deadline. This can break or severely slow JDBC tooling that expects a prompt handshake response.FlightSqlServer.Handshake drains the entire request stream before sending response headers and the handshake response. For a bidirectional streaming RPC, some clients may send a request and wait for the server response without completing the stream, which delays the response indefinitely (or until cancellation/deadline).
The PR adds this Handshake override to support JDBC tooling by echoing Basic credentials as a Bearer token in response headers.
- src/KurrentDB.SecondaryIndexing/FlightSql/FlightSqlServer.cs[41-61]
- Read at most the first request message (e.g., a single
await requestStream.MoveNext(...)) and then immediately:- set response headers (if needed)
- write the
FlightHandshakeResponse
- If additional client messages are possible, consider ignoring them without blocking the initial response (or document/validate expected client behavior).
| PR 5600 (2026-04-28) |
[correctness] Webhook connector not discoverable
Webhook connector not discoverable
The PR adds the `Kurrent.Connectors.Webhook` package, but the runtime only creates/validates connectors that are explicitly registered in `ConnectorCatalogue`, which currently contains no Webhook connector types. Any config that sets `ConnectorOptions.InstanceTypeName` to a webhook connector will fail validation and/or throw during connector creation.Kurrent.Connectors.Webhook is added as a dependency, but webhook connectors still cannot be created/validated because connector discovery is hard-coded to ConnectorCatalogue and webhook types aren’t registered there.
Connector creation (SystemConnectorsFactory), validation (SystemConnectorsValidation), and data protection (ConnectorsMasterDataProtector) all depend on ConnectorCatalogue.TryGetConnector(...). If the webhook connector types are meant to be supported, they must be added to the catalogue along with validator and data protector types (and any required entitlements).
- src/Connectors/KurrentDB.Connectors/Infrastructure/Connect/Components/Connectors/ConnectorCatalogue.cs[20-58]
- src/Connectors/KurrentDB.Connectors/Infrastructure/Connect/Components/Connectors/ConnectorDataProtectors.cs[16-99]
- src/Connectors/KurrentDB.Connectors/Infrastructure/Connect/Components/Connectors/ConnectorsMasterDataProtector.cs[69-76]
- src/Connectors/KurrentDB.Connectors/Infrastructure/Connect/Components/Connectors/SystemConnectorsValidation.cs[11-22]
- If webhook support is intended: add the webhook connector type(s) from
Kurrent.Connectors.Webhookinto theItemsdictionary, pointing to their validator and a new...ConnectorDataProtectorthat marks any secrets (tokens, headers, etc.) as sensitive. - If webhook support is not intended yet: remove the added
PackageReferenceand centralPackageVersionentry to avoid a misleading/unused dependency.
| PR 5592 (2026-04-23) |
[observability] Cache size metric lags
Cache size metric lags
`PartitionStateCache.Count` is maintained via increments on `Set` and decrements in the async eviction callback, so it can temporarily exceed the true bounded cache size. This value is exposed as `PartitionStateCacheSize` in HTTP stats, potentially misleading operators/alerts expecting it to be ≤ `MaxPartitionStateCacheSize`.The PartitionStateCacheSize surfaced to operators is backed by a lagging application-level counter that can temporarily exceed the configured capacity due to asynchronous eviction callbacks.
Tests already acknowledge eviction is asynchronous and avoid asserting Size <= capacity.
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[73-84]
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionStateCache.cs[37-69]
- src/KurrentDB.Projections.V2.Tests/Integration/PartitionStateCacheEvictionTests.cs[194-214]
Choose one:
- If
RandomAccessCacheexposes an internal count/current size, use that instead of the manual_countforSize. - If not available, adjust the operator surface to avoid implying a hard bound:
- rename the stat to indicate it’s approximate (and/or document it explicitly in HTTP stats output), or
- clamp/report
min(Count, capacity)plus reportcapacityseparately.
- Update test comments/assertions if the metric semantics change.
[maintainability] `ProjectionSubsystemOptions` booleans unnamed
`ProjectionSubsystemOptions` booleans unnamed
Several updated `ProjectionSubsystemOptions(...)` call sites pass boolean arguments positionally (`true`/`false`) instead of using named arguments, reducing clarity and increasing risk of misordered parameters. This violates the convention requiring named boolean arguments at call sites where it improves readability.Boolean arguments in ProjectionSubsystemOptions(...) calls are passed positionally, which is unclear and easy to break when adding/reordering parameters.
The PR added a new trailing parameter, so these call sites were touched and are now in-scope to align with the named-boolean-arguments convention.
- src/KurrentDB.Projections.JavaScript.Tests/Integration/ProjectionRuntimeScenario.cs[30-30]
- src/KurrentDB.Projections.Management.Tests/ClientAPI/specification_with_standard_projections_runnning.cs[43-46]
[maintainability] `ExpectedRevision` hardcoded `-2`
`ExpectedRevision` hardcoded `-2`
The new integration test hardcodes `ExpectedRevision = -2` ("Any") instead of using a named constant/helper, which obscures intent and embeds protocol-specific magic numbers. This violates the no-magic-numbers convention where a named representation exists.The test uses ExpectedRevision = -2 to mean "Any", which is a magic number.
There is a named representation available in the codebase (AnyStreamRevision.Any.ToInt64()), which preserves intent and avoids encoding protocol constants inline.
- src/KurrentDB.Projections.V2.Tests/Integration/PartitionStateCacheEvictionTests.cs[48-52]
[reliability] Drain uses canceled token
Drain uses canceled token
Partition processors are intentionally run with `CancellationToken.None` to drain and checkpoint, but the `loadPersistedState` callback uses the engine cancellation token (`ct`), so a cache miss during shutdown can throw `OperationCanceledException` and abort draining/checkpoint completion. Bounded caches increase cache misses, making this shutdown race more likely than before.On shutdown, partitions are designed to keep draining (partitionCt = CancellationToken.None), but persisted state loads still honor the canceled engine token. With bounded caches, a cache miss can happen during drain and cause OperationCanceledException, faulting a partition task and potentially preventing a final checkpoint from completing.
- Partitions drain to preserve correctness/checkpointing on stop.
- Cache eviction increases the probability of
LoadPartitionStateneeding to callloadPersistedStateduring drain.
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[116-133]
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionProcessor.cs[59-78]
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[276-291]
- Change the
loadPersistedStatelambda passed toPartitionProcessorto useCancellationToken.None(or the samepartitionCt) instead of the enginect. - Optionally add defensive handling in
PartitionProcessor.LoadPartitionState:- Catch
OperationCanceledExceptionfromloadPersistedStateand retry once withCancellationToken.None(to preserve the drain semantics).
- Catch
- Add/adjust a test that cancels the engine while partitions are draining and verifies the engine still reaches a final checkpoint without faulting due to persisted-state reads.
[maintainability] `MaxPartitionStateCacheSize` uses `100_000`
`MaxPartitionStateCacheSize` uses `100_000`
The new `MaxPartitionStateCacheSize` option and multiple updated call sites hardcode `100_000` rather than using a named constant/default, reducing readability and making future changes error-prone. This violates the no-magic-numbers convention for domain defaults.100_000 is hardcoded as the default/argument for MaxPartitionStateCacheSize, which violates the project convention to avoid magic numbers for domain defaults.
The codebase already uses named defaults like Opts.MaxProjectionStateSizeDefault. The new cache-size default should follow the same pattern so that changing the default is centralized.
- src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs[573-577]
- src/KurrentDB.Projections.JavaScript.Tests/Integration/ProjectionRuntimeScenario.cs[30-30]
[correctness] Restart test replays with state
Restart test replays with state
`evicted_partition_state_is_recovered_from_stream` starts engine2 from `TFPos(0,0)` while also loading the latest persisted partition state from the `-state` stream, which can apply earlier events on top of a later snapshot and yield incorrect state. This does not match the production restart path, where `CoreProjectionV2` starts the engine from the last persisted checkpoint position.PartitionStateCacheEvictionTests.evicted_partition_state_is_recovered_from_stream starts the second engine from TFPos(0,0) even though it also relies on loading the latest persisted partition snapshot from the -state stream. This can cause earlier events (before the snapshot) to be applied on top of the snapshot, producing incorrect state and making the test inconsistent with the real restart flow.
In production, CoreProjectionV2 reads the last checkpoint event and starts the engine from that checkpoint. The -state stream snapshot is implicitly tied to that checkpoint position.
- src/KurrentDB.Projections.V2.Tests/Integration/PartitionStateCacheEvictionTests.cs[306-323]
- src/KurrentDB.Projections.V2/Services/Processing/V2/CoreProjectionV2.cs[187-209]
- Parse the checkpoint position written by engine1 (from the checkpoint stream event payload), and start engine2 from that
TFPos. - Alternatively, drive the test via the same harness/path that
CoreProjectionV2uses (read checkpoint then start), rather than constructingProjectionEngineV2directly for the recovery scenario. - Keep the assertion that p1 reaches
count=2, but ensure engine2 processes only events after the checkpoint that corresponds to the persisted-statesnapshot.
| PR 5590 (2026-04-22) |
[correctness] Projection name inconsistency
Projection name inconsistency
Docs now mix hyphenated and underscored system projection names (e.g., `$stream-by-category` vs `$stream_by_category`, `$by-category` vs `$by_category`), which can lead users to call non-existent projection endpoints and get 404s. The server’s canonical projection names use underscores, so the hyphenated variants should be corrected for consistency and correctness.Some docs refer to system projections using hyphenated names (e.g. $stream-by-category, $by-category, $by-event-type) while others (including the newly-added/updated sections in this PR) use underscore names. The server code indicates the canonical names use underscores, so the hyphenated references should be updated to prevent users from copying invalid projection names into API calls.
The projections subsystem’s standard projection list uses underscore names (e.g. $stream_by_category). Docs should match this exact spelling because projection management endpoints are name-based.
- docs/server/features/projections/README.md[73-82]
- docs/server/features/projections/settings.md[40-47]
- docs/server/features/indexes/secondary.md[11-11]
- docs/server/features/indexes/secondary.md[26-26]
- src/KurrentDB.Projections.Management/ProjectionsSubsystem.cs[91-97]
- Replace
$stream-by-categorywith$stream_by_category. - Replace
$by-categorywith$by_category. - Replace
$by-event-typewith$by_event_type. - Ensure any other occurrences of these hyphenated names in docs are updated to underscore variants for consistency.
| PR 5587 (2026-04-20) |
[performance] Decodes dropped event payloads
Decodes dropped event payloads
The new event-type filter calls ConvertToProjectionEvent before checking whether the event type is handled, which decodes Data/Metadata to UTF8 strings even for events that are immediately discarded. This adds avoidable CPU/allocation overhead on streams with many filtered-out event types.The event-type filter allocates a ProjectionResolvedEvent (including UTF8-decoding data/metadata) before checking whether the event will be handled.
coreEvent.Event.EventType is already available and used in the same method for system-event checks, so the handled-type check can be done before calling ConvertToProjectionEvent.
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[155-177]
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[265-280]
- Check
handledEventTypes.Contains(coreEvent.Event.EventType)before callingConvertToProjectionEvent(coreEvent). - Only construct
ProjectionResolvedEvent(and decode payload) when the event will be dispatched (or when it must be inspected for delete handling).
[correctness] Silent fallback for `Events`
Silent fallback for `Events`
When `AllEvents` is `false`, `_config.SourceDefinition.Events ?? []` silently treats a missing `Events` list as empty, which can cause the projection to drop all events without surfacing a configuration bug. Required dependencies/config should fail fast (e.g., `?? throw`) instead of using silent fallbacks.RunReadLoop silently falls back to an empty event-type list via _config.SourceDefinition.Events ?? [] when AllEvents == false, which can drop all events without reporting a configuration error.
When a projection declares specific event types (AllEvents == false), Events should be treated as required configuration. Per compliance, missing required config should fail fast.
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[148-151]
[correctness] Checkpoint stalls on skipped
Checkpoint stalls on skipped
In ProjectionEngineV2.RunReadLoop, when an event type is not declared the code breaks out of the EventReceived case before updating lastLogPosition/bytesProcessed, so checkpoints may not advance past skipped events (especially if the tail contains only skipped types). This can cause repeated re-reading of skipped events after restart and prevent persisting EOF progress for those streams/categories.ProjectionEngineV2.RunReadLoop currently breaks on filtered-out event types before updating lastLogPosition/bytesProcessed. This prevents checkpoint markers from advancing past skipped events (regression vs V1 semantics), especially when the end of the read contains only skipped types.
- Skipped events are intentionally not dispatched to partitions, but the engine still needs to advance its notion of the last seen TF position so it can checkpoint beyond them.
- Today
lastLogPositionis only updated afterDispatchEvent, and the final checkpoint injection only happens wheneventsProcessed > 0.
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[155-217]
- Ensure
lastLogPositionadvances tologPositionfor everyEventReceived(even when the event is filtered out). - Ensure skipped events contribute to the “unhandled bytes” accounting (so
CheckpointUnhandledBytesThresholdcan trigger progress). - Update the final checkpoint condition to also inject when only skipped/unhandled events were seen since the last checkpoint (e.g.,
if (eventsProcessed > 0 || bytesProcessed > 0)orif (lastLogPosition != checkpoint)).
| PR 5585 (2026-04-16) |
[correctness] CRT write can corrupt PFX
CRT write can corrupt PFX
Program.cs always writes a public PEM to `Path.ChangeExtension(devCertPath, ".crt")`; if the user passes a dev cert path that already ends with `.crt`, the `.crt` output path equals the input path and the code overwrites/corrupts the PFX file. This can break subsequent startups because the file is no longer a valid PFX.Writing the public certificate uses Path.ChangeExtension(devCertPath, ".crt") and can produce the same path as the input, overwriting the PFX file with PEM content.
This happens when devCertPath already ends with .crt (or other edge cases where ChangeExtension returns an equal path). The code currently does not validate the extension or check for equality.
- src/KurrentDB/Program.cs[176-186]
- src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs[113-118]
- Validate
DevCertPathextension early (allow.pfx,.p12, or empty extension; reject.crt/.pem/.cerwith a clear fatal message). - Additionally guard
if (StringComparer.OrdinalIgnoreCase.Equals(crtPath, devCertPath))and choose a safe alternate output name (e.g.,${devCertPath}.public.crt) or abort with an error.
[reliability] Dev cert still missing
Dev cert still missing
When the Unix certificate store is inaccessible and --dev-cert-path is not provided, dev mode can still exit with "Could not create dev certificate" because the ephemeral fallback certificate is never reloadable from the X509 store. The new Unix ephemeral fallback only helps if a file export path is provided (so Program.cs can reload from disk).On Unix, when the user cert store cannot be used (UnauthorizedAccessException/DirectoryNotFoundException), the code falls back to an ephemeral certificate that is not stored anywhere. If --dev-cert-path is not set, Program.cs still reloads from the X509 store and can exit fatally because the store is empty.
-
UnixCertificateManager.SaveCertificateCorecreates an EphemeralKeySet cert in the fallback path and does not callstore.Add. -
CertificateManager.EnsureDevelopmentCertificatedoes not return the generated certificate and disposes it at the end. -
Program.csonly uses a file reload path whenDevCertPathis provided.
- src/KurrentDB/Program.cs[145-173]
- src/KurrentDB.Common/DevCertificates/UnixCertificateManager.cs[23-45]
- src/KurrentDB.Common/DevCertificates/CertificateManager.cs[248-310]
- Introduce an API that returns the ensured certificate to the caller (e.g., an overload like
EnsureDevelopmentCertificate(..., out X509Certificate2 certificate)), and ensure it does not dispose the returned certificate. - Update
Program.csto use the returned cert directly when store persistence fails (or when the store reload returns empty). - Keep the existing store/file behaviors for the cases where persistence is successful.
[reliability] Loaded cert not fully validated
Loaded cert not fully validated
When loading an existing certificate from --dev-cert-path, Program.cs only checks the custom OID and NotAfter and can accept a certificate that is not yet valid or lacks a private key. This can lead to TLS configuration failures later because the server certificate context requires a certificate usable for server authentication (including private key material).The --dev-cert-path load path accepts certificates that may be unusable at runtime (e.g., not-yet-valid or missing a private key).
Current checks are:
IsHttpsDevelopmentCertificate(loaded)loaded.NotAfter > UtcNow
Missing checks include:
loaded.NotBefore <= UtcNow-
loaded.HasPrivateKey(and possiblyGetRSAPrivateKey() != null)
- src/KurrentDB/Program.cs[128-142]
- Extend the acceptance condition to include
loaded.NotBefore <= now && now <= loaded.NotAfter && loaded.HasPrivateKey. - If invalid, dispose and regenerate (current behavior) with a warning that includes the reason (expired, not-yet-valid, missing private key).
| PR 5584 (2026-04-16) |
[correctness] Computed flags appear configurable
Computed flags appear configurable
TlsDisabled/AuthDisabled are read-only computed properties but will be treated as normal options by the reflection-based help/defaults/unknown-option logic, so users may see and pass flags like --tls-disabled that appear valid yet have no effect. This also pollutes --help/--what-if output with internal-only options.TlsDisabled and AuthDisabled are computed/read-only, but the options/help/unknown-option machinery reflects over all public properties and will expose these as if they were user-configurable (e.g., --tls-disabled). Because they are not bindable, passing them has no effect, creating silent misconfiguration and confusing --help/--what-if output.
The options system builds metadata/defaults/help text/known-keys via GetProperties() without checking for a setter/init accessor.
- src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs[161-170]
- src/KurrentDB.Core/Configuration/Sources/KurrentConfigurationKeys.cs[31-45]
- src/KurrentDB.Core/Configuration/ClusterVNodeOptions.Framework.cs[214-215]
- src/KurrentDB.Core/Configuration/SectionMetadata.cs[23-42]
Filter option properties to those that are actually configurable (e.g., property.SetMethod != null / property.CanWrite) when:
- building
OptionsKeys/AllKnownKeys - generating
SectionMetadata/OptionMetadata - generating default values and help text
This keeps computed properties usable internally while preventing them from appearing as CLI/config options.
[correctness] Cleartext auth not proven
Cleartext auth not proven
The disable-tls integration test sends Basic credentials to /info, but /info is allowed anonymously, so the test can pass even if credentials are ignored/blocked over cleartext transport. This misses the PR’s key guarantee: authenticated access should work over non-TLS.authenticated_request_succeeds currently targets /info, which is permitted anonymously, so the test does not prove that credentials are accepted over cleartext when disableTls: true.
The PR’s main behavioral requirement is: auth is still enforced, and valid credentials succeed, even when TLS is disabled.
- src/KurrentDB.Core.Tests/Integration/disable_tls_with_auth.cs[37-55]
Change the authenticated test to call an endpoint that requires authentication and deterministically returns 401 without a user and 200 with valid Basic auth, e.g. GET /users/$current.
- Keep the existing unauthenticated assertion, but consider aligning it to the same auth-required endpoint for clarity.
- Assert the authenticated call returns
200 OK(and optionally validate response content indicates theadminuser).
| PR 5567 (2026-03-25) |
[reliability] Forgets allow DoS range
Forgets allow DoS range
`query_events` processes `forgets` with an inclusive loop from `EventNumber` to `To` and adds every generated key into `session.Forgotten`, so a client can submit a huge range and force unbounded CPU and memory growth.QueryEventsTool iterates from EventNumber to To and unconditionally adds each computed key into session.Forgotten. A large range can cause severe CPU use and memory exhaustion.
- The loop is inclusive and runs once per number in the range.
- Each iteration constructs a new string key and inserts it into a
HashSet, growing memory linearly with the range size.
- src/KurrentDB.Kontext/QueryEventsTool.cs[74-96]
- Validate ranges:
- Require
To >= EventNumber. - Enforce a maximum range length (e.g., 1k/10k) and reject larger requests.
- Require
- Alternatively, avoid generating keys for the whole numeric range:
- Iterate over existing
session.Events.Keys(and/orsession.Forgotten) and remove/add only keys that fall within the requested range.
- Iterate over existing
- Consider adding cancellation checks inside loops (
ct.ThrowIfCancellationRequested()), and plumb aCancellationTokenintoQueryEventsif possible.
[reliability] Checkpoint skips failed batches
Checkpoint skips failed batches
`SubscriptionServiceBase.FlushBatch` clears the batch and flushes the checkpoint even if `IndexBatch` throws, so events that failed indexing can be permanently skipped on restart and never become searchable.SubscriptionServiceBase.FlushBatch persists the checkpoint even when IndexBatch fails. This can permanently skip events from indexing, causing missing results in Kontext search.
-
FlushBatchcatches exceptions fromIndexBatch, logs, then continues tobatch.Clear(),SaveIndexes(), andFlushCheckpoint(). -
FlushCheckpoint()writes the last seen commit/prepare positions to disk and resets_eventsSinceCheckpoint, so the subscription will resume after the failed batch.
- src/KurrentDB.Kontext/SubscriptionServiceBase.cs[134-171]
- src/KurrentDB.Kontext/CheckpointIO.cs[5-18]
- Only clear the batch and flush the checkpoint after a successful
IndexBatch(and ideally afterSaveIndexes). - If indexing fails, do not advance the checkpoint; either:
- rethrow to force the subscription loop to restart from the last persisted checkpoint, or
- keep the batch intact and retry indexing with backoff.
- Consider making checkpoint writes more robust (e.g., atomic write-then-rename) to avoid corruption on crash.
[security] Session hijack leaks data
Session hijack leaks data
`SessionManager` issues short (8-hex) session IDs and stores sessions globally without binding them to a user/MCP connection, so another client can guess/steal a session ID and use `view_events` to read that session’s working set.MCP search sessions are globally accessible by a short ID and are not bound to a user or MCP connection. This enables session hijack and cross-user data leakage via view_events / query_events.
-
Session.IdisGuid.NewGuid().ToString("N")[..8](32-bit space). -
SessionManager.Get(id)has no association toClaimsPrincipalor MCP session. -
ViewEventsprintssession.Eventswithout per-request authorization checks.
- src/KurrentDB.Kontext/SessionManager.cs[5-72]
- src/KurrentDB.Kontext/QueryEventsTool.cs[54-237]
- Use a strong, non-truncated ID (full GUID or cryptographically random token).
- Store and validate session ownership:
- bind to MCP server session (
server.SessionId) or - bind to
ClaimsPrincipal(e.g., NameIdentifier) and reject access from other users.
- bind to MCP server session (
- Ensure
view_events(and other session operations) re-check authorization/redact based on the current caller (or store only references and re-hydrate per call). - Consider thread-safety: if sessions can be accessed concurrently, protect
Session.Events/Session.Forgottenwith a lock or use concurrent collections.
[maintainability] Mutable `SearchResult` collections exposed
Mutable `SearchResult` collections exposed
`SearchService.SearchResult` exposes mutable `List`/`Dictionary` properties, allowing external mutation of results and weakening encapsulation. The encapsulation rule requires returning read-only collection interfaces where callers should not modify the data.SearchService.SearchResult publicly exposes mutable collections (List/Dictionary), enabling callers to mutate search results.
Search results should generally be immutable from the caller's perspective; expose IReadOnlyList/IReadOnlyDictionary (or wrap with ReadOnlyCollection/ReadOnlyDictionary) and keep mutable implementations internal.
- src/KurrentDB.Kontext/SearchService.cs[211-220]
[observability] Per-batch `LogInformation` in `FlushBatch`
Per-batch `LogInformation` in `FlushBatch`
`SubscriptionServiceBase.FlushBatch` logs a high-frequency per-batch message at `Information` level, which can flood logs during normal indexing. Policy requires per-batch/per-event logs to be below Information (e.g., Debug/Verbose).SubscriptionServiceBase.FlushBatch logs per-batch indexing progress using Logger.LogInformation(...), which violates the log-level policy for high-frequency messages.
Batch flushes can occur frequently (size-based and timer-based), so Information logs will be noisy in production.
- src/KurrentDB.Kontext/SubscriptionServiceBase.cs[153-153]
[security] Imports bypass stream authz
Imports bypass stream authz
The bulk import endpoint writes events via `ISystemClient` using `SystemAccounts.System` and does not consult `IAuthorizationProvider`, so stream ACLs can be bypassed by any caller who can reach the endpoint with a valid MCP session ID.Kontext write paths (bulk import + retain_facts) write events as SystemAccounts.System and do not verify the caller has write permissions for the target streams.
- Bulk import handler calls
ISystemClient.WriteBatchAsync(valid)without anyIAuthorizationProviderchecks. -
WriteBatchAsyncusesprincipal: SystemAccounts.System. -
retain_factswrites to$kontext-memoryusing the same system principal.
- src/KurrentDB.Plugins.Kontext/EndpointRouteBuilderExtensions.cs[18-68]
- src/KurrentDB.Kontext/SystemClientExtensions.cs[140-167]
- src/KurrentDB.Kontext/MemoryTool.cs[44-87]
- In the HTTP import handler:
- resolve
IAuthorizationProviderand validateCanWriteStreamAsync(context.User, stream)for each distinct target stream. - reject unauthorized streams with 403.
- resolve
- Avoid writing as
SystemAccounts.Systemwhen the operation should be scoped to the caller.- If you need a KurrentDB principal type, add an adapter from
HttpContext.Userto the expected principal, or use an API that accepts the current user.
- If you need a KurrentDB principal type, add an adapter from
- Apply the same principle to
retain_facts(and any other write tools): require appropriate permissions and use the caller’s principal. - If the design intentionally requires privileged writes, explicitly restrict the endpoint/tool to an admin role and document it.
[maintainability] Mutable `Session.Events` exposed
Mutable `Session.Events` exposed
`Session` publicly exposes mutable collections (`Dictionary`/`HashSet`), allowing external mutation and making session invariants hard to enforce. The encapsulation rule requires exposing read-only collection types to consumers that should not mutate state.Session.Events and Session.Forgotten are exposed as mutable collections, allowing any consumer to mutate them.
The session working set and forgotten-set should be mutated only through controlled methods (e.g., on Session or SessionManager) while consumers should receive IReadOnlyDictionary/IReadOnlySet views.
- src/KurrentDB.Kontext/SessionManager.cs[12-16]
| PR 5562 (2026-03-23) |
[reliability] V2 engine stop race
V2 engine stop race
CoreProjectionV2 cancels and disposes the engine CTS without awaiting the ProjectionEngineV2 task, then clears `_engine`. A rapid Stop/Start can run multiple engines concurrently, causing overlapping checkpoint/emitted writes and inconsistent projection state.StopEngine() cancels the engine but does not wait for it to stop, so restarting can overlap engine instances.
ProjectionEngineV2 performs async draining/checkpointing and waits for partition tasks in its finally; callers must not start a second engine instance until the first has completed.
- Store and await the engine run task during Stop/Kill/Suspend (ideally off the projection worker thread if deadlock risk exists).
- Gate Start so it cannot run until prior engine completion is observed.
- src/KurrentDB.Projections.V2/Services/Processing/V2/CoreProjectionV2.cs[215-236]
- src/KurrentDB.Projections.V2/Services/Processing/V2/CoreProjectionV2.cs[278-285]
| PR 5554 (2026-03-16) |
[reliability] Stale OTLP options value
Stale OTLP options value
`OpenTelemetryLogger.OtlpOptions` is not cleared when `OtlpLogsEnabled()` is false, so if the method is called once with logs enabled and later called with logs disabled, `OtlpOptions` can remain non-null and stale. This violates the property's own documented contract ("null if log export is disabled").OpenTelemetryLogger.OtlpOptions is a static property documented as "null if log export is disabled", but the implementation never clears it on the early-return path when logs export is disabled.
Even if runtime reconfiguration is uncommon, this can cause confusing behavior in the same process (e.g., tests, integration harnesses, or repeated logger initialization) where a stale non-null value remains.
-
Set
OtlpOptions = null;before returning when!configuration.OtlpLogsEnabled(). -
Optionally: set
OtlpOptions = null;at the start of the method to ensure a clean slate, then set it when enabled. -
src/KurrentDB.Logging/OpenTelemetryLogger.cs[21-30]
[security] Leaky public OTLP options
Leaky public OTLP options
`OpenTelemetryLogger` exposes the resolved `OtlpExporterOptions` (including `Headers`) via a public static property, making auth headers easily accessible to any in-process code and increasing the chance they get logged/serialized accidentally. This is a new security exposure introduced by the PR.OpenTelemetryLogger.OtlpOptions is a public static reference to the resolved OtlpExporterOptions, which includes Headers (often API keys/tokens). This creates a new in-process secret exposure surface and makes accidental logging/serialization more likely.
The property appears to be introduced primarily to support tests asserting the resolved effective configuration.
-
Make the property
internal(or remove it) and expose only what tests need (e.g., endpoint + protocol, or a sanitized copy with headers removed/masked). -
If tests need access from another assembly, prefer
InternalsVisibleTofor the test project. -
Avoid returning a mutable reference that other code can modify.
-
src/KurrentDB.Logging/OpenTelemetryLogger.cs[15-57]
-
src/KurrentDB.Common.Tests/OpenTelemetry/OpenTelemetryLoggerTests.cs[1-87]
| PR 5544 (2026-03-07) |
[correctness] Engine v2 always faults
Engine v2 always faults
EngineVersion=2 is externally selectable, but the selected V2ProjectionProcessingStrategy throws in CreateProcessingPhases, so projection creation will fault immediately in the existing V1 CoreProjection pipeline.EngineVersion=2 can be requested through the management API, but the code path still constructs a v1 CoreProjection which always calls CreateProcessingPhases(). The v2 strategy currently throws, so projections will fault immediately.
This is user-triggerable via gRPC create options, and will lead to immediate projection failures when engine v2 is selected.
- src/KurrentDB.Projections.Management/Services/Grpc/ProjectionManagement.Create.cs[60-71]
- src/KurrentDB.Projections.V1/Services/Processing/Strategies/ProcessingStrategySelector.cs[22-35]
- src/KurrentDB.Projections.V1/Services/Processing/V2/V2ProjectionProcessingStrategy.cs[50-63]
- src/KurrentDB.Projections.V1/Services/Processing/CoreProjection.cs[121-132]
[correctness] StateHandler shared concurrently
StateHandler shared concurrently
ProjectionEngineV2 passes the same IProjectionStateHandler instance into multiple PartitionProcessor tasks, causing unsafe concurrent access and state corruption (especially for JintProjectionStateHandler).ProjectionEngineV2 starts N partition processors concurrently but passes the same IProjectionStateHandler instance to all of them. Projection state handlers maintain internal mutable state, so concurrent calls will corrupt state and/or crash.
This is especially problematic for JS projections (JintProjectionStateHandler) which encapsulate a single Jint Engine and mutable _state.
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[75-89]
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2Config.cs[9-18]
- src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[35-70]
[correctness] BiState shared state races
BiState shared state races
BiState shared state is tracked per PartitionProcessor, so with multiple partitions the shared state will be updated concurrently from stale snapshots and written multiple times, losing updates and producing incorrect shared aggregates.BiState shared state is handled locally per partition processor, but shared state is conceptually global. With multiple partitions this produces stale reads and lost updates.
Each partition writes to the same shared result stream name ($projections-{name}--result) without coordination.
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionProcessor.cs[94-101]
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionProcessor.cs[127-131]
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[75-89]
[correctness] Custom partition routing mismatch
Custom partition routing mismatch
Deferred partition-key mode routes events by EventStreamId but later computes the actual custom partition key; custom partitions can group across streams, so the same computed partition can be processed on different processors, splitting/corrupting per-partition state.Deferred partitioning routes by stream id but the real partition key is computed later. Custom partitioning can group across streams (e.g., event.body.region), so per-partition state can be split across processors.
Each processor has its own state cache; there is no cross-processor coordination for a computed partition key.
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[52-67]
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionDispatcher.cs[86-92]
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionProcessor.cs[70-92]
- src/KurrentDB.Projections.Core.Tests/Services/Jint/when_partitioning_by_custom_rule.cs[15-33]
[correctness] Checkpoint sequence reset drops
Checkpoint sequence reset drops
CheckpointCoordinator discards partially collected buffers when it sees a new markerSequence. Since the read loop can inject marker N+1 before all partitions reported marker N, checkpoint data can be dropped, causing lost state/emits or duplication on restart.Checkpoint collection is single-slot and resets on any new marker sequence. If partitions report out of step, earlier checkpoint buffers are discarded.
The read loop injects markers based on thresholds, independent of whether the prior marker has fully completed across all partitions.
- src/KurrentDB.Projections.V2/Services/Processing/V2/CheckpointCoordinator.cs[49-65]
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2.cs[138-147]
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionDispatcher.cs[95-105]
[correctness] Checkpoint write can hang
Checkpoint write can hang
CheckpointCoordinator awaits a TaskCompletionSource without cancellation/timeout; if the write response never arrives, checkpointing stalls indefinitely and the semaphore remains held, blocking progress and potentially shutdown.Checkpoint writes can block forever waiting for a reply message; this stalls all subsequent checkpoints and can hang shutdown.
This is a reliability issue under partial failures (dropped messages, node failover, etc.).
- src/KurrentDB.Projections.V2/Services/Processing/V2/CheckpointCoordinator.cs[67-112]
[correctness] Null passed to Load
Null passed to Load
PartitionProcessor caches null states and later calls _stateHandler.Load(cachedState) even when cachedState is null. This violates the IProjectionStateHandler contract and can crash handlers that don’t accept null.PartitionProcessor may call Load(null) due to null-state caching, but the handler interface does not promise null is accepted.
Null states appear to be part of projection semantics (tests handle null expected states), so this should be made an explicit contract.
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionProcessor.cs[87-92]
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionProcessor.cs[119-125]
- src/KurrentDB.Projections.Shared/Services/IProjectionStateHandler.cs[18-37]
[correctness] EmitEnabled flag unused
EmitEnabled flag unused
ProjectionEngineV2Config exposes EmitEnabled, but emitted events are buffered and written unconditionally; this can allow side-effecting emits even when emit is intended to be disabled by configuration.EmitEnabled is present in the V2 engine config but not enforced, while the engine always writes emitted events.
The management layer already treats emit enablement as a configuration flag, so V2 should provide equivalent enforcement to avoid unexpected side effects.
- src/KurrentDB.Projections.V2/Services/Processing/V2/ProjectionEngineV2Config.cs[9-18]
- src/KurrentDB.Projections.V2/Services/Processing/V2/PartitionProcessor.cs[110-135]
- src/KurrentDB.Projections.V2/Services/Processing/V2/CheckpointCoordinator.cs[139-150]
- src/KurrentDB.Projections.Management/Services/Management/ManagedProjection.cs[953-983]
| PR 5459 (2026-01-14) |
[possible issue] Prevent crashes from unhandled exceptions
✅ Prevent crashes from unhandled exceptions
Add a try-catch block inside the async void method AuthorizeManyAsync to prevent unhandled exceptions from await accessCheck from crashing the application.
src/KurrentDB.Core/Services/AuthorizationGateway.cs [422-434]
async void AuthorizeManyAsync<TRequest>(
ValueTask<bool> accessCheck,
ClaimsPrincipal user,
ReadOnlyMemory<Operation> operations,
IEnvelope replyTo,
IPublisher destination,
TRequest request,
Func<TRequest, Message> createAccessDenied) where TRequest : Message {
- if (await accessCheck)
- AuthorizeMany(user, operations, replyTo, destination, request, createAccessDenied);
- else
+ try {
+ if (await accessCheck)
+ AuthorizeMany(user, operations, replyTo, destination, request, createAccessDenied);
+ else
+ replyTo.ReplyWith(createAccessDenied(request));
+ } catch (Exception ex) {
+ // It's important to log the exception. Assuming a logger is available.
+ // Log.Error(ex, "Error during asynchronous authorization for multi-stream write.");
replyTo.ReplyWith(createAccessDenied(request));
+ }
}Suggestion importance[1-10]: 8
__
Why: The suggestion correctly identifies that an unhandled exception in an async void method can crash the application and proposes a try-catch block to handle potential exceptions from await accessCheck gracefully.
| PR 5452 (2026-01-13) |
[possible issue] Pass correct affinity parameter
✅ Pass correct affinity parameter
Pass message.Affinity instead of the message object to Strategy.GetSynchronizationGroup to ensure correct affinity-based message grouping.
src/KurrentDB.Core/Bus/ThreadPoolMessageScheduler.cs [131]
-stateMachine.Schedule(message, Strategy.GetSynchronizationGroup(message));
+stateMachine.Schedule(message, Strategy.GetSynchronizationGroup(message.Affinity));Suggestion importance[1-10]: 8
__
Why: This is a critical bug fix. The current implementation passes the entire message object to GetSynchronizationGroup, which uses object reference equality, defeating the purpose of affinity-based grouping and causing incorrect synchronization behavior.
[possible issue] Prevent potential unregistration error
✅ Prevent potential unregistration error
Conditionally unregister from the Monitor in RequestStop only if a _queueLengthListener exists to prevent a potential exception.
src/KurrentDB.Core/Bus/ThreadPoolMessageScheduler.cs [84-91]
public void RequestStop() {
if (Interlocked.Exchange(ref _lifetimeSource, null) is { } cts) {
cts.Cancel();
- Monitor.Unregister(this);
- _queueLengthListener?.Dispose();
- _queueLengthObserver = null;
+ if (_queueLengthListener is not null) {
+ Monitor.Unregister(this);
+ _queueLengthListener.Dispose();
+ _queueLengthObserver = null;
+ }
}
}Suggestion importance[1-10]: 8
__
Why: The suggestion correctly identifies a potential ArgumentException when unregistering from the Monitor if the queue was never registered, which happens with certain strategies.
[high-level] Consider simplifying the custom object pooling
✅ Consider simplifying the custom object pooling
The custom lock-free object pooling for AsyncStateMachine adds complexity. Consider reverting to the simpler, previous implementation that used System.Collections.Concurrent.ConcurrentBag to reduce maintenance overhead and risk.
src/KurrentDB.Core/Bus/ThreadPoolMessageScheduler.Pooling.cs [9-32]
private volatile AsyncStateMachine _firstNode;
private void ReturnToPool(AsyncStateMachine node) {
AsyncStateMachine current;
do {
current = _firstNode;
node.NextInPool = current;
} while (Interlocked.CompareExchange(ref _firstNode, node, current) != current);
}
... (clipped 14 lines)src/KurrentDB.Core/Bus/ThreadPoolMessageScheduler.cs [121-123]
var stateMachine = messageCount > MaxPoolSize
? new(this)
: RentFromPool();// src/KurrentDB.Core/Bus/ThreadPoolMessageScheduler.cs
public partial class ThreadPoolMessageScheduler : IQueuedHandler {
private readonly ConcurrentBag _pool;
// ...
public void Publish(Message message) {
// ...
AsyncStateMachine stateMachine;
if (!_pool.TryTake(out stateMachine)) {
stateMachine = new PoolingAsyncStateMachine(this);
}
// ...
}
}
// src/KurrentDB.Core/Bus/ThreadPoolMessageScheduler.StateMachine.cs
private class AsyncStateMachine {
// ...
protected void ReturnToPool() => _scheduler._pool.Add(this);
}// src/KurrentDB.Core/Bus/ThreadPoolMessageScheduler.cs
public partial class ThreadPoolMessageScheduler : IQueuedHandler {
// ...
public void Publish(Message message) {
// ...
var stateMachine = messageCount > MaxPoolSize
? new(this)
: RentFromPool();
// ...
}
}
// src/KurrentDB.Core/Bus/ThreadPoolMessageScheduler.Pooling.cs
partial class ThreadPoolMessageScheduler {
private volatile AsyncStateMachine _firstNode;
private void ReturnToPool(AsyncStateMachine node) {
// lock-free push to linked list
do { ... } while (Interlocked.CompareExchange(...) != ...);
}
private AsyncStateMachine RentFromPool() {
// lock-free pop from linked list
do { ... } while (Interlocked.CompareExchange(...) != ...);
return current;
}
}Suggestion importance[1-10]: 7
__
Why: The suggestion correctly identifies the replacement of ConcurrentBag with a custom lock-free pooling implementation, raising a valid concern about increased complexity and risk in a critical component for a performance gain that should be justified.
| PR 5445 (2026-01-12) |
[general] Improve timeout handling in tests
✅ Improve timeout handling in tests
In ProcessConnectorEvents, replace DateTime.UtcNow with a Stopwatch for more reliable timeout measurement and throw a TimeoutException if the connector is not found, instead of returning silently.
src/Connectors/KurrentDB.Connectors.Tests/Planes/Management/ManagementServerFixture.cs [41-60]
public async Task ProcessConnectorEvents(string connectorId, CancellationToken cancellationToken = default) {
// Wait for the background projection to process events for this connector.
// We poll until the connector appears in the snapshot to avoid race conditions
// with the background ConnectorsStateProjection service.
var timeout = TimeSpan.FromSeconds(5);
- var start = DateTime.UtcNow;
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
- while (DateTime.UtcNow - start < timeout) {
+ while (stopwatch.Elapsed < timeout) {
cancellationToken.ThrowIfCancellationRequested();
var (snapshot, _, _) = await AssemblyFixture.SnapshotProjectionsStore.LoadSnapshot<ConnectorsSnapshot>(
ConnectorQueryConventions.Streams.ConnectorsStateProjectionStream
);
if (snapshot.Connectors.Any(c => c.ConnectorId == connectorId))
return;
await Task.Delay(50, cancellationToken);
}
+
+ throw new TimeoutException($"Connector with ID '{connectorId}' did not appear in the snapshot within the {timeout.TotalSeconds}s timeout.");
}Suggestion importance[1-10]: 8
__
Why: The suggestion correctly points out two significant issues in the test helper method: using DateTime.UtcNow for timeouts and silent failure. The proposed changes to use Stopwatch and throw a TimeoutException substantially improve test reliability and debuggability.
| PR 5444 (2026-01-12) |
[possible issue] Avoid converting UTC time to local
✅ Avoid converting UTC time to local
Avoid converting the created timestamp to local time. Instead, format the UTC DateTime directly using the ISO 8601 round-trip format specifier (o) to ensure timezone consistency.
src/KurrentDB.Core/DuckDB/InlineFunctions.cs [47]
- $"{{ \"data\": {dataString}, \"metadata\": {metaString}, \"stream_id\": \"{stream}\", \"created\": \"{created.ToLocalTime():yyyy-MM-dd'T'HH:mm:ssK}\", \"event_type\": \"{eventType}\" }}";
+ $"{{ \"data\": {dataString}, \"metadata\": {metaString}, \"stream_id\": \"{stream}\", \"created\": \"{created:o}\", \"event_type\": \"{eventType}\" }}";Suggestion importance[1-10]: 8
__
Why: The suggestion correctly identifies that converting UTC time to local server time is not a best practice and can lead to timezone-related issues. Proposing to use the round-trip format specifier (o) on the original UTC DateTime is a robust solution that improves data consistency.
[possible issue] Cast JSON timestamp to TIMESTAMP
✅ Cast JSON timestamp to TIMESTAMP
In the AllCteTemplate query, cast the event->>'created' string value to a TIMESTAMP type to ensure correct sorting and filtering.
src/KurrentDB/Components/Query/QueryService.cs [97]
-event->>'created' as created_at
+CAST(event->>'created' AS TIMESTAMP) as created_atSuggestion importance[1-10]: 8
__
Why: The PR changes the created_at column to be a string, which would break any sorting or filtering operations that expect a timestamp. This suggestion correctly points out the need to cast this string back to a TIMESTAMP type to maintain query functionality.
| PR 5437 (2026-01-06) |
[high-level] Consider a more generic multi-append API
✅ Consider a more generic multi-append API
The new multi-stream append feature uses a low-level, index-based API. It is suggested to create a more abstract, higher-level API to improve safety and ease of use for future implementations.
src/KurrentDB.Core/Bus/Extensions/PublisherWriteExtensions.cs [45-52]
public static async Task WriteEvents(
this IPublisher publisher,
LowAllocReadOnlyMemory streams,
LowAllocReadOnlyMemory expectedRevisions,
LowAllocReadOnlyMemory events,
LowAllocReadOnlyMemory eventStreamIndexes,
CancellationToken cancellationToken = default
) {src/KurrentDB.SecondaryIndexing/Indexes/User/Management/UserIndexEventStore.cs [46-102]
LowAllocReadOnlyMemory streams = duplicate
? [stream, UserIndexConstants.ManagementAllStream]
: new(stream);
LowAllocReadOnlyMemory expectedVersions = duplicate
? [expectedVersion.Value, ExpectedVersion.Any]
: new(expectedVersion.Value);
var totalEventCount = duplicate ? events.Count * 2 : events.Count;
Event[] processedEvents = new Event[totalEventCount];
... (clipped 47 lines)// In PublisherWriteExtensions.cs
public static async Task WriteEvents(
this IPublisher publisher,
LowAllocReadOnlyMemory streams,
LowAllocReadOnlyMemory expectedRevisions,
LowAllocReadOnlyMemory events,
LowAllocReadOnlyMemory eventStreamIndexes,
CancellationToken cancellationToken = default
) { ... }
// Usage in UserIndexEventStore.cs
// ... build parallel arrays for streams, expectedVersions, events, and eventStreamIndexes
await _client.Writing.WriteEvents(
streams: streams,
expectedRevisions: expectedVersions,
events: processedEvents,
eventStreamIndexes: eventStreamIndexes,
cancellationToken);// Proposed higher-level API
public record MultiStreamWrite(
string Stream,
long ExpectedRevision,
IReadOnlyCollection Events
);
public static async Task<...> WriteEvents(
this IPublisher publisher,
IReadOnlyCollection writes,
CancellationToken cancellationToken = default
) {
// Internally, this would build the low-level parallel arrays
// and call the existing implementation.
}
// Simplified usage
var writes = new List { new(stream, expectedVersion, events) };
if (duplicate) {
writes.Add(new(allStream, ExpectedVersion.Any, events));
}
await _client.Writing.WriteEvents(writes, cancellationToken);Suggestion importance[1-10]: 8
__
Why: The suggestion correctly identifies that the new multi-stream write API is low-level and potentially error-prone, proposing a significant architectural improvement for this new core feature that would enhance usability and maintainability.
[possible issue] Fix bug with empty event collections
✅ Fix bug with empty event collections
Add a guard clause to handle empty event collections to prevent a potential crash, and refactor the event processing loop for improved readability.
src/KurrentDB.SecondaryIndexing/Indexes/User/Management/UserIndexEventStore.cs [37-94]
public async Task<AppendEventsResult> AppendEvents(
StreamName stream,
ExpectedStreamVersion expectedVersion,
IReadOnlyCollection<NewStreamEvent> events,
CancellationToken cancellationToken) {
+
+ if (events.Count == 0) {
+ var streamRevision = await _inner.GetStreamVersion(stream, cancellationToken);
+ return AppendEventsResult.StreamExists(streamRevision.Value);
+ }
// decide if we are going to duplicate the events to the user index all Stream
var duplicate = stream.ToString().StartsWith(UserIndexConstants.Category);
LowAllocReadOnlyMemory<string> streams = duplicate
? [stream, UserIndexConstants.ManagementAllStream]
: new(stream);
LowAllocReadOnlyMemory<long> expectedVersions = duplicate
? [expectedVersion.Value, ExpectedVersion.Any]
: new(expectedVersion.Value);
var totalEventCount = duplicate ? events.Count * 2 : events.Count;
- Event[] processedEvents = new Event[totalEventCount];
- int[] eventStreamIndexes = new int[totalEventCount];
+ var processedEvents = new Event[totalEventCount];
+ var eventStreamIndexes = new int[totalEventCount];
var originalStreamIndex = 0;
var allStreamIndex = 1;
int i = 0;
- int j = events.Count;
foreach (var evt in events) {
var message = Message.Builder
.Value(evt.Payload!)
.Headers(new Headers(evt.Metadata.ToHeaders()))
.WithSchemaType(SchemaDataFormat.Json)
.Create();
var data = await _serialize(evt.Payload, message.Headers);
var dataArray = data.ToArray();
var schema = SchemaInfo.FromHeaders(message.Headers);
var isJson = schema.SchemaDataFormat == SchemaDataFormat.Json;
// process the events into the original stream
processedEvents[i] = new Event(
eventId: evt.Id,
eventType: schema.SchemaName,
isJson: isJson,
data: dataArray);
eventStreamIndexes[i] = originalStreamIndex;
// process the events into the management stream if necessary
if (duplicate) {
- processedEvents[j] = new Event(
+ var duplicateIndex = i + events.Count;
+ processedEvents[duplicateIndex] = new Event(
eventId: Guid.NewGuid(),
eventType: schema.SchemaName,
isJson: isJson,
data: dataArray);
- eventStreamIndexes[j] = allStreamIndex;
+ eventStreamIndexes[duplicateIndex] = allStreamIndex;
}
i++;
- j++;
}
...Suggestion importance[1-10]: 6
__
Why: The suggestion correctly identifies a potential bug when handling an empty events collection and proposes a valid refactoring to simplify the loop logic, but the provided improved_code for the bug fix is incorrect as it calls a non-existent method _inner.GetStreamVersion.
[general] Fix typo in error message string
✅ Fix typo in error message string
Fix a typo in the SecondaryIndexingDisabled error message by adding a missing closing parenthesis.
src/KurrentDB.Api.V2/Modules/Indexes/ApiErrors.cs [32-34]
public static RpcException SecondaryIndexingDisabled() => RpcExceptions.FromError(
error: IndexesError.SecondaryIndexingDisabled,
- message: "Secondary indexing is disabled (configuration key KurrentDB::SecondaryIndexing::Enabled is false");
+ message: "Secondary indexing is disabled (configuration key KurrentDB::SecondaryIndexing::Enabled is false)");Suggestion importance[1-10]: 2
__
Why: The suggestion correctly identifies and fixes a typo (a missing parenthesis) in an error message string, which is a minor but valid improvement for message clarity.
| PR 5432 (2026-01-05) |
[general] Fix typo in error text
✅ Fix typo in error text
Correct the grammatical error "is has" to "has" in the error message string.
src/KurrentDB.Api.V2/Modules/Streams/ApiErrors.cs [75-76]
-var message = $"Stream '{stream}' is has a different group of messages in this session. " +
+var message = $"Stream '{stream}' has a different group of messages in this session. " +
$"Appends for the same stream must currently be grouped together and not interleaved with appends for other streams.";Suggestion importance[1-10]: 3
__
Why: The suggestion correctly identifies and fixes a grammatical error ("is has") in the new error message, improving its clarity and professionalism.
| PR 5430 (2026-01-02) |
[high-level] Refactor logging dependency injection strategy
Refactor logging dependency injection strategy
Refactor the code to inject specific ILogger instances directly into classes instead of injecting ILoggerFactory. This change simplifies constructors and improves testability.
src/KurrentDB.SecondaryIndexing/Indexes/User/UserIndexEngine.cs [39-54]
public UserIndexEngine(
ISystemClient client,
IPublisher publisher,
ISubscriber subscriber,
ISchemaSerializer serializer,
SecondaryIndexingPluginOptions options,
DuckDBConnectionPool db,
IReadIndex index,
TFChunkDbConfig chunkDbConfig,
[FromKeyedServices(SecondaryIndexingConstants.InjectionKey)]
... (clipped 6 lines)src/KurrentDB.SecondaryIndexing/Indexes/User/UserIndexEngineSubscription.cs [26-41]
public partial class UserIndexEngineSubscription(
ISystemClient client,
IPublisher publisher,
ISchemaSerializer serializer,
SecondaryIndexingPluginOptions options,
DuckDBConnectionPool db,
IReadIndex readIndex,
Meter meter,
Func<(long, DateTime)> getLastAppendedRecord,
ILoggerFactory logFactory,
... (clipped 6 lines)public class UserIndexEngine(ILoggerFactory loggerFactory) {
private readonly ILogger _log;
private readonly UserIndexEngineSubscription _subscription;
public UserIndexEngine(...) {
_log = loggerFactory.CreateLogger();
_subscription = new UserIndexEngineSubscription(..., loggerFactory, ...);
}
// ...
}
public class UserIndexEngineSubscription(..., ILoggerFactory logFactory, ...) {
private readonly ILogger _log = logFactory.CreateLogger();
// ...
}// Assuming UserIndexEngineSubscription is registered in DI container
public class UserIndexEngine(ILogger log, UserIndexEngineSubscription subscription) {
private readonly ILogger _log = log;
private readonly UserIndexEngineSubscription _subscription = subscription;
// ...
}
public class UserIndexEngineSubscription(..., ILogger log, ...) {
private readonly ILogger _log = log;
// ...
}Suggestion importance[1-10]: 7
__
Why: The suggestion correctly identifies a suboptimal dependency injection pattern where ILoggerFactory is used instead of ILogger, impacting multiple new classes and affecting design quality and testability.
[general] Permit uppercase identifiers
✅ Permit uppercase identifiers
Add RegexOptions.IgnoreCase to the GeneratedRegex attribute to allow uppercase characters in index and column identifiers.
src/KurrentDB.SecondaryIndexing/Indexes/User/UserIndexSql.cs [40-41]
-[GeneratedRegex("^[a-z][a-z0-9_-]*$", RegexOptions.Compiled)]
+[GeneratedRegex("^[a-z][a-z0-9_-]*$", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex ValidationRegex();Suggestion importance[1-10]: 4
__
Why: This is a reasonable suggestion to make the identifier validation case-insensitive, which could improve flexibility, but it's a minor enhancement and not a bug fix.
| PR 5425 (2025-12-22) |
[possible issue] Handle null selector result correctly
✅ Handle null selector result correctly
Modify the CanHandleEvent method to treat a null or undefined result from a selector expression as a signal to skip indexing the event, similar to the skip value.
src/KurrentDB.SecondaryIndexing/Indexes/User/UserIndexProcessor.cs [163-172]
var fieldValue = _evaluator.Select(_fieldSelectorExpression);
- if (fieldValue == JsValue.Null)
- return true;
+ if (fieldValue.IsNull() || fieldValue.IsUndefined())
+ return _fieldSelectorExpression is null;
if (_skip.Equals(fieldValue))
return false;
field = (TField)TField.ParseFrom(fieldValue);
return true;Suggestion importance[1-10]: 7
__
Why: The suggestion correctly identifies a logical flaw where an event with a null selector result is indexed. The proposed change to skip such events makes the indexing behavior more intuitive and robust.
| PR 5422 (2025-12-18) |
[high-level] Refactor JavaScript object creation
✅ Refactor JavaScript object creation
Instead of manually building a JavaScript object using a hierarchy of C# classes that inherit from ObjectInstance, define a simple C# record or class that matches the desired JavaScript structure. Then, use Jint's JsValue.FromObject to automatically convert the C# object to a JsValue, simplifying the code and improving maintainability.
src/KurrentDB.SecondaryIndexing/Indexes/User/JavaScript/RecordObject.cs [12-77]
internal sealed class RecordObject : JsObject {
private readonly PositionObject _position;
private readonly SchemaInfoObject _schemaInfo;
public RecordObject(Engine engine, JsonParser parser) : base(engine, parser) {
_position = new PositionObject(engine, parser);
SetReadOnlyProperty("position", _position);
_schemaInfo = new SchemaInfoObject(engine, parser);
SetReadOnlyProperty("schemaInfo", _schemaInfo);
... (clipped 56 lines)src/KurrentDB.SecondaryIndexing/Indexes/User/UserIndexProcessor.cs [157-166]
_jsRecord.MapFrom(resolvedEvent, ++_sequenceId);
if (_filter is not null) {
var passesFilter = _filter.Call(_jsRecord).AsBoolean();
if (!passesFilter)
return false;
}
if (_fieldSelector is not null) {
var fieldJsValue = _fieldSelector.Call(_jsRecord);// KurrentDB.SecondaryIndexing/Indexes/User/UserIndexProcessor.cs
class UserIndexProcessor {
private readonly RecordObject _jsRecord;
public UserIndexProcessor(...) {
_jsRecord = new RecordObject(_engine, parser);
}
private bool CanHandleEvent(ResolvedEvent resolvedEvent, out TField? field) {
_jsRecord.MapFrom(resolvedEvent, ++_sequenceId);
var passesFilter = _filter.Call(_jsRecord).AsBoolean();
// ...
}
}
// KurrentDB.SecondaryIndexing/Indexes/User/JavaScript/RecordObject.cs
class RecordObject : JsObject { // Inherits from Jint's ObjectInstance
public RecordObject(...) {
_position = new PositionObject(engine, parser);
SetReadOnlyProperty("position", _position); // Manual property setting
// ...
}
public void MapFrom(ResolvedEvent resolvedEvent, ulong sequenceId) {
RecordId = $"{resolvedEvent.OriginalEvent.EventId}"; // Manual mapping
// ...
_position.MapFrom(resolvedEvent, sequenceId);
}
}// Define simple C# records to represent the JS object structure
record JsApiPosition(string streamId, ulong streamRevision, ulong logPosition);
record JsApiSchemaInfo(string subject, string type);
record JsApiRecord(
string recordId,
JsApiPosition position,
JsApiSchemaInfo schemaInfo,
object value, // Can be lazily evaluated
object headers
// ... other properties
);
// KurrentDB.SecondaryIndexing/Indexes/User/UserIndexProcessor.cs
class UserIndexProcessor {
private bool CanHandleEvent(ResolvedEvent resolvedEvent, out TField? field) {
// Create a simple C# object
var recordPoco = MapToPoco(resolvedEvent, ++_sequenceId);
// Let Jint handle the conversion to a JS object
var jsRecord = Jint.Native.JsValue.FromObject(_engine, recordPoco);
var passesFilter = _filter.Call(jsRecord).AsBoolean();
// ...
}
}Suggestion importance[1-10]: 8
__
Why: The suggestion correctly identifies a complex, custom implementation for creating JavaScript objects and proposes a much simpler, more idiomatic approach using Jint's built-in object mapping, which would significantly reduce code complexity and improve maintainability.
| PR 5421 (2025-12-18) |
[general] Reintroduce lazy initialization for connection pools
✅ Reintroduce lazy initialization for connection pools
Reintroduce lazy initialization for the DuckDBConnectionPool to avoid creating it for every connection, restoring the previous performance-optimized behavior.
src/KurrentDB/KestrelHelpers.cs [34-41]
public static void UseDuckDbConnectionPoolPerConnection(this ListenOptions listenOptions) {
listenOptions.Use(next => async connectionContext => {
var poolFactory = listenOptions.ApplicationServices.GetRequiredService<DuckDBConnectionPoolLifetime>();
- using var pool = poolFactory.CreatePool();
- connectionContext.Items[nameof(DuckDBConnectionPool)] = pool;
- await next(connectionContext);
+ var lazyPool = new Lazy<DuckDBConnectionPool>(poolFactory.CreatePool);
+ connectionContext.Items[nameof(Lazy<DuckDBConnectionPool>)] = lazyPool;
+
+ try {
+ await next(connectionContext);
+ } finally {
+ if (lazyPool.IsValueCreated) {
+ lazyPool.Value.Dispose();
+ }
+ }
});
}Suggestion importance[1-10]: 8
__
Why: The suggestion correctly identifies a performance regression where a connection pool is created eagerly for every connection and proposes reintroducing lazy initialization, which is a significant performance improvement.
[general] Update extension method for lazy pool
✅ Update extension method for lazy pool
Update the GetDuckDbConnectionPool extension method to retrieve the Lazy and return its Value, aligning with the lazy initialization pattern.
src/KurrentDB.Core/Services/Transport/Http/HttpContextExtensions.cs [16-25]
[CanBeNull]
public static DuckDBConnectionPool GetDuckDbConnectionPool(this HttpContext httpContext) {
var connectionItemsFeature = httpContext.Features.Get<IConnectionItemsFeature>();
if (connectionItemsFeature is null ||
- !connectionItemsFeature.Items.TryGetValue(nameof(DuckDBConnectionPool), out var item))
+ !connectionItemsFeature.Items.TryGetValue(nameof(Lazy<DuckDBConnectionPool>), out var item) ||
+ item is not Lazy<DuckDBConnectionPool> lazyPool)
return null;
- return item as DuckDBConnectionPool;
+ return lazyPool.Value;
}Suggestion importance[1-10]: 8
__
Why: This suggestion is a necessary follow-up to reintroducing lazy initialization for the connection pool, ensuring the retrieval logic correctly handles the Lazy wrapper to access the pool instance.
| PR 5409 (2025-12-16) |
[possible issue] Fix incorrect configuration option skipping
✅ Fix incorrect configuration option skipping
Fix a bug by removing an else block that incorrectly skips processing scalar configuration options if their top-level value is missing in a provider.
src/KurrentDB.Core/Configuration/ClusterVNodeOptions.Framework.cs [104-125]
if (!provider.TryGet(option.Value.Key, out var value) && !isDefault) {
// Handle options that have been configured as arrays (GossipSeed is currently the only one
// where this is possible)
if (option.Value.OptionSchema.Value<string>("type") is "array") {
var parentPath = option.Value.Key;
var childValues = new List<string>();
foreach (var childKey in provider.GetChildKeys([], parentPath)) {
var absoluteChildKey = parentPath + ":" + childKey;
if (provider.TryGet(absoluteChildKey, out var childValue) && childValue is not null) {
childValues.Add(childValue);
sourceDisplayName = GetSourceDisplayName(absoluteChildKey, provider);
}
}
- value = string.Join(", ", childValues);
if (childValues.Count is 0)
continue; // no child values. skip
+
+ value = string.Join(", ", childValues);
} else {
- continue; // no value and it is an array so don't check for children. skip.
+ continue; // no value for this option in this provider.
}
}Suggestion importance[1-10]: 8
__
Why: The suggestion correctly identifies a bug where scalar options would be skipped if their top-level key is missing, preventing them from being loaded from other providers.
| PR 5405 (2025-12-12) |
[general] Simplify null or whitespace string handling
✅ Simplify null or whitespace string handling
In TryPrettifyJson, simplify the initial check to return string.Empty for any null or whitespace input string, ensuring consistent behavior.
src/KurrentDB/Components/Query/Query.razor [176-186]
static string TryPrettifyJson(string json) {
- if (string.IsNullOrWhiteSpace(json)) return json ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(json)) return string.Empty;
try {
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, Indent);
}
catch (JsonException) {
// Not valid JSON; return original (or decide to throw)
return json;
}
}Suggestion importance[1-10]: 4
__
Why: The suggestion improves code clarity and consistency by simplifying the handling of null or whitespace strings, which is a good practice for maintainability.
| PR 5391 (2025-12-07) |
[high-level] System memory calculation logic is flawed
✅ System memory calculation logic is flawed
The current method for calculating available system memory is flawed because it incorrectly uses process-specific memory metrics (Process.WorkingSet64) instead of system-wide ones. It is recommended to either make the memory limit a configurable setting or to rely on DuckDB's own default memory management.
src/KurrentDB.Core/DuckDB/DuckDBConnectionPoolLifetime.cs [56-63]
(double Total, double Used) CalculateRam() {
var process = Process.GetCurrentProcess();
var processRam = process.WorkingSet64;
var totalRam = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
var totalGb = (double)totalRam / 1024 / 1024 / 1024;
var processGb = (double)processRam / 1024 / 1024 / 1024;
return (totalGb, processGb);
}(double Total, double Used) CalculateRam() {
var process = Process.GetCurrentProcess();
var processRam = process.WorkingSet64; // This is only this process's memory
var totalRam = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
// ...
return (totalGb, processGb);
}
// in constructor...
var (total, used) = CalculateRam();
var availableRam = total - used; // Incorrectly calculates "available" RAM
var duckDbRam = (int)Math.Round(availableRam / 2);
_pool = new ConnectionPoolWithFunctions($"Data Source={path};memory_limit={duckDbRam}GB", ...);// Suggested Alternative: Make the memory limit configurable
// (This requires adding a property to TFChunkDbConfig)
public DuckDBConnectionPoolLifetime(TFChunkDbConfig config, ...) {
var path = ...;
var connectionString = $"Data Source={path}";
// Check for a configured memory limit
if (config.MemoryLimitGB.HasValue) {
connectionString += $";memory_limit={config.MemoryLimitGB.Value}GB";
}
// If not configured, DuckDB will use its own default (typically 80% of system RAM)
_pool = new ConnectionPoolWithFunctions(connectionString, ...);
}Suggestion importance[1-10]: 10
__
Why: The suggestion correctly identifies a critical flaw where process-specific memory (Process.WorkingSet64) is incorrectly used to calculate system-wide available RAM, leading to a dangerously high and incorrect memory limit for DuckDB.
| PR 5387 (2025-12-01) |
[possible issue] Fix a potential race condition
✅ Fix a potential race condition
To prevent a potential race condition in the Subscribe method, assign _cts to a local variable at the start and use that variable for the null check and subsequent accesses.
src/KurrentDB.SecondaryIndexing/Subscriptions/SecondaryIndexSubscription.cs [29-50]
public void Subscribe() {
- if (_cts == null) {
+ var cts = _cts;
+ if (cts == null) {
log.LogWarning("Subscription already terminated");
return;
}
var position = indexProcessor.GetLastPosition();
var startFrom = position == TFPos.Invalid ? Position.Start : Position.FromInt64(position.CommitPosition, position.PreparePosition);
log.LogInformation("Starting indexing subscription from {StartFrom}", startFrom);
_subscription = new(
bus: publisher,
expiryStrategy: DefaultExpiryStrategy.Instance,
checkpoint: startFrom,
resolveLinks: false,
user: SystemAccounts.System,
requiresLeader: false,
catchUpBufferSize: options.CommitBatchSize * 2,
- cancellationToken: _cts!.Token
+ cancellationToken: cts.Token
);
- _processingTask = ProcessEvents(_cts.Token);
+ _processingTask = ProcessEvents(cts.Token);
}Suggestion importance[1-10]: 8
__
Why: The suggestion correctly identifies a potential race condition where _cts could be nullified after the check, and proposes the standard thread-safe pattern to fix it.
| PR 5373 (2025-11-19) |
[general] Improve exception message with context
✅ Improve exception message with context
Improve the exception message when an index is not found by including the indexName to provide better context for debugging.
src/KurrentDB/Components/Query/QueryService.cs [36-43]
case "index":
var indexName = tokens[1];
var exists = tryGetUserIndexTableDetails(indexName, out var tableName, out var tableFunctionName, out var hasFields);
if (!exists)
- throw new("Index does not exist");
+ throw new($"Index '{indexName}' does not exist.");
cte = string.Format(UserIndexCteTemplate, cteName, $"\"{tableName}\"", $"\"{tableFunctionName}\"", hasFields ? ", field" : string.Empty);
break;Suggestion importance[1-10]: 4
__
Why: The suggestion improves the exception message by including the indexName, which enhances debuggability, but it is a minor quality improvement rather than a functional change.