Skip to content

fix(sql): scope stream subscription end-of-stream measure to the subscribed stream (#586) - #587

Merged
alexeyzimarev merged 3 commits into
devfrom
fix/586-stream-scoped-end-of-stream
Aug 27, 2026
Merged

fix(sql): scope stream subscription end-of-stream measure to the subscribed stream (#586)#587
alexeyzimarev merged 3 commits into
devfrom
fix/586-stream-scoped-end-of-stream

Conversation

@alexeyzimarev

@alexeyzimarev alexeyzimarev commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #586

Problem

For relational stream subscriptions (SQL Server, PostgreSQL, SQLite), the end-of-stream measure behind the subscription gap metric was a constant MAX(stream_position) over the whole messages table, with no stream filter. The checkpoint it is compared against is a position within the subscribed stream, so:

  • eventuous.subscription.gap.count was inflated by (longest stream length - subscribed stream length) and never reached zero for a caught-up subscription.
  • StartFrom = Latest could seed a stream subscription checkpoint from an unrelated stream.

SubscriptionKind.All was unaffected.

Fix

  • SqlSubscriptionBase: the GetEndOfStream / GetEndOfAll string properties are replaced by a single protected abstract DbCommand PrepareEndOfStreamCommand(TConnection connection) hook, mirroring the existing PrepareCommand. The Kind switch in the base is gone; each concrete subscription prepares its own command.
  • All-stream subscriptions keep MAX(global_position).
  • Stream subscriptions return MAX(stream_position) joined to the streams table and filtered on stream_name.

Deviation from the issue suggestion: the stream filter uses the stream name from options rather than the stream id resolved in BeforeSubscribe. The measure is registered in DI and polled by SubscriptionMetrics from an observable gauge, which can happen before the subscription has ever connected; filtering by name makes the measure correct at any time instead of returning EndOfStream.Invalid until connect. A stream that does not exist yet reads as empty (position 0), consistent with the empty-store semantics established in #551.

Side effect: SQL Server and PostgreSQL now build the measure SQL from the resolved Schema (the same one PrepareCommand uses) instead of options.Schema, so it follows connection-options schema overrides like polling does.

Breaking for direct subclasses of SqlSubscriptionBase / the provider bases: GetEndOfStream and GetEndOfAll are removed in favour of PrepareEndOfStreamCommand. No references in eventuous-docs or eventuous-plugin.

Tests

Measure level (unit check of the PrepareEndOfStreamCommand contract):

  • SubscriptionMeasureBase.ShouldMeasureEndOfSubscribedStream: 20 events to an unrelated stream, 5 to the subscribed one, assert the measure reports position 4 (was 19), then catch up and assert the gap is zero. Also calls the measure before the subscription connects to cover the DI/metrics path.
  • Wired for PostgreSQL and SQL Server via new StreamSubscriptionMeasure test classes.
  • SQLite gets its own SubscriptionMeasureTests (stream scenario plus the all-stream scenario from test: subscription drop/resubscribe + health; fix relational end-of-stream measure (#308, #548) #551 it was missing) and a GetMeasure() on its fixture.
  • The test-only TestSubscription in ConnectionStringTests implements the new hook.

Metric level (added after review, so a regression in the metrics pipeline itself is caught):

  • SubscriptionGapMetricsTestsBase.ShouldReportZeroGapWhenCaughtUp observes the exported eventuous.subscription.gap.count gauge through AddEventuousSubscriptions and SubscriptionMetrics (the DI-registered measure, the checkpoint-commit listener, the gap arithmetic and the tags) rather than computing the gap from the delegate. It produces a longer unrelated stream next to the subscribed one, waits for the subscription to catch up, then polls the gauge until it reads zero (checkpoints commit on a batch or a delay).
  • Wired for PostgreSQL, SQL Server, KurrentDB and SQLite. SQLite gets a container-less MetricsFixture on SqliteStoreFixtureBase plus the existing gap-count test for parity; TestHandler in the OpenTelemetry test fakes is now public for that.

Verification

  • Measure level, RED on SQLite and PostgreSQL against the unfixed code: Expected 4 but received 19. GREEN: SQLite measure 2/2, PostgreSQL measure 2/2; full subscription suites SQLite 13/13, PostgreSQL 16/16 (including SubscribeToAllFromEnd, which seeds from the measure).
  • Metric level, RED against the unfixed library: the gauge reads 100 (other stream tail 199 minus checkpoint 99) and the test fails. GREEN locally: SQLite, PostgreSQL and KurrentDB metrics tests 2/2 each.
  • Full solution build: 0 errors, no warnings in the touched projects.
  • SQL Server tests are excluded on macOS at the assembly level, so they did not run locally; the SQL Server project builds clean and this PR relies on CI for execution (first run: sqlserver shard 54/54, 0 skipped).

🤖 Generated with Claude Code

…cribed stream (#586)

For SubscriptionKind.Stream, the gap measure ran MAX(stream_position) over the whole messages table, so any longer stream inflated eventuous.subscription.gap.count for a fully caught-up subscription, and StartFrom=Latest could seed a stream subscription from another stream's position.

Replace the constant GetEndOfStream/GetEndOfAll SQL properties on SqlSubscriptionBase with a PrepareEndOfStreamCommand(TConnection) hook, mirroring PrepareCommand, implemented per concrete subscription. Stream subscriptions filter by stream name through the streams table rather than the id resolved in BeforeSubscribe, so the measure is also valid when the metrics observer polls it before the subscription has connected.

Tests: SubscriptionMeasureBase.ShouldMeasureEndOfSubscribedStream appends two streams of different lengths, subscribes to the shorter one, and asserts the measure reports its tail and the gap is zero after catch-up; wired for SQL Server, PostgreSQL and SQLite. SQLite also gets the all-stream measure test from #551.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Scope SQL end-of-stream measures to subscribed streams

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Scope relational stream measures to subscribed streams, preventing inflated gaps and incorrect
 Latest checkpoints.
• Delegate measure command construction to concrete subscriptions, honoring resolved provider
 schemas.
• Add cross-provider regressions for pre-connect, empty-store, isolation, and caught-up behavior.
Diagram

sequenceDiagram
    participant Gauge as Metrics Gauge
    participant Base as SQL Subscription
    participant Provider as Provider Command
    participant Store as Relational Store
    participant Checkpoint as Checkpoint Store
    Gauge->>Base: Poll end position
    Base->>Provider: Prepare scoped query
    Provider->>Store: Filter stream name
    Store-->>Base: Tail or zero
    Base-->>Gauge: End position
    Gauge->>Checkpoint: Read checkpoint
    Checkpoint-->>Gauge: Saved position
    Gauge->>Gauge: Calculate gap
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Filter by resolved stream ID
  • ➕ Uses the identifier already consumed by stream polling.
  • ➕ Avoids joining the streams table during each measurement.
  • ➖ Cannot produce a valid measure before BeforeSubscribe resolves the ID.
  • ➖ Would return invalid or require an additional lookup during metrics polling.
  • ➖ Couples diagnostics correctness to subscription connection state.

Recommendation: Keep the PR's stream-name filtering approach. Although filtering by the resolved stream ID could avoid the join, metrics may poll before the subscription connects; the name-based query remains correct before connection, preserves empty-stream semantics, and avoids connection-state coupling.

Files changed (16) +242 / -29

Bug fix (3) +39 / -0
PostgresStreamSubscription.csScope PostgreSQL measure by stream name +13/-0

Scope PostgreSQL measure by stream name

• Builds a parameterized end-position query joining messages to streams and filtering by the subscribed stream name. This works before stream ID resolution and excludes unrelated streams.

src/Postgres/src/Eventuous.Postgresql/Subscriptions/PostgresStreamSubscription.cs

SqlServerStreamSubscription.csScope SQL Server measure by stream name +13/-0

Scope SQL Server measure by stream name

• Queries the subscribed stream's maximum position through a parameterized messages-to-streams join. The query remains valid before the subscription resolves its stream ID.

src/SqlServer/src/Eventuous.SqlServer/Subscriptions/SqlServerStreamSubscription.cs

SqliteStreamSubscription.csScope SQLite measure by stream name +13/-0

Scope SQLite measure by stream name

• Adds a parameterized join from messages to streams and filters by the configured stream name. The measure therefore works before connection and ignores unrelated stream positions.

src/Sqlite/src/Eventuous.Sqlite/Subscriptions/SqliteStreamSubscription.cs

Refactor (7) +21 / -29
PostgresAllStreamSubscription.csPrepare PostgreSQL all-stream tail command +3/-0

Prepare PostgreSQL all-stream tail command

• Implements the new command hook with MAX(global_position). The query uses the resolved schema associated with the subscription.

src/Postgres/src/Eventuous.Postgresql/Subscriptions/PostgresAllStreamSubscription.cs

PostgresSubscriptionBase.csRemove shared PostgreSQL tail SQL properties +0/-3

Remove shared PostgreSQL tail SQL properties

• Removes unscoped end-of-stream and all-stream SQL constants now supplied by concrete subscription classes.

src/Postgres/src/Eventuous.Postgresql/Subscriptions/PostgresSubscriptionBase.cs

SqlSubscriptionBase.csDelegate end-position command preparation +12/-16

Delegate end-position command preparation

• Replaces kind-switched SQL string properties with a protected abstract DbCommand preparation hook. Measure execution now runs the concrete command while retaining null-to-zero and error handling semantics.

src/Relational/src/Eventuous.Sql.Base/Subscriptions/SqlSubscriptionBase.cs

SqlServerAllStreamSubscription.csPrepare SQL Server all-stream tail command +3/-0

Prepare SQL Server all-stream tail command

• Implements the new command hook with MAX(GlobalPosition) using the resolved schema.

src/SqlServer/src/Eventuous.SqlServer/Subscriptions/SqlServerAllStreamSubscription.cs

SqlServerSubscriptionBase.csRemove shared SQL Server tail SQL properties +0/-5

Remove shared SQL Server tail SQL properties

• Removes constructor-built measure queries and obsolete overrides, leaving concrete subscription types responsible for command preparation.

src/SqlServer/src/Eventuous.SqlServer/Subscriptions/SqlServerSubscriptionBase.cs

SqliteAllStreamSubscription.csPrepare SQLite all-stream tail command +3/-0

Prepare SQLite all-stream tail command

• Implements the new command hook with MAX(global_position) against the resolved messages table.

src/Sqlite/src/Eventuous.Sqlite/Subscriptions/SqliteAllStreamSubscription.cs

SqliteSubscriptionBase.csRemove shared SQLite tail SQL properties +0/-5

Remove shared SQLite tail SQL properties

• Removes generic end-position SQL properties superseded by concrete all-stream and stream subscription commands.

src/Sqlite/src/Eventuous.Sqlite/Subscriptions/SqliteSubscriptionBase.cs

Tests (6) +182 / -0
SubscriptionMeasureBase.csAdd reusable subscribed-stream measure regression +37/-0

Add reusable subscribed-stream measure regression

• Adds a shared scenario proving pre-connect empty-stream behavior, isolation from longer unrelated streams, and a zero gap after catch-up. PostgreSQL and SQL Server suites reuse this coverage.

src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionMeasureBase.cs

SubscriptionMeasureTests.csCover PostgreSQL stream-scoped measures +15/-0

Cover PostgreSQL stream-scoped measures

• Adds a stream subscription fixture and runs the shared isolation, pre-connect, and caught-up gap regression.

src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/SubscriptionMeasureTests.cs

ConnectionStringTests.csAdapt test subscription to command hook +8/-0

Adapt test subscription to command hook

• Implements the new abstract end-position command hook in the test-only SQL Server subscription so connection-string tests continue to compile and run.

src/SqlServer/test/Eventuous.Tests.SqlServer/Subscriptions/ConnectionStringTests.cs

SubscriptionMeasureTests.csCover SQL Server stream-scoped measures +15/-0

Cover SQL Server stream-scoped measures

• Adds a stream subscription fixture and applies the shared regression for unrelated streams, pre-connect polling, and zero caught-up gap.

src/SqlServer/test/Eventuous.Tests.SqlServer/Subscriptions/SubscriptionMeasureTests.cs

SubscriptionFixture.csExpose SQLite subscription measure +6/-0

Expose SQLite subscription measure

• Adds a fixture helper that retrieves the end-of-stream measure from IMeasuredSubscription for direct diagnostics assertions.

src/Sqlite/test/Eventuous.Tests.Sqlite/Subscriptions/SubscriptionFixture.cs

SubscriptionMeasureTests.csAdd SQLite subscription measure coverage +101/-0

Add SQLite subscription measure coverage

• Introduces all-stream empty/global-tail tests and a stream-specific regression covering pre-connect polling, unrelated streams, and zero gap after catch-up.

src/Sqlite/test/Eventuous.Tests.Sqlite/Subscriptions/SubscriptionMeasureTests.cs

@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (1) 📜 Skill insights (0)

Grey Divider


Action required

1. Gap metric remains untested 📎 Requirement gap ☼ Reliability
Description
The new relational regression tests manually subtract the measured end position from the checkpoint
instead of observing eventuous.subscription.gap.count. They can pass even if metric registration,
collection, tagging, or the metric-specific gap calculation is broken.
Code

src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionMeasureBase.cs[R71-73]

+        var checkpoint = await fixture.CheckpointStore.GetLastCheckpoint(fixture.SubscriptionId, cancellationToken);
+        var caughtUp   = await measure(cancellationToken);
+        await Assert.That(caughtUp.Position - checkpoint.Position!.Value).IsEqualTo(0ul);
Evidence
Rule 6 explicitly requires each relational provider test to assert
eventuous.subscription.gap.count is zero. The shared SQL Server/PostgreSQL test and the SQLite
test instead calculate caughtUp.Position - checkpoint.Position themselves, bypassing the
observable gauge implemented by SubscriptionMetrics; the repository already has metric tests that
retrieve and assert SubscriptionMetrics.GapCountMetricName.

Verify zero caught-up gap across all relational providers
src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionMeasureBase.cs[70-73]
src/Sqlite/test/Eventuous.Tests.Sqlite/Subscriptions/SubscriptionMeasureTests.cs[88-91]
src/Core/src/Eventuous.Subscriptions/Diagnostics/SubscriptionMetrics.cs[22-28]
src/Diagnostics/test/Eventuous.Tests.OpenTelemetry/MetricsTests.cs[38-52]

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 relational regression coverage does not collect or assert the actual `eventuous.subscription.gap.count` metric; it only reproduces the expected subtraction directly.

## Issue Context
PR Compliance ID 6 requires SQL Server, PostgreSQL, and SQLite coverage proving the emitted caught-up gap metric is zero. Use the repository's established metric observation/exporter pattern and retain the different-length stream setup.

## Fix Focus Areas
- src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionMeasureBase.cs[70-73]
- src/Sqlite/test/Eventuous.Tests.Sqlite/Subscriptions/SubscriptionMeasureTests.cs[88-91]

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


Grey Divider

Context sources

Grey Divider

Tip of the day
💡 Did you know, you can start a comment with 'qodo' or '@qodo' to chat about any finding

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +71 to +73
var checkpoint = await fixture.CheckpointStore.GetLastCheckpoint(fixture.SubscriptionId, cancellationToken);
var caughtUp = await measure(cancellationToken);
await Assert.That(caughtUp.Position - checkpoint.Position!.Value).IsEqualTo(0ul);

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. Gap metric remains untested 📎 Requirement gap ☼ Reliability

The new relational regression tests manually subtract the measured end position from the checkpoint
instead of observing eventuous.subscription.gap.count. They can pass even if metric registration,
collection, tagging, or the metric-specific gap calculation is broken.
Agent Prompt
## Issue description
The relational regression coverage does not collect or assert the actual `eventuous.subscription.gap.count` metric; it only reproduces the expected subtraction directly.

## Issue Context
PR Compliance ID 6 requires SQL Server, PostgreSQL, and SQLite coverage proving the emitted caught-up gap metric is zero. Use the repository's established metric observation/exporter pattern and retain the different-length stream setup.

## Fix Focus Areas
- src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionMeasureBase.cs[70-73]
- src/Sqlite/test/Eventuous.Tests.Sqlite/Subscriptions/SubscriptionMeasureTests.cs[88-91]

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 892789d. Added SubscriptionGapMetricsTestsBase.ShouldReportZeroGapWhenCaughtUp, which observes the exported eventuous.subscription.gap.count gauge through AddEventuousSubscriptions -> SubscriptionMetrics (the DI-registered measure, the checkpoint-commit listener, the gap arithmetic and the tags) instead of computing the gap from the delegate. It produces a longer unrelated stream next to the subscribed one, waits for the subscription to catch up, then polls the gauge until it reads 0. Wired for PostgreSQL, SQL Server, KurrentDB and SQLite (which gets a container-less MetricsFixture). Against the unfixed library the gauge reads 100 and the test fails; with the fix it converges on zero. The direct measure tests stay as the unit-level check of the PrepareEndOfStreamCommand contract.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

Test Results

   45 files  ± 0     45 suites  ±0   14m 15s ⏱️ +23s
  592 tests + 9    592 ✅ + 9  0 💤 ±0  0 ❌ ±0 
1 169 runs  +17  1 169 ✅ +17  0 💤 ±0  0 ❌ ±0 

Results for commit c5615b7. ± Comparison against base commit a978e32.

This pull request removes 9 and adds 18 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/26/2026 13:58:02 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/26/2026 13:58:02)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(d25c31ec-257c-4295-9fe1-c0a07b4ba197)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:38.6902296+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:38.6902296+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:45.6579460+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:45.6579460+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:54:08.7430352+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:54:08.7430352+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/26/2026 18:41:53 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/26/2026 18:41:53)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(546f3036-9615-459e-bad9-64fc18cc9f24)
Eventuous.Tests.KurrentDB.Metrics.SubscriptionGapMetricsTests ‑ ShouldReportZeroGapWhenCaughtUp_Esdb
Eventuous.Tests.Postgres.Metrics.SubscriptionGapMetricsTests ‑ ShouldReportZeroGapWhenCaughtUp_Postgres
Eventuous.Tests.Postgres.Subscriptions.StreamSubscriptionMeasure ‑ Postgres_ShouldMeasureEndOfSubscribedStream
Eventuous.Tests.SqlServer.Metrics.SubscriptionGapMetricsTests ‑ ShouldReportZeroGapWhenCaughtUp_SqlServer
Eventuous.Tests.SqlServer.Subscriptions.StreamSubscriptionMeasure ‑ SqlServer_ShouldMeasureEndOfSubscribedStream
Eventuous.Tests.Sqlite.Metrics.MetricsTests ‑ ShouldMeasureSubscriptionGapCountBase_Sqlite
Eventuous.Tests.Sqlite.Metrics.SubscriptionGapMetricsTests ‑ ShouldReportZeroGapWhenCaughtUp_Sqlite
…

♻️ This comment has been updated with latest results.

alexeyzimarev and others added 2 commits August 26, 2026 17:40
…riptions (#586)

The measure tests call the subscription's GetSubscriptionEndOfStream delegate directly and compute the gap themselves, so a regression in meter registration, the DI-registered measure, checkpoint-commit tracking, tagging or the gap arithmetic in SubscriptionMetrics would go unnoticed.

Add SubscriptionGapMetricsTestsBase, which produces a longer unrelated stream next to the subscribed one, waits for the subscription to catch up, then polls the exported eventuous.subscription.gap.count gauge until it reads zero (checkpoints commit on a batch or delay) and asserts its tags. Wired for PostgreSQL, SQL Server, KurrentDB and SQLite; SQLite gets a container-less MetricsFixture on SqliteStoreFixtureBase and the existing gap-count test for parity. TestHandler is made public so the SQLite fixture can register it.

Against the unfixed library the gauge reads 100 (other stream tail 199 minus checkpoint 99) and the test fails; with the fix it converges on zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… stuck in queue

Run 32986025539 for 892789d sat in 'queued' with no jobs for over three hours and the API refuses to cancel or re-run it; the CodeQL run for the same head ended in startup_failure and cannot be retried either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.AddEventHandler<TestHandler>()
);

services.AddOpenTelemetry().WithMetrics(builder => builder.AddEventuousSubscriptions().AddReader(new BaseExportingMetricReader(Exporter)));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not applying this one — the reader is disposed. The analyzer is missing the ownership transfer through AddReader.

The chain, verified against the decompiled OpenTelemetry 1.15.3 assembly rather than assumed:

  1. SqliteStoreFixtureBase.DisposeAsync() calls await Provider.DisposeAsync(), which disposes the singleton MeterProvider.
  2. MeterProviderSdk.Dispose(bool) calls Reader?.Shutdown(5000); Reader?.Dispose(); (and compositeMetricReader?.Dispose()).
  3. BaseExportingMetricReader.Dispose(bool) nulls IPullMetricExporter.Collect, then calls exporter.Dispose().

So both the reader and the exporter are released when the fixture tears down.

The genuinely redundant call is the existing Exporter.Dispose() in DisposeAsync, which runs after base.DisposeAsync() has already disposed the exporter through the reader. It is harmless — BaseExporter<T>.Dispose(bool) is an empty virtual and TestExporter doesn't override it, so that call is a no-op.

Holding the reader in a field would therefore duplicate a dispose the SDK already performs, and it would diverge from src/Diagnostics/test/Eventuous.Tests.OpenTelemetry/Fixtures/MetricsSubscriptionFixtureBase.cs, which this file mirrors: it carries the identical AddReader(new BaseExportingMetricReader(Exporter)) and Exporter.Dispose() pair, is untouched by this PR, and is the base for the KurrentDB, PostgreSQL and SQL Server metrics fixtures.

@alexeyzimarev
alexeyzimarev merged commit 3987153 into dev Aug 27, 2026
17 checks passed
@alexeyzimarev
alexeyzimarev deleted the fix/586-stream-scoped-end-of-stream branch August 27, 2026 14:29
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.

Subscription gap metrics: end-of-stream measure for relational stream subscriptions is not scoped to the stream

1 participant