Skip to content

[DB-2019] Add persistent subscriptions support for secondary indexes#5580

Open
alexeyzimarev wants to merge 17 commits intomasterfrom
feat/persistent-subscriptions-secondary-indexes
Open

[DB-2019] Add persistent subscriptions support for secondary indexes#5580
alexeyzimarev wants to merge 17 commits intomasterfrom
feat/persistent-subscriptions-secondary-indexes

Conversation

@alexeyzimarev
Copy link
Copy Markdown
Member

Summary

  • Adds PersistentSubscriptionIndexService — a sibling service to PersistentSubscriptionService that handles persistent subscriptions targeting secondary indexes
  • gRPC layer detects index-prefix filter on Create requests and publishes …ToIndex messages; Delete/Update/Connect use forwarding from the existing service
  • Reuses existing PersistentSubscription state machine, checkpoint reader/writer, parking, ack/nack, and consumer strategies
  • On SecondaryIndexDeleted, active connections are dropped and the subscription config is removed

Key design decisions

  • Wire API unchanged — clients subscribe by targeting $all with a StreamIdentifier filter whose single prefix is an index name (same convention as catch-up subscriptions)
  • Separate service (A.1)PersistentSubscriptionIndexService is a sibling on the same perSubscrBus, cleanly isolating index lifecycle from stream/all subscriptions
  • Forwarding for Delete/Update/Connect — these proto messages don't carry a filter, so the existing service checks config entries and forwards to the index service when the group belongs to an index subscription
  • Drop + delete on index deletion — no dormant configs; index deletion is a terminal event

New files

  • PersistentSubscriptionIndexService.cs — handles Create/Update/Delete/Connect, SecondaryIndexCommitted (live events), SecondaryIndexDeleted (cleanup), TimerTick, lifecycle
  • PersistentSubscriptionIndexEventSource.csIPersistentSubscriptionEventSource for indexes (TFPos-based, no filter)
  • PersistentSubscriptionToIndexParamsBuilder.cs — factory for index subscription params
  • FilterRouting.cs — shared helper extracted from Streams.Read.cs for detecting index-prefix filters

Test plan

  • Unit tests: PersistentSubscriptionIndexEventSourceTests (11 tests), FilterRoutingTests (8 tests)
  • Integration tests: PersistentSubscriptionTests — create/delete lifecycle, unknown index rejection, duplicate detection, non-existent delete (4 tests)
  • Existing persistent subscription tests: 480 passed, 0 failed, 1 skipped (unchanged)
  • Full xUnit suite: 1277 passed, 1 pre-existing failure (OOM finalizer, unrelated)
  • Manual smoke test with dev server

🤖 Generated with Claude Code

@alexeyzimarev alexeyzimarev requested review from a team as code owners April 14, 2026 10:00
Copilot AI review requested due to automatic review settings April 14, 2026 10:00
@alexeyzimarev alexeyzimarev changed the title feat: add persistent subscriptions support for secondary indexes [DB-2019] Add persistent subscriptions support for secondary indexes Apr 14, 2026
@linear
Copy link
Copy Markdown

linear bot commented Apr 14, 2026

@qodo-code-review
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Add persistent subscriptions support for secondary indexes

✨ Enhancement 🧪 Tests 📝 Documentation

Grey Divider

Walkthroughs

Description
• Adds persistent subscriptions support for secondary indexes through a new dedicated
  PersistentSubscriptionIndexService
• Implements index-targeted subscription operations (Create/Update/Delete/Connect) with automatic
  cleanup on index deletion
• Extends gRPC layer to detect index-prefix filters and route Create requests to the new index
  service
• Reuses existing persistent subscription infrastructure (state machine, checkpoints, parking,
  ack/nack, consumer strategies)
• Adds new client messages (ConnectToPersistentSubscriptionToIndex,
  CreatePersistentSubscriptionToIndex, UpdatePersistentSubscriptionToIndex,
  DeletePersistentSubscriptionToIndex) mirroring …ToAll structure
• Implements PersistentSubscriptionIndexEventSource for TFPos-based event reading from indexes
• Routes Delete/Update/Connect operations from main service to index service via forwarding logic
• Extends PersistentSubscriptionStreamReader to support reading from index event sources
• Includes comprehensive integration tests (4 test cases) and unit tests (19 tests total)
• Provides design specification and detailed implementation plan documentation
• Wire API remains unchanged; clients subscribe by targeting $all with index-name prefix filter
Diagram
flowchart LR
  Client["Client Request"]
  GrpcCreate["gRPC Create Handler"]
  FilterDetect["FilterRouting Detection"]
  MainService["PersistentSubscriptionService"]
  IndexService["PersistentSubscriptionIndexService"]
  EventSource["PersistentSubscriptionIndexEventSource"]
  Reader["PersistentSubscriptionStreamReader"]
  IndexBus["Index Events Bus"]
  
  Client -->|Create with filter| GrpcCreate
  GrpcCreate -->|Detect index prefix| FilterDetect
  FilterDetect -->|Index found| IndexService
  FilterDetect -->|No index| MainService
  IndexService -->|Create event source| EventSource
  EventSource -->|Read from index| Reader
  Reader -->|Query index| IndexBus
  IndexService -->|On SecondaryIndexDeleted| IndexService
Loading

Grey Divider

File Changes

1. src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs ✨ Enhancement +646/-0

New persistent subscription service for secondary indexes

• New service class handling persistent subscriptions targeting secondary indexes
• Implements handlers for Create/Update/Delete/Connect operations and index lifecycle events
• Manages subscription state, configuration persistence, and client connections
• Reuses existing PersistentSubscription state machine and checkpoint infrastructure

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs


2. src/KurrentDB.Core/Messages/ClientMessage.cs ✨ Enhancement +188/-0

New client messages for index persistent subscriptions

• Adds four new message types for index-targeted persistent subscriptions:
 ConnectToPersistentSubscriptionToIndex, CreatePersistentSubscriptionToIndex,
 UpdatePersistentSubscriptionToIndex, DeletePersistentSubscriptionToIndex
• Each includes corresponding completion message with result enums
• Messages carry index name and group name, mirroring the …ToAll message structure

src/KurrentDB.Core/Messages/ClientMessage.cs


3. src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs ✨ Enhancement +86/-0

Route index subscriptions to dedicated service

• Adds forwarding logic in Update/Delete handlers to route index subscription requests to
 PersistentSubscriptionIndexService
• Partitions configuration entries by IndexName during bootstrap, forwarding index entries to the
 new service
• Adds hijack in Connect handler to detect and forward index subscription connections
• Uses translating envelope to convert index-specific completion messages back to …ToAll format

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs


View more (15)
4. src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs ✨ Enhancement +73/-0

Detect and route index filters in gRPC Create

• Detects index-targeted filters using new FilterRouting helper
• Routes Create requests with index-prefix filters to CreatePersistentSubscriptionToIndex
• Handles completion messages from index service and translates to gRPC response
• Preserves existing …ToAll behavior for non-index filters

src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs


5. src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs ✨ Enhancement +49/-1

Add index event source reading capability

• Adds optional IPublisher mainQueue parameter to constructor for index reads
• Implements new branch in BeginReadEventsInternal for FromIndex event sources
• Issues ReadIndexEventsForward messages and translates responses to standard callback format
• Maintains backward compatibility with existing stream and $all read paths

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs


6. src/KurrentDB.SecondaryIndexing.Tests/IntegrationTests/PersistentSubscriptionTests.cs 🧪 Tests +81/-0

Integration tests for index persistent subscriptions

• New integration test class with four test cases covering index subscription lifecycle
• Tests create on unknown index, create/delete success, duplicate detection, and non-existent delete
• Uses SecondaryIndexingEnabledFixture for real index and bus setup

src/KurrentDB.SecondaryIndexing.Tests/IntegrationTests/PersistentSubscriptionTests.cs


7. src/KurrentDB.SecondaryIndexing.Tests/Fixtures/SecondaryIndexingFixture.cs 🧪 Tests +48/-0

Test fixture helpers for index subscriptions

• Adds helper methods CreatePersistentSubscriptionToIndex and
 DeletePersistentSubscriptionToIndex
• Methods publish index subscription messages and await completion via TcsEnvelope
• Provides test infrastructure for index subscription operations

src/KurrentDB.SecondaryIndexing.Tests/Fixtures/SecondaryIndexingFixture.cs


8. src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionToIndexParamsBuilder.cs ✨ Enhancement +57/-0

Builder for index subscription parameters

• New builder class for constructing PersistentSubscriptionParams for index subscriptions
• Provides factory method CreateFor(groupName, indexName) with sensible defaults
• Extends PersistentSubscriptionParamsBuilder with index-specific methods

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionToIndexParamsBuilder.cs


9. src/KurrentDB.Core/ClusterVNode.cs ✨ Enhancement +21/-0

Wire up index subscription service in cluster node

• Instantiates new PersistentSubscriptionIndexService with required dependencies
• Registers service on perSubscrBus and subscribes to index-related messages
• Forwards SecondaryIndexCommitted and SecondaryIndexDeleted from main bus to persistent
 subscription queue

src/KurrentDB.Core/ClusterVNode.cs


10. src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexEventSource.cs ✨ Enhancement +48/-0

Event source for index subscriptions

• New event source implementation for index-targeted subscriptions
• Implements IPersistentSubscriptionEventSource with FromIndex=true
• Provides index name and position parsing from checkpoint strings
• Uses PersistentSubscriptionAllStreamPosition for TFPos-based positioning

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexEventSource.cs


11. src/KurrentDB.Core/Services/Transport/Grpc/FilterRouting.cs ✨ Enhancement +41/-0

Shared filter routing helper for index detection

• New shared helper extracted from Streams.Read.cs for detecting index-prefix filters
• TryGetIndexName method validates filter shape and extracts index name
• Ensures exactly one prefix matching SystemStreams.IsIndexStream with no regex

src/KurrentDB.Core/Services/Transport/Grpc/FilterRouting.cs


12. src/KurrentDB.Core/Messages/SubscriptionMessage.cs ✨ Enhancement +11/-0

Message for bootstrapping index subscriptions

• Adds new PersistentSubscriptionIndexEntriesLoaded message type
• Carries list of index subscription entries from config bootstrap
• Enables main service to forward index entries to dedicated index service

src/KurrentDB.Core/Messages/SubscriptionMessage.cs


13. src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionAllStreamEventSource.cs ✨ Enhancement +2/-0

Update event source interface implementation

• Adds FromIndex property returning false
• Adds IndexName property that throws InvalidOperationException
• Maintains interface contract with new event source types

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionAllStreamEventSource.cs


14. src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionSingleStreamEventSource.cs ✨ Enhancement +2/-0

Update event source interface implementation

• Adds FromIndex property returning false
• Adds IndexName property that throws InvalidOperationException
• Maintains interface contract with new event source types

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionSingleStreamEventSource.cs


15. src/KurrentDB.Core/Services/PersistentSubscription/IPersistentSubscriptionEventSource.cs ✨ Enhancement +2/-0

Extend event source interface for indexes

• Adds two new properties to interface: bool FromIndex and string IndexName
• Enables event source implementations to declare index subscription capability

src/KurrentDB.Core/Services/PersistentSubscription/IPersistentSubscriptionEventSource.cs


16. src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionConfig.cs ✨ Enhancement +1/-0

Add index name to subscription config entry

• Adds nullable string IndexName field to PersistentSubscriptionEntry
• Allows configuration entries to identify index-targeted subscriptions

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionConfig.cs


17. docs/superpowers/specs/2026-04-13-persistent-subscriptions-secondary-indexes-design.md 📝 Documentation +257/-0

Design specification for index persistent subscriptions

• Comprehensive design specification for persistent subscriptions to secondary indexes
• Documents architecture, components, data flow, error handling, and testing strategy
• Covers wire API (unchanged), new service design, and backward compatibility
• Includes rollout plan with three-commit structure and non-goals

docs/superpowers/specs/2026-04-13-persistent-subscriptions-secondary-indexes-design.md


18. docs/superpowers/plans/2026-04-13-persistent-subscriptions-secondary-indexes.md 📝 Documentation +1423/-0

Implementation plan for persistent subscriptions to secondary indexes

• Comprehensive implementation plan for adding persistent subscriptions support to secondary indexes
• Organized into 5 commits with 23 tasks covering plumbing, service creation, gRPC hijacking, and
 integration tests
• Includes detailed step-by-step instructions with code snippets for interface extensions, new
 message types, event source implementation, and service wiring
• Specifies testing strategy with unit tests, integration tests, and full solution verification

docs/superpowers/plans/2026-04-13-persistent-subscriptions-secondary-indexes.md


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review bot commented Apr 14, 2026

Code Review by Qodo

🐞 Bugs (4)   📘 Rule violations (3)   📎 Requirement gaps (0)
🐞\ ≡ Correctness (4)
📘\ ≡ Correctness (1) ⛨ Security (1) ⚙ Maintainability (1)

Grey Divider


Action required

1. Forwarding uses stale config🐞
Description
PersistentSubscriptionService forwards $all Update/Delete/Connect to the index service by searching
its in-memory _config.Entries, but Create-to-index bypasses the main service and the main service
never refreshes config after index-service writes, so newly created index subscriptions can’t be
connected/deleted/updated via the $all APIs.
Code

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[R1039-1049]

+		// Check if the group belongs to an index subscription before trying the $all stream.
+		var allStream = new PersistentSubscriptionAllStreamEventSource().ToString();
+		var key = BuildSubscriptionGroupKey(allStream, message.GroupName);
+		if (!_subscriptionsById.ContainsKey(key)) {
+			var indexEntry = _config.Entries.FirstOrDefault(e => e.IndexName != null && e.Group == message.GroupName);
+			if (indexEntry != null) {
+				_queuedHandler.Publish(new ClientMessage.ConnectToPersistentSubscriptionToIndex(
+					message.InternalCorrId, message.CorrelationId, message.Envelope,
+					message.ConnectionId, message.ConnectionName, message.GroupName,
+					indexEntry.IndexName, message.AllowedInFlightMessages, message.From,
+					message.User, message.Expires));
Evidence
gRPC Create routes index subscriptions to CreatePersistentSubscriptionToIndex (index service),
while $all Connect/Delete/Update requests still land in PersistentSubscriptionService and rely on
_config.Entries to find an index entry to forward. The main service only reads configuration at
startup (LoadConfiguration in StartSubscriptions) and has no path to incorporate index-service
config writes at runtime.

src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs[123-164]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[154-162]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[1035-1054]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[565-613]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Forwarding from `PersistentSubscriptionService` to the index service for `$all` Update/Delete/Connect depends on `_config.Entries` containing up-to-date index entries. But index subscription creation is routed directly to `PersistentSubscriptionIndexService`, which writes config without updating the main service’s in-memory `_config`, so forwarding can fail for subscriptions created after startup.

## Issue Context
This breaks the advertised “wire API unchanged” behavior: clients create an index persistent subscription, then cannot connect/delete/update it using the `$all` requests that lack an index name.

## Fix Focus Areas
- src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs[123-164]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[646-676]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[844-869]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[1035-1054]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[221-227]

## What to change
Implement one of:
- After index create/update/delete succeeds, publish a message that the main service handles to update its `_config.Entries` (add/update/remove the corresponding `PersistentSubscriptionEntry`).
- Or, change forwarding to consult the index service’s in-memory registry (e.g., a new query message keyed by group) instead of the main service’s `_config`.
- Or, reload config on-demand before forwarding (likely slower; avoid if possible).

Also ensure the chosen approach updates the main service for both create/update/delete paths so forwarding remains consistent without restart.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. ...ToIndex ctor lacks validation 📘
Description
The new Create/Update/DeletePersistentSubscriptionToIndex messages assign required fields like
groupName/indexName without Ensure.NotNullOrEmpty(...), allowing null/empty values to flow
into the service and fail later in less obvious ways. This violates the requirement to fail
explicitly rather than allowing silent invalid inputs for mandatory parameters.
Code

src/KurrentDB.Core/Messages/ClientMessage.cs[R1568-1590]

+		public CreatePersistentSubscriptionToIndex(Guid internalCorrId, Guid correlationId, IEnvelope envelope,
+			string groupName, string indexName, bool resolveLinkTos, TFPos startFrom,
+			int messageTimeoutMilliseconds, bool recordStatistics, int maxRetryCount, int bufferSize,
+			int liveBufferSize, int readBatchSize,
+			int checkPointAfterMilliseconds, int minCheckPointCount, int maxCheckPointCount,
+			int maxSubscriberCount, string namedConsumerStrategy, ClaimsPrincipal user, DateTime? expires = null)
+			: base(internalCorrId, correlationId, envelope, user, expires) {
+			ResolveLinkTos = resolveLinkTos;
+			GroupName = groupName;
+			IndexName = indexName;
+			StartFrom = startFrom;
+			MessageTimeoutMilliseconds = messageTimeoutMilliseconds;
+			RecordStatistics = recordStatistics;
+			MaxRetryCount = maxRetryCount;
+			BufferSize = bufferSize;
+			LiveBufferSize = liveBufferSize;
+			ReadBatchSize = readBatchSize;
+			MaxCheckPointCount = maxCheckPointCount;
+			MinCheckPointCount = minCheckPointCount;
+			CheckPointAfterMilliseconds = checkPointAfterMilliseconds;
+			MaxSubscriberCount = maxSubscriberCount;
+			NamedConsumerStrategy = namedConsumerStrategy;
+		}
Evidence
PR Compliance ID 1 requires mandatory inputs to be required and validated with explicit failure
rather than allowing null/empty values to propagate. In CreatePersistentSubscriptionToIndex (and
similarly Update...ToIndex and Delete...ToIndex), required strings are assigned directly
(GroupName = groupName; IndexName = indexName;) without any null/empty checks, unlike other
message types in the codebase that use Ensure.NotNullOrEmpty(...).

CLAUDE.md
src/KurrentDB.Core/Messages/ClientMessage.cs[1568-1590]
src/KurrentDB.Core/Messages/ClientMessage.cs[1634-1656]
src/KurrentDB.Core/Messages/ClientMessage.cs[1684-1689]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Create/Update/DeletePersistentSubscriptionToIndex` constructors do not validate required inputs (e.g., `groupName`, `indexName`, `namedConsumerStrategy`), allowing null/empty values to propagate and fail later.

## Issue Context
Other `ClientMessage` request types (e.g., `ConnectToPersistentSubscriptionToIndex`) use `Ensure.NotNullOrEmpty(...)` / `Ensure.Nonnegative(...)` to enforce invariants early.

## Fix Focus Areas
- src/KurrentDB.Core/Messages/ClientMessage.cs[1568-1590]
- src/KurrentDB.Core/Messages/ClientMessage.cs[1634-1656]
- src/KurrentDB.Core/Messages/ClientMessage.cs[1684-1689]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Hardcoded requireLeader: false 📘
Description
The new index read path publishes ReadIndexEventsForward with requireLeader: false and `user:
SystemAccounts.System` hardcoded, rather than taking explicit caller-provided leadership and
authorization context. This violates the requirement to pass requireLeader and ClaimsPrincipal
explicitly to avoid incorrect privilege/behavior defaults.
Code

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[R99-118]

+		} else if (eventSource.FromIndex) {
+			if (_mainQueue is null)
+				throw new InvalidOperationException("MainQueue publisher is required for index reads.");
+
+			var correlationId = Guid.NewGuid();
+			var handler = new ResponseHandler(onEventsFound, onEventsSkipped, onError, skipFirstEvent);
+			_mainQueue.Publish(new ClientMessage.ReadIndexEventsForward(
+				internalCorrId: correlationId,
+				correlationId: correlationId,
+				envelope: new CallbackEnvelope(msg => handler.FetchIndexCompleted((ClientMessage.ReadIndexEventsForwardCompleted)msg)),
+				indexName: eventSource.IndexName,
+				commitPosition: startPosition.TFPosition.Commit,
+				preparePosition: startPosition.TFPosition.Prepare,
+				excludeStart: false,
+				maxCount: Math.Min(countToLoad, actualBatchSize),
+				requireLeader: false,
+				validationTfLastCommitPosition: null,
+				user: SystemAccounts.System,
+				replyOnExpired: false,
+				pool: null));
Evidence
PR Compliance ID 2 requires APIs to receive explicit ClaimsPrincipal and explicit requireLeader
from the caller rather than hardcoding defaults. The new index branch always sets `requireLeader:
false and user: SystemAccounts.System when publishing ClientMessage.ReadIndexEventsForward`,
making leadership gating and auth context implicit/hardcoded.

CLAUDE.md
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[99-118]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The index read request hardcodes `requireLeader: false` and `user: SystemAccounts.System` instead of accepting explicit values from the caller.

## Issue Context
Compliance requires callers to explicitly supply leadership gating and authorization context to prevent accidental privilege/behavior changes.

## Fix Focus Areas
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[99-118]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Index config clobbered 🐞
Description
PersistentSubscriptionIndexService.SaveConfiguration deletes all IndexName!=null entries from
$persistentSubscriptionConfig and replaces them with its local _config.Entries, but bootstrap never
populates _config with the forwarded entries, so the first write can silently drop other index
subscriptions.
Code

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[R565-600]

+	private void SaveConfiguration(Action continueWith) {
+		// Re-read the full config, merge our index entries, then write it back.
+		// This avoids clobbering entries that belong to the main service.
+		_ioDispatcher.ReadBackward(SystemStreams.PersistentSubscriptionConfig, -1, 1, false,
+			SystemAccounts.System,
+			x => HandleSaveReadCompleted(continueWith, x),
+			expires: ClientMessage.ReadRequestMessage.NeverExpires);
+	}
+
+	private void HandleSaveReadCompleted(Action continueWith,
+		ClientMessage.ReadStreamEventsBackwardCompleted completed) {
+		PersistentSubscriptionConfig fullConfig;
+		switch (completed.Result) {
+			case ReadStreamResult.Success:
+				try {
+					fullConfig = PersistentSubscriptionConfig.FromSerializedForm(completed.Events[0].Event.Data);
+				} catch (Exception ex) {
+					Log.Error(ex, "Error reading config for merge during save.");
+					return;
+				}
+				break;
+			case ReadStreamResult.NoStream:
+				fullConfig = new PersistentSubscriptionConfig { Version = "2" };
+				break;
+			default:
+				Log.Error("Unexpected result {result} reading config for merge during save.", completed.Result);
+				return;
+		}
+
+		// Remove all index entries from full config and replace with ours
+		fullConfig.Entries.RemoveAll(e => e.IndexName != null);
+		fullConfig.Entries.AddRange(_config.Entries);
+		fullConfig.Updated = _config.Updated;
+		fullConfig.UpdatedBy = _config.UpdatedBy;
+
+		WriteMergedConfig(fullConfig, continueWith);
Evidence
Index bootstrap creates in-memory subscriptions but does not persist the received entries into
_config.Entries, while SaveConfiguration explicitly removes all index entries from the full config
and replaces them with _config.Entries, meaning any index entries not present in _config are lost on
write.

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[46-120]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[565-600]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PersistentSubscriptionIndexService` overwrites all index persistent subscription config entries on every save by removing all `IndexName != null` entries and re-adding only its local `_config.Entries`. Since bootstrap (`PersistentSubscriptionIndexEntriesLoaded`) does not populate `_config.Entries`, this can delete existing index subscriptions from `$persistentSubscriptionConfig`.

## Issue Context
This breaks persistence for index persistent subscriptions across restarts and can cause silent loss of unrelated index subscription configs when any create/update/delete triggers a save.

## Fix Focus Areas
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[69-120]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[565-600]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[513-533]

## What to change
- Ensure `_config.Entries` is initialized to the complete set of index entries (at minimum: set `_config.Entries = message.IndexEntries.ToList()` during bootstrap after validation, or call `LoadConfiguration` on leader start).
- Ensure create/update/delete mutate that same `_config.Entries` set, so the merge/write cannot drop other index entries.
- Consider guarding concurrent saves (serialize save operations) to avoid interleaving read/merge/write sequences.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Index reads never advance 🐞
Description
PersistentSubscriptionStreamReader uses ReadIndexEventsForwardCompleted.CurrentPos as the next
checkpoint for index reads, but CurrentPos is the request start position; this can cause repeated
reads from the same position (duplicates/infinite catch-up).
Code

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[R210-216]

+		public void FetchIndexCompleted(ClientMessage.ReadIndexEventsForwardCompleted msg) {
+			switch (msg.Result) {
+				case ReadIndexResult.Success:
+					_onFetchCompleted(
+						_skipFirstEvent ? msg.Events.Skip(1).ToArray() : (IReadOnlyList<ResolvedEvent>)msg.Events,
+						new PersistentSubscriptionAllStreamPosition(msg.CurrentPos.CommitPosition, msg.CurrentPos.PreparePosition),
+						msg.IsEndOfStream);
Evidence
ReadIndexEventsForwardCompleted only carries CurrentPos, and SecondaryIndexReaderBase sets it to
the request position (pos = new TFPos(msg.CommitPosition, msg.PreparePosition)). The index read
enumerator advances by using the last returned event’s EventPosition for the next request; the
persistent subscription reader currently does not advance at all.

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[99-118]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[210-227]
src/KurrentDB.Core/Messages/ClientMessage.IndexReads.cs[73-82]
src/KurrentDB.SecondaryIndexing/Indexes/SecondaryIndexReaderBase.cs[36-57]
src/KurrentDB.Core/Services/Transport/Enumerators/Enumerator.ReadIndex.cs[166-173]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
For index reads, `PersistentSubscriptionStreamReader.ResponseHandler.FetchIndexCompleted` checkpoints using `msg.CurrentPos`, which is the *request start* position for index reads. This prevents the persistent subscription catch-up loop from advancing.

## Issue Context
Index read responses don’t expose `NextPos`; the correct next position must be derived from the last returned event’s `EventPosition` (same as `Enumerator.ReadIndex`).

## Fix Focus Areas
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[99-118]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[210-227]
- src/KurrentDB.Core/Services/Transport/Enumerators/Enumerator.ReadIndex.cs[166-173]

## What to change
- In `FetchIndexCompleted(ReadIndexEventsForwardCompleted msg)`:
 - If `msg.Events.Count > 0`, compute next TFPos from `msg.Events[^1].EventPosition` and use that for the returned `PersistentSubscriptionAllStreamPosition`.
 - If `msg.Events.Count == 0`, keep the checkpoint at the existing start position.
- Consider passing `excludeStart: skipFirstEvent` (or equivalently derive next pos and set excludeStart=true on subsequent reads) to prevent duplicates.
- Add a regression test that a sequence of index reads advances the position and does not re-read the same page.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Index deletion cleanup skipped🐞
Description
PersistentSubscriptionIndexService matches SecondaryIndexDeleted.StreamIdRegex against its internal
topic key "$index-{IndexName}", but SecondaryIndexDeleted regexes are generated to match the real
index stream names (e.g. "$idx-…"), so index deletion won’t drop connections or remove config
entries.
Code

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[R390-393]

+		foreach (var (stream, subscriptions) in _subscriptionTopics) {
+			// The stream key is in the format "$index-{indexName}"; the regex matches against the raw index name.
+			if (!message.StreamIdRegex.IsMatch(stream))
+				continue;
Evidence
SecondaryIndexDeleted regex is constructed to match the index stream naming convention (e.g.
$idx-user-...), and SubscriptionsService applies it to topic keys that are actual stream/index
names. The index persistent subscription code invents a different key format ($index-...) and then
applies the regex to that mismatching string.

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexEventSource.cs[19-24]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[386-407]
src/KurrentDB.SecondaryIndexing/Indexes/User/UserIndexHelpers.cs[15-20]
src/KurrentDB.Core/Services/SubscriptionsService.cs[364-369]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`SecondaryIndexDeleted.StreamIdRegex` is built to match real index stream names (`$idx-...`). `PersistentSubscriptionIndexService` stores topics keyed as `$index-{IndexName}` and matches the regex against that key, so deletions never match and subscriptions/configs are not cleaned up.

## Issue Context
This causes zombie index persistent subscriptions after index deletion, leaving active connections and config entries behind.

## Fix Focus Areas
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexEventSource.cs[19-24]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[373-414]
- src/KurrentDB.SecondaryIndexing/Indexes/User/UserIndexHelpers.cs[15-20]

## What to change
- Align the topic key with the actual index name by making `PersistentSubscriptionIndexEventSource.ToString()` return `IndexName` (e.g. `$idx-...`) and removing `$index-` keying.
 - Then `SecondaryIndexCommitted` should use `message.IndexName` directly when looking up `_subscriptionTopics`.
- If you keep `$index-` keys, extract the raw index name before applying the regex (strip the `$index-` prefix), and likewise ensure commit routing uses the same normalized key.
- Add a unit/integration test that deletes an index and asserts the persistent subscription config entry is removed and connections drop.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Positional boolean args in gRPC 📘
Description
The new gRPC create path calls CreatePersistentSubscriptionToIndex(...) with multiple boolean
arguments positionally (e.g., settings.ResolveLinks, settings.ExtraStatistics), reducing clarity
and increasing the chance of swapped-argument bugs. This violates the guideline requiring named
boolean arguments at call sites.
Code

src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs[R133-160]

+						_publisher.Publish(new ClientMessage.CreatePersistentSubscriptionToIndex(
+							correlationId,
+							correlationId,
+							new CallbackEnvelope(HandleCreatePersistentSubscriptionCompleted),
+							request.Options.GroupName,
+							indexName,
+							settings.ResolveLinks,
+							new TFPos(startPosition.ToInt64().commitPosition, startPosition.ToInt64().preparePosition),
+							settings.MessageTimeoutCase switch {
+								MessageTimeoutOneofCase.MessageTimeoutMs => settings.MessageTimeoutMs,
+								MessageTimeoutOneofCase.MessageTimeoutTicks => (int)TimeSpan.FromTicks(settings.MessageTimeoutTicks).TotalMilliseconds,
+								_ => 0
+							},
+							settings.ExtraStatistics,
+							settings.MaxRetryCount,
+							settings.HistoryBufferSize,
+							settings.LiveBufferSize,
+							settings.ReadBatchSize,
+							settings.CheckpointAfterCase switch {
+								CheckpointAfterOneofCase.CheckpointAfterMs => settings.CheckpointAfterMs,
+								CheckpointAfterOneofCase.CheckpointAfterTicks => (int)TimeSpan.FromTicks(settings.CheckpointAfterTicks).TotalMilliseconds,
+								_ => 0
+							},
+							settings.MinCheckpointCount,
+							settings.MaxCheckpointCount,
+							settings.MaxSubscriberCount,
+							consumerStrategy,
+							user));
Evidence
PR Compliance ID 7 requires boolean arguments to be passed with named arguments for clarity. In the
new index-routing publish call, booleans are passed positionally into
CreatePersistentSubscriptionToIndex, making the call site non-self-documenting.

CLAUDE.md
src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs[133-160]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Booleans are passed positionally in the gRPC publish call to `CreatePersistentSubscriptionToIndex`, reducing readability and safety.

## Issue Context
The code already uses named arguments in many places (e.g., the index read publish), so aligning this call improves consistency.

## Fix Focus Areas
- src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs[133-160]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Wrong indexed position reported 🐞
Description
ConnectToPersistentSubscriptionToIndex replies with
PersistentSubscriptionConfirmation(lastIndexedPosition=0) instead of the actual last indexed
position, unlike the main persistent subscription service.
Code

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[R362-364]

+		var subscribedMessage = new ClientMessage.PersistentSubscriptionConfirmation(
+			key, message.CorrelationId, 0, null);
+		message.Envelope.ReplyWith(subscribedMessage);
Evidence
The main persistent subscription service reports _readIndex.LastIndexedPosition in the
confirmation; the index service hardcodes 0, which makes the client-visible progress metadata
inconsistent for index subscriptions.

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[359-368]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[1005-1014]
src/KurrentDB.Core/Messages/ClientMessage.cs[1758-1770]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Index subscription connect responses currently always report `LastIndexedPosition=0`, which is inconsistent with non-index persistent subscriptions and can mislead clients about server progress/lag.

## Issue Context
`SecondaryIndexReaders` already exposes `GetLastIndexedPosition(indexName)`.

## Fix Focus Areas
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[334-370]
- src/KurrentDB.Core/Services/Storage/SecondaryIndexReader.cs[54-57]

## What to change
- Replace the hardcoded `0` with a meaningful value, e.g. `var last = _secondaryIndexReaders.GetLastIndexedPosition(indexName);` and pass `last.CommitPosition` (or another agreed long) into `PersistentSubscriptionConfirmation`.
- Ensure the value matches what clients expect for `LastIndexedPosition` semantics.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Index filter validation gap 🐞
Description
FilterRouting.TryGetIndexName returns false for prefixes.Count!=1 instead of rejecting the request,
so Create($all) with an index prefix plus other prefixes silently falls back to creating a normal
$all persistent subscription rather than returning InvalidArgument.
Code

src/KurrentDB.Core/Services/Transport/Grpc/FilterRouting.cs[R31-32]

+		if (prefixes is not { Count: 1 })
+			return false;
Evidence
Streams.Read explicitly throws InvalidArgument when an index prefix is present and the prefix list
has more than one value, but persistent subscription Create uses FilterRouting and will fall back to
the non-index path when prefixes.Count!=1.

src/KurrentDB.Core/Services/Transport/Grpc/FilterRouting.cs[21-39]
src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs[123-165]
src/KurrentDB.Core/Services/Transport/Grpc/Streams.Read.cs[205-215]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Persistent subscription Create currently routes to the index service only when there is exactly one prefix and it is an index name. If an index prefix is combined with other prefixes, the call silently falls back to `$all` filtered subscriptions, which differs from existing catch-up index read validation.

## Issue Context
This produces surprising behavior and can create subscriptions that read from `$all` with a filter containing an index stream name.

## Fix Focus Areas
- src/KurrentDB.Core/Services/Transport/Grpc/FilterRouting.cs[21-39]
- src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs[123-165]

## What to change
- Extend `FilterRouting` (or the call site) to detect the presence of any index prefix when `prefixes.Count > 1` and throw `RpcExceptions.InvalidArgument("Index reads only work with one index name ...")`, matching `Streams.Read.cs` behavior.
- Add unit tests covering multi-prefix including an index name.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
10. Ambiguous group forwarding🐞
Description
Forwarding from PersistentSubscriptionService to the index service selects the first index entry
matching GroupName only; if the same group exists on multiple indexes, Update/Delete/Connect can act
on the wrong index subscription.
Code

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[R647-649]

+					// Before reporting DoesNotExist, check if the group belongs to an index subscription.
+					var indexEntry = _config.Entries.FirstOrDefault(e => e.IndexName != null && e.Group == message.GroupName);
+					if (indexEntry != null) {
Evidence
Index subscriptions are keyed by stream+group in the index service, allowing the same group to exist
on different indexes, but forwarding uses FirstOrDefault over entries filtered only by GroupName
and IndexName!=null, which is ambiguous by construction.

src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[646-674]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[1039-1049]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[432-437]
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[635-637]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Forwarding `$all` operations (connect/delete/update) to index subscriptions is ambiguous when multiple index subscriptions share the same group name. The current code silently picks the first match.

## Issue Context
The wire API for these operations doesn’t carry an index name, so the server must either enforce global group uniqueness for index subscriptions or detect ambiguity and fail explicitly.

## Fix Focus Areas
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[646-676]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[844-869]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs[1039-1051]

## What to change
- Either enforce uniqueness at create-time for index subscriptions (reject create if any existing index entry has the same group), or
- Change forwarding lookups to detect multiple matches and return a deterministic error instead of choosing arbitrarily.
- Add a regression test that demonstrates the chosen behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@alexeyzimarev
Copy link
Copy Markdown
Member Author

PR Review Summary

Critical Issues (3 found)

# Issue Location
1 Config race condition: Both services write $persistentSubscriptionConfig with ExpectedVersion.Any. Read-merge-write in the index service can clobber entries written by the main service between its read and write. PersistentSubscriptionIndexService.cs:565-612
2 Translating CallbackEnvelopes silently swallow non-matching messages: The forwarding envelopes for Update/Delete have an if with no else. If a NotHandled or unexpected message arrives, the client hangs forever — no reply sent. PersistentSubscriptionService.cs:651,849
3 Hard cast in CallbackEnvelope: (ReadIndexEventsForwardCompleted)msg with no type check. A NotHandled message would crash with InvalidCastException on the callback thread. PersistentSubscriptionStreamReader.cs:108

Important Issues (7 found)

# Issue Location
4 Ambiguous group-name-only routing: Forwarding uses FirstOrDefault(e => e.IndexName != null && e.Group == message.GroupName) — if two indexes share a group name, wrong subscription is targeted. PersistentSubscriptionService.cs:648,846,1043
5 No _started lifecycle guard: Create/Connect/etc. can be processed before bootstrap or after shutdown. PersistentSubscriptionIndexService.cs
6 SecondaryIndexDeleted calls Shutdown() but not Delete(): Checkpoint and parked-message streams are leaked. The explicit Delete handler correctly calls Delete(). PersistentSubscriptionIndexService.cs:400-406
7 Save-path errors silently abandon caller: HandleSaveReadCompleted returns without calling continueWith on error — client hangs forever. PersistentSubscriptionIndexService.cs:508-525
8 No timeout/retry for index reads: FromStream/FromAll branches have exponential-backoff retry on timeout. FromIndex has none — subscription silently hangs if read is lost. PersistentSubscriptionStreamReader.cs:99-118
9 Dead code: LoadConfiguration / HandleLoadCompleted methods are never called. PersistentSubscriptionIndexService.cs:535
10 Debug.Assert stripped in Release: Checkpoint parsing uses Debug.Assert for validation — malformed checkpoints produce cryptic FormatException in production. PersistentSubscriptionIndexEventSource.cs:39-42

Suggestions (6 found)

# Issue Location
11 Boolean tristate → enum: FromStream/FromAll/FromIndex should be an EventSourceKind enum for exhaustiveness checking. IPersistentSubscriptionEventSource.cs
12 Seal new classes: PersistentSubscriptionIndexEventSource, PersistentSubscriptionIndexService, PersistentSubscriptionToIndexParamsBuilder should be sealed. Multiple files
13 Extract $index- as a constant: Hardcoded in 4 locations across 3 files. Multiple files
14 Log level: Timeout retry uses Information instead of Warning. PersistentSubscriptionIndexService.cs:622
15 Null mainQueue accepted at construction: Old 2-param constructor allows null that only fails at read-time. PersistentSubscriptionStreamReader.cs:30-31
16 Significant test gaps: Update, Connect, live event routing, SecondaryIndexDeleted cleanup, forwarding paths, bootstrap, and gRPC entry point are all untested. Tests

Strengths

  • Clean architecture: Separate sibling service with clear boundaries, sharing the state machine and checkpoint infrastructure.
  • FilterRouting helper: Stateless, pure, excellent use of [NotNullWhen(true)], clean guard clauses.
  • Consistent patterns: New code follows established PersistentSubscriptionService patterns closely.
  • Config partitioning: Existing service partitions entries by IndexName != null and forwards — elegant bootstrap design.
  • Integration tests: Test fixture helpers use TcsEnvelope correctly per threading guidelines.

Recommended Action

Fix before merge (Critical + top Important):

  1. Add else branches to translating envelopes — reply with Fail on unexpected messages
  2. Add type-check before the cast in PersistentSubscriptionStreamReader — route NotHandled to _onError
  3. Call sub.Delete() in SecondaryIndexDeleted handler (not just Shutdown())
  4. Add _started lifecycle guard to index service
  5. Fix HandleSaveReadCompleted — call continueWith or reply with error on failure paths
  6. Remove dead LoadConfiguration / HandleLoadCompleted

Address soon (remaining Important):
7. Config race condition — use optimistic concurrency or single-writer
8. Ambiguous group-name routing — match on IndexName as well as GroupName
9. Add timeout/retry for index reads
10. Replace Debug.Assert with proper validation

Consider later (Suggestions):
11. Enum for event source kind, seal classes, extract constants, fix log level, expand test coverage


🤖 Generated with Claude Code

Comment on lines +1568 to +1590
public CreatePersistentSubscriptionToIndex(Guid internalCorrId, Guid correlationId, IEnvelope envelope,
string groupName, string indexName, bool resolveLinkTos, TFPos startFrom,
int messageTimeoutMilliseconds, bool recordStatistics, int maxRetryCount, int bufferSize,
int liveBufferSize, int readBatchSize,
int checkPointAfterMilliseconds, int minCheckPointCount, int maxCheckPointCount,
int maxSubscriberCount, string namedConsumerStrategy, ClaimsPrincipal user, DateTime? expires = null)
: base(internalCorrId, correlationId, envelope, user, expires) {
ResolveLinkTos = resolveLinkTos;
GroupName = groupName;
IndexName = indexName;
StartFrom = startFrom;
MessageTimeoutMilliseconds = messageTimeoutMilliseconds;
RecordStatistics = recordStatistics;
MaxRetryCount = maxRetryCount;
BufferSize = bufferSize;
LiveBufferSize = liveBufferSize;
ReadBatchSize = readBatchSize;
MaxCheckPointCount = maxCheckPointCount;
MinCheckPointCount = minCheckPointCount;
CheckPointAfterMilliseconds = checkPointAfterMilliseconds;
MaxSubscriberCount = maxSubscriberCount;
NamedConsumerStrategy = namedConsumerStrategy;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. ...toindex ctor lacks validation 📘 Rule violation ≡ Correctness

The new Create/Update/DeletePersistentSubscriptionToIndex messages assign required fields like
groupName/indexName without Ensure.NotNullOrEmpty(...), allowing null/empty values to flow
into the service and fail later in less obvious ways. This violates the requirement to fail
explicitly rather than allowing silent invalid inputs for mandatory parameters.
Agent Prompt
## Issue description
`Create/Update/DeletePersistentSubscriptionToIndex` constructors do not validate required inputs (e.g., `groupName`, `indexName`, `namedConsumerStrategy`), allowing null/empty values to propagate and fail later.

## Issue Context
Other `ClientMessage` request types (e.g., `ConnectToPersistentSubscriptionToIndex`) use `Ensure.NotNullOrEmpty(...)` / `Ensure.Nonnegative(...)` to enforce invariants early.

## Fix Focus Areas
- src/KurrentDB.Core/Messages/ClientMessage.cs[1568-1590]
- src/KurrentDB.Core/Messages/ClientMessage.cs[1634-1656]
- src/KurrentDB.Core/Messages/ClientMessage.cs[1684-1689]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +99 to +118
} else if (eventSource.FromIndex) {
if (_mainQueue is null)
throw new InvalidOperationException("MainQueue publisher is required for index reads.");

var correlationId = Guid.NewGuid();
var handler = new ResponseHandler(onEventsFound, onEventsSkipped, onError, skipFirstEvent);
_mainQueue.Publish(new ClientMessage.ReadIndexEventsForward(
internalCorrId: correlationId,
correlationId: correlationId,
envelope: new CallbackEnvelope(msg => handler.FetchIndexCompleted((ClientMessage.ReadIndexEventsForwardCompleted)msg)),
indexName: eventSource.IndexName,
commitPosition: startPosition.TFPosition.Commit,
preparePosition: startPosition.TFPosition.Prepare,
excludeStart: false,
maxCount: Math.Min(countToLoad, actualBatchSize),
requireLeader: false,
validationTfLastCommitPosition: null,
user: SystemAccounts.System,
replyOnExpired: false,
pool: null));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Hardcoded requireleader: false 📘 Rule violation ⛨ Security

The new index read path publishes ReadIndexEventsForward with requireLeader: false and `user:
SystemAccounts.System` hardcoded, rather than taking explicit caller-provided leadership and
authorization context. This violates the requirement to pass requireLeader and ClaimsPrincipal
explicitly to avoid incorrect privilege/behavior defaults.
Agent Prompt
## Issue description
The index read request hardcodes `requireLeader: false` and `user: SystemAccounts.System` instead of accepting explicit values from the caller.

## Issue Context
Compliance requires callers to explicitly supply leadership gating and authorization context to prevent accidental privilege/behavior changes.

## Fix Focus Areas
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[99-118]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +565 to +600
private void SaveConfiguration(Action continueWith) {
// Re-read the full config, merge our index entries, then write it back.
// This avoids clobbering entries that belong to the main service.
_ioDispatcher.ReadBackward(SystemStreams.PersistentSubscriptionConfig, -1, 1, false,
SystemAccounts.System,
x => HandleSaveReadCompleted(continueWith, x),
expires: ClientMessage.ReadRequestMessage.NeverExpires);
}

private void HandleSaveReadCompleted(Action continueWith,
ClientMessage.ReadStreamEventsBackwardCompleted completed) {
PersistentSubscriptionConfig fullConfig;
switch (completed.Result) {
case ReadStreamResult.Success:
try {
fullConfig = PersistentSubscriptionConfig.FromSerializedForm(completed.Events[0].Event.Data);
} catch (Exception ex) {
Log.Error(ex, "Error reading config for merge during save.");
return;
}
break;
case ReadStreamResult.NoStream:
fullConfig = new PersistentSubscriptionConfig { Version = "2" };
break;
default:
Log.Error("Unexpected result {result} reading config for merge during save.", completed.Result);
return;
}

// Remove all index entries from full config and replace with ours
fullConfig.Entries.RemoveAll(e => e.IndexName != null);
fullConfig.Entries.AddRange(_config.Entries);
fullConfig.Updated = _config.Updated;
fullConfig.UpdatedBy = _config.UpdatedBy;

WriteMergedConfig(fullConfig, continueWith);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Index config clobbered 🐞 Bug ≡ Correctness

PersistentSubscriptionIndexService.SaveConfiguration deletes all IndexName!=null entries from
$persistentSubscriptionConfig and replaces them with its local _config.Entries, but bootstrap never
populates _config with the forwarded entries, so the first write can silently drop other index
subscriptions.
Agent Prompt
## Issue description
`PersistentSubscriptionIndexService` overwrites all index persistent subscription config entries on every save by removing all `IndexName != null` entries and re-adding only its local `_config.Entries`. Since bootstrap (`PersistentSubscriptionIndexEntriesLoaded`) does not populate `_config.Entries`, this can delete existing index subscriptions from `$persistentSubscriptionConfig`.

## Issue Context
This breaks persistence for index persistent subscriptions across restarts and can cause silent loss of unrelated index subscription configs when any create/update/delete triggers a save.

## Fix Focus Areas
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[69-120]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[565-600]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs[513-533]

## What to change
- Ensure `_config.Entries` is initialized to the complete set of index entries (at minimum: set `_config.Entries = message.IndexEntries.ToList()` during bootstrap after validation, or call `LoadConfiguration` on leader start).
- Ensure create/update/delete mutate that same `_config.Entries` set, so the merge/write cannot drop other index entries.
- Consider guarding concurrent saves (serialize save operations) to avoid interleaving read/merge/write sequences.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +210 to +216
public void FetchIndexCompleted(ClientMessage.ReadIndexEventsForwardCompleted msg) {
switch (msg.Result) {
case ReadIndexResult.Success:
_onFetchCompleted(
_skipFirstEvent ? msg.Events.Skip(1).ToArray() : (IReadOnlyList<ResolvedEvent>)msg.Events,
new PersistentSubscriptionAllStreamPosition(msg.CurrentPos.CommitPosition, msg.CurrentPos.PreparePosition),
msg.IsEndOfStream);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

6. Index reads never advance 🐞 Bug ≡ Correctness

PersistentSubscriptionStreamReader uses ReadIndexEventsForwardCompleted.CurrentPos as the next
checkpoint for index reads, but CurrentPos is the request start position; this can cause repeated
reads from the same position (duplicates/infinite catch-up).
Agent Prompt
## Issue description
For index reads, `PersistentSubscriptionStreamReader.ResponseHandler.FetchIndexCompleted` checkpoints using `msg.CurrentPos`, which is the *request start* position for index reads. This prevents the persistent subscription catch-up loop from advancing.

## Issue Context
Index read responses don’t expose `NextPos`; the correct next position must be derived from the last returned event’s `EventPosition` (same as `Enumerator.ReadIndex`).

## Fix Focus Areas
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[99-118]
- src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs[210-227]
- src/KurrentDB.Core/Services/Transport/Enumerators/Enumerator.ReadIndex.cs[166-173]

## What to change
- In `FetchIndexCompleted(ReadIndexEventsForwardCompleted msg)`:
  - If `msg.Events.Count > 0`, compute next TFPos from `msg.Events[^1].EventPosition` and use that for the returned `PersistentSubscriptionAllStreamPosition`.
  - If `msg.Events.Count == 0`, keep the checkpoint at the existing start position.
- Consider passing `excludeStart: skipFirstEvent` (or equivalently derive next pos and set excludeStart=true on subsequent reads) to prevent duplicates.
- Add a regression test that a sequence of index reads advances the position and does not re-read the same page.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds persistent subscription support for secondary indexes by introducing a dedicated PersistentSubscriptionIndexService that reuses the existing persistent subscription machinery, with gRPC Create routing to the new index-specific ClientMessage types and forwarding from the existing service for operations that don’t carry filter info.

Changes:

  • Added PersistentSubscriptionIndexService + PersistentSubscriptionIndexEventSource and wiring in ClusterVNode to handle index lifecycle (committed/deleted) and subscription CRUD/connect.
  • Extended the persistent subscription event-source abstraction and stream reader to support index reads via ReadIndexEventsForward.
  • Added gRPC Create-time routing helper (FilterRouting) and integration test coverage for index persistent subscription create/delete scenarios.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/KurrentDB.SecondaryIndexing.Tests/IntegrationTests/PersistentSubscriptionTests.cs Adds integration tests for create/delete/duplicate/unknown-index behaviors.
src/KurrentDB.SecondaryIndexing.Tests/Fixtures/SecondaryIndexingFixture.cs Adds fixture helpers for publishing/awaiting index persistent subscription create/delete messages.
src/KurrentDB.Core/Services/Transport/Grpc/PersistentSubscriptions.Create.cs Routes Create($all + index-prefix filter) to CreatePersistentSubscriptionToIndex and handles its completion.
src/KurrentDB.Core/Services/Transport/Grpc/FilterRouting.cs Adds shared helper for detecting index-targeted stream-identifier prefix filters.
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionToIndexParamsBuilder.cs Adds a params builder for index persistent subscriptions.
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionStreamReader.cs Adds FromIndex branch using ReadIndexEventsForward.
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexEventSource.cs Implements IPersistentSubscriptionEventSource for index reads/checkpointing.
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionIndexService.cs New service handling index persistent subscription lifecycle + index committed/deleted messages.
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionService.cs Forwards Update/Delete/Connect($all) to index service when group belongs to an index entry; forwards index entries on bootstrap.
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionConfig.cs Adds PersistentSubscriptionEntry.IndexName to persist index subscriptions.
src/KurrentDB.Core/Services/PersistentSubscription/IPersistentSubscriptionEventSource.cs Extends the abstraction with FromIndex and IndexName.
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionAllStreamEventSource.cs Implements new interface members for $all event source.
src/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscriptionSingleStreamEventSource.cs Implements new interface members for single-stream event source.
src/KurrentDB.Core/Messages/SubscriptionMessage.cs Adds PersistentSubscriptionIndexEntriesLoaded for bootstrapping the index service.
src/KurrentDB.Core/Messages/ClientMessage.cs Adds new …ToIndex message types for create/update/delete/connect and their completions.
src/KurrentDB.Core/ClusterVNode.cs Wires index persistent subscriptions onto the per-subscription bus and main bus forwarding.
docs/superpowers/specs/2026-04-13-persistent-subscriptions-secondary-indexes-design.md Adds design/spec documentation.
docs/superpowers/plans/2026-04-13-persistent-subscriptions-secondary-indexes.md Adds implementation plan documentation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

indexName: eventSource.IndexName,
commitPosition: startPosition.TFPosition.Commit,
preparePosition: startPosition.TFPosition.Prepare,
excludeStart: false,
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Index reads always publish ReadIndexEventsForward with excludeStart=false, but the code later uses CurrentPos as the next checkpoint. Since ReadIndexEventsForwardCompleted.CurrentPos is the requested position (see ClientMessage.IndexReads), this will cause the reader to re-read the same events repeatedly. Consider advancing based on the last returned event position and setting excludeStart=true for subsequent pages / when skipFirstEvent is in play (similar to the index enumerators).

Suggested change
excludeStart: false,
excludeStart: true,

Copilot uses AI. Check for mistakes.
Comment on lines +210 to +215
public void FetchIndexCompleted(ClientMessage.ReadIndexEventsForwardCompleted msg) {
switch (msg.Result) {
case ReadIndexResult.Success:
_onFetchCompleted(
_skipFirstEvent ? msg.Events.Skip(1).ToArray() : (IReadOnlyList<ResolvedEvent>)msg.Events,
new PersistentSubscriptionAllStreamPosition(msg.CurrentPos.CommitPosition, msg.CurrentPos.PreparePosition),
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On successful index reads, the next position passed to _onFetchCompleted is built from msg.CurrentPos. For ReadIndexEventsForwardCompleted this is the request start position, not the position after the last returned event, so the subscription will not progress. Compute the next position from the last event’s EventPosition/OriginalPosition (and handle the empty-events case) so checkpointing and subsequent reads advance correctly.

Suggested change
public void FetchIndexCompleted(ClientMessage.ReadIndexEventsForwardCompleted msg) {
switch (msg.Result) {
case ReadIndexResult.Success:
_onFetchCompleted(
_skipFirstEvent ? msg.Events.Skip(1).ToArray() : (IReadOnlyList<ResolvedEvent>)msg.Events,
new PersistentSubscriptionAllStreamPosition(msg.CurrentPos.CommitPosition, msg.CurrentPos.PreparePosition),
private static PersistentSubscriptionAllStreamPosition GetNextIndexPosition(
ClientMessage.ReadIndexEventsForwardCompleted msg) {
if (msg.Events.Count > 0) {
var lastEvent = msg.Events[msg.Events.Count - 1];
if (lastEvent.EventPosition is { } eventPosition) {
return new PersistentSubscriptionAllStreamPosition(
eventPosition.CommitPosition,
eventPosition.PreparePosition);
}
if (lastEvent.OriginalPosition is { } originalPosition) {
return new PersistentSubscriptionAllStreamPosition(
originalPosition.CommitPosition,
originalPosition.PreparePosition);
}
}
return new PersistentSubscriptionAllStreamPosition(
msg.CurrentPos.CommitPosition,
msg.CurrentPos.PreparePosition);
}
public void FetchIndexCompleted(ClientMessage.ReadIndexEventsForwardCompleted msg) {
switch (msg.Result) {
case ReadIndexResult.Success:
_onFetchCompleted(
_skipFirstEvent ? msg.Events.Skip(1).ToArray() : (IReadOnlyList<ResolvedEvent>)msg.Events,
GetNextIndexPosition(msg),

Copilot uses AI. Check for mistakes.
Comment on lines +610 to +612
_ioDispatcher.ConfigureStreamAndWriteEvents(SystemStreams.PersistentSubscriptionConfig,
ExpectedVersion.Any, streamMetadata, events, SystemAccounts.System,
x => HandleWriteCompleted(continueWith, x));
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WriteMergedConfig uses ExpectedVersion.Any when rewriting $persistentSubscriptionConfig. With two services now capable of writing this stream (PersistentSubscriptionService and PersistentSubscriptionIndexService), this can silently drop updates (last writer wins) even with the read/merge step. Consider using optimistic concurrency (read expected version + retry on WrongExpectedVersion) or funnel all config writes through a single service/queue to avoid lost updates.

Copilot uses AI. Check for mistakes.
Comment on lines 1248 to +1253
foreach (var entry in _config.Entries) {
if (entry.IndexName != null) {
indexEntries.Add(entry);
continue;
}

Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Index entries are collected into indexEntries but remain in _config.Entries. Since PersistentSubscriptionService.SaveConfiguration serializes and writes _config as-is, any later save from the main service can overwrite changes made by PersistentSubscriptionIndexService (stale index entries clobber merged config). Consider removing index entries from _config after partitioning (or ensuring SaveConfiguration excludes them) so the index service is the only writer of index entries.

Suggested change
foreach (var entry in _config.Entries) {
if (entry.IndexName != null) {
indexEntries.Add(entry);
continue;
}
var nonIndexEntries = new List<PersistentSubscriptionEntry>();
foreach (var entry in _config.Entries.ToList()) {
if (entry.IndexName != null) {
indexEntries.Add(entry);
continue;
}
nonIndexEntries.Add(entry);
}
_config.Entries = nonIndexEntries;
foreach (var entry in _config.Entries) {

Copilot uses AI. Check for mistakes.
},
(error) => {
// Before reporting DoesNotExist, check if the group belongs to an index subscription.
var indexEntry = _config.Entries.FirstOrDefault(e => e.IndexName != null && e.Group == message.GroupName);
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forwarding to the index service uses FirstOrDefault(e => e.IndexName != null && e.Group == GroupName). If the same group name exists on multiple index subscriptions, this will route Update($all, group) to an arbitrary index. Consider enforcing group-name uniqueness across index subscriptions, or detecting multiple matches and failing with a clear error instead of picking the first entry.

Suggested change
var indexEntry = _config.Entries.FirstOrDefault(e => e.IndexName != null && e.Group == message.GroupName);
var matchingIndexEntries = _config.Entries
.Where(e => e.IndexName != null && e.Group == message.GroupName)
.Take(2)
.ToArray();
if (matchingIndexEntries.Length > 1) {
message.Envelope.ReplyWith(new ClientMessage.UpdatePersistentSubscriptionToAllCompleted(
message.CorrelationId,
ClientMessage.UpdatePersistentSubscriptionToAllCompleted.UpdatePersistentSubscriptionToAllResult.Fail,
$"Multiple index subscriptions exist for group '{message.GroupName}'. Group names must be unique across index subscriptions."));
return;
}
var indexEntry = matchingIndexEntries.SingleOrDefault();

Copilot uses AI. Check for mistakes.
Comment on lines +846 to +847
var indexEntry = _config.Entries.FirstOrDefault(e => e.IndexName != null && e.Group == message.GroupName);
if (indexEntry != null) {
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forwarding to the index service uses FirstOrDefault(e => e.IndexName != null && e.Group == GroupName). If the same group name exists on multiple index subscriptions, this Delete($all, group) will delete an arbitrary one. Consider enforcing group-name uniqueness across index subscriptions, or detecting multiple matches and failing with a clear error instead of picking the first entry.

Copilot uses AI. Check for mistakes.
Comment on lines +1043 to +1044
var indexEntry = _config.Entries.FirstOrDefault(e => e.IndexName != null && e.Group == message.GroupName);
if (indexEntry != null) {
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forwarding to the index service uses FirstOrDefault(e => e.IndexName != null && e.Group == GroupName). If the same group name exists on multiple index subscriptions, Connect($all, group) will connect to an arbitrary index subscription. Consider enforcing group-name uniqueness across index subscriptions, or detecting multiple matches and failing with a clear error instead of picking the first entry.

Suggested change
var indexEntry = _config.Entries.FirstOrDefault(e => e.IndexName != null && e.Group == message.GroupName);
if (indexEntry != null) {
var indexEntries = _config.Entries
.Where(e => e.IndexName != null && e.Group == message.GroupName)
.Take(2)
.ToArray();
if (indexEntries.Length > 1) {
Log.Error(
"Ambiguous persistent subscription group '{groupName}' matched multiple index subscriptions while connecting to $all.",
message.GroupName);
message.Envelope.ReplyWith(new ClientMessage.SubscriptionDropped(
message.CorrelationId,
SubscriptionDropReason.NotFound));
return ValueTask.CompletedTask;
}
if (indexEntries.Length == 1) {
var indexEntry = indexEntries[0];

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +35
if (prefixes is not { Count: 1 })
return false;

var candidate = prefixes[0];
if (!SystemStreams.IsIndexStream(candidate))
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TryGetIndexName returns false when prefixes.Count != 1, even if one of the prefixes is an index stream id. This differs from Streams.Read.cs, which throws InvalidArgument when an index name is combined with other prefixes. Consider matching that behavior here (throw when an index prefix is present alongside others) so index-targeted requests don’t silently fall back to normal $all filtering.

Copilot uses AI. Check for mistakes.

foreach (var (stream, subscriptions) in _subscriptionTopics) {
// The stream key is in the format "$index-{indexName}"; the regex matches against the raw index name.
if (!message.StreamIdRegex.IsMatch(stream))
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecondaryIndexDeleted provides a regex that matches the actual index stream ids (e.g. "$idx-…"), but _subscriptionTopics keys are "$index-{indexName}". As written, IsMatch(stream) will never match and index subscriptions/config entries won’t be cleaned up on index deletion. Match against the raw index name (e.g. strip the "$index-" prefix or store topics keyed by message.IndexName) before applying the regex.

Suggested change
if (!message.StreamIdRegex.IsMatch(stream))
const string indexStreamPrefix = "$index-";
var indexName = stream.StartsWith(indexStreamPrefix, StringComparison.Ordinal)
? stream[indexStreamPrefix.Length..]
: stream;
if (!message.StreamIdRegex.IsMatch(indexName))

Copilot uses AI. Check for mistakes.
alexeyzimarev and others added 16 commits April 14, 2026 16:51
…e to ClusterVNode

New service handles create/update/delete/connect for index subscriptions,
reacts to SecondaryIndexCommitted for live events and SecondaryIndexDeleted
for cleanup. Existing service partitions config entries and forwards index
entries to the new service on bootstrap.

Includes prerequisite plumbing: ClientMessage types for index persistent
subscriptions, IPersistentSubscriptionEventSource.FromIndex/IndexName,
PersistentSubscriptionIndexEventSource, PersistentSubscriptionToIndexParamsBuilder,
IndexName field on PersistentSubscriptionEntry, and reader FromIndex branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Create detects index-prefix filter at gRPC layer and publishes
ToIndex messages directly. Delete, Update, and Connect use forwarding
from the existing PersistentSubscriptionService when the group belongs
to an index subscription (detected via config entries with IndexName).

Adds FilterRouting static helper for extracting index names from
stream-identifier prefix filters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…scriptions

Tests create/delete lifecycle and unknown-index rejection using the
existing SecondaryIndexingFixture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The forwarding envelopes for Update/Delete had an if with no else —
if a NotHandled or unexpected message arrived, the client would hang
forever with no reply. Now replies with Fail on unexpected messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The CallbackEnvelope for ReadIndexEventsForward used a hard cast with
no type check. A NotHandled message would crash with InvalidCastException.
Now routes unexpected messages to the onError callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…eams

SecondaryIndexDeleted handler called Shutdown() but not Delete(),
leaving checkpoint and parked-message streams orphaned. The explicit
Delete handler already called Delete() correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Create/Connect/etc. could be processed before bootstrap completes or
after shutdown. Now guards all handlers with a _started flag set after
PersistentSubscriptionIndexEntriesLoaded and cleared on shutdown.
Also clears subscription dictionaries on shutdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
HandleSaveReadCompleted returned silently on error paths without
calling continueWith, causing the client to hang forever with no
response. Now calls continueWith so the caller gets a reply.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These methods were never called — the index service bootstraps via
PersistentSubscriptionIndexEntriesLoaded from the main service, not
by reading config directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ted regex mismatch

IndexName from FilterRouting is already the full index stream name
(e.g. $idx-all). Adding $index- created $index-$idx-all — a double
prefix that broke SecondaryIndexDeleted regex matching (regex matches
$idx-... but topic keys were $index-$idx-...) and produced wrong
subscription IDs. Now uses the raw index name throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two issues fixed:

1. Main service's SaveConfiguration now excludes index entries from
   writes — prevents clobbering index service changes. Index entries
   are kept in memory for forwarding lookups.

2. Index service notifies main service via
   PersistentSubscriptionIndexEntryChanged when subscriptions are
   created or deleted, keeping the forwarding lookup current for
   subscriptions created after startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
If the same group name exists on multiple index subscriptions,
forwarding Update/Delete/Connect via the $all API would silently
pick the first match. Now detects multiple matches and fails with
a clear error message directing the user to the index-specific API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ind enum

The FromStream/FromAll/FromIndex booleans on IPersistentSubscriptionEventSource
had no exhaustiveness checking and grew linearly with new variants. Now uses
EventSourceKind enum with a switch expression in the reader. The booleans
remain as default interface implementations for backward compatibility.

Also seals all three event source implementations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…d ParamsBuilder

Prevents unintended inheritance and enables JIT devirtualization.
Event source classes were already sealed in the previous commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per CLAUDE.md log level policy: recoverable errors and degraded states
should use Warning, not Information. A timeout that triggers a retry
is a recoverable error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…s flow

Replace CRUD-only tests with functional tests that prove the feature
works end-to-end:
- ReceivesEventsFromDefaultIndex: write events → wait for indexing →
  create subscription → connect → verify events arrive and ack
- ReceivesLiveEventsAfterCatchUp: create subscription from end →
  connect → write events → verify live events arrive

Also adds ConnectToPersistentSubscriptionToIndex fixture helper using
ContinuationEnvelope + Channel pattern. Fixes _started guard to not
block CRUD operations during async config load, and always publishes
PersistentSubscriptionIndexEntriesLoaded even when empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@alexeyzimarev alexeyzimarev force-pushed the feat/persistent-subscriptions-secondary-indexes branch from 7788147 to 58000d7 Compare April 14, 2026 14:51
FilterRouting.TryGetIndexName silently returned false when multiple
prefixes included an index name, falling back to $all filtering. Now
throws InvalidArgument matching the behavior of Streams.Read.cs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants