Skip to content

PR #6: Bug - Apply DefaultDeadline unconditionally in BuildClientSettings to fix silent timeout bypass#100

Open
AlexanderJohnston wants to merge 40 commits into
bwatts:devfrom
AlexanderJohnston:fix/connstring-default-deadline
Open

PR #6: Bug - Apply DefaultDeadline unconditionally in BuildClientSettings to fix silent timeout bypass#100
AlexanderJohnston wants to merge 40 commits into
bwatts:devfrom
AlexanderJohnston:fix/connstring-default-deadline

Conversation

@AlexanderJohnston

Copy link
Copy Markdown

Problem

When ESDB experiences transient I/O pressure (chunk caching/uncaching, storage writer queue backup), gRPC calls from Totem can exceed the configured DefaultDeadline and return DeadlineExceeded. This permanently kills the affected flow — there is no retry, no backoff, and no recovery without manual intervention.

BuildClientSettings in EventStoreServiceExtensions has two paths depending on whether options.ConnectionString is set:

static EventStoreClientSettings BuildClientSettings(EventStoreTimelineOptions options, IServiceProvider provider)
{
    if(!string.IsNullOrEmpty(options.ConnectionString))
    {
        var settings = EventStoreClientSettings.Create(options.ConnectionString);
        settings.LoggerFactory = provider.GetService<ILoggerFactory>();
        return settings;  // ← DefaultDeadline is NEVER set
    }

    // ... structured path ...
    s.DefaultDeadline = options.Connection.Timeout;  // ← only reached here
    return s;
}

When the ConnectionString early-return path is taken, DefaultDeadline is never assigned. The gRPC client is created with no per-call deadline, falling back to whatever the EventStore .NET client's internal default happens to be (no deadline by default in gRPC — calls can wait indefinitely, or the client may impose its own).

The ConnectionString property defaults to null, but if any configuration source populates it — an environment variable, a Docker/Kubernetes secret, a hosting platform injecting ConnectionStrings__EventStore, or any other source in Microsoft's configuration merge order (JSON → env vars → command line) — the structured options in appsettings.json are silently bypassed. The operator's 15-minute timeout configuration has no effect.

Even if the ConnectionString path is not the active cause of the 10s deadline in this specific incident, the bug is real: any operator who switches to connection-string-based configuration will silently lose all timeout protection. LoggerFactory had the same issue — it was set redundantly in both paths, but DefaultDeadline was only set in one.

Fix

Restructure BuildClientSettings so both paths converge before applying shared settings:

static EventStoreClientSettings BuildClientSettings(EventStoreTimelineOptions options, IServiceProvider provider)
{
    EventStoreClientSettings s;

    if(!string.IsNullOrEmpty(options.ConnectionString))
    {
        s = EventStoreClientSettings.Create(options.ConnectionString);
    }
    else
    {
        // Omit the credentials when insecure mode is true

        var tls = options.Server.Insecure ? "tls=false" : "";
        var includeCredentials = !options.Server.Insecure && !string.IsNullOrEmpty(options.Connection.Username);

        var connStr = includeCredentials
            ? $"esdb://{options.Connection.Username}:{options.Connection.Password}@{options.Server.Name}:{options.Server.Port}?{tls}"
            : $"esdb://{options.Server.Name}:{options.Server.Port}?{tls}";

        s = EventStoreClientSettings.Create(connStr);
    }

    s.LoggerFactory = provider.GetService<ILoggerFactory>();
    s.DefaultDeadline = options.Connection.Timeout;
    return s;
}

LoggerFactory and DefaultDeadline are now assigned exactly once, unconditionally, after both paths produce an EventStoreClientSettings instance.

What this preserves

Behaviour Before After
Structured path (no ConnectionString) DefaultDeadline set from options.Connection.Timeout ✅ Identical
ConnectionString path DefaultDeadline not set — silent omission ✅ Now set from options.Connection.Timeout
LoggerFactory assignment Set in both paths (redundant) Set once after convergence
Connection string parsing EventStoreClientSettings.Create(connStr) ✅ Identical
Structured connection building Username/password/TLS logic ✅ Identical — just indented into else block

What this changes

  • ConnectionString path now gets a DefaultDeadline. Previously it had none. If the connection string itself contains a timeout parameter, DefaultDeadline sets the per-call default but gRPC per-call deadlines explicitly passed to individual operations still take precedence. No conflict.
  • Code structure. The early return is replaced with an if/else that converges. Three fewer lines total. No new dependencies.

Scope

One method in one file. No new packages, no behavioural changes to the structured path, no changes to flow runtime logic.

bwatts and others added 30 commits February 6, 2026 09:46
- Write to the client stream whether or not the checkpoint write succeeds
- Do not ignore events routed to stopped flows
- Avoid a null reference when an existing flow fails to load
The SkipException handling that required await was removed in a prior commit.
Simplify back to returning the task directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Transition core standard libraries to net10.
- Upgrade Totem, Totem.Runtime, Totem.Timeline, Totem.App.Tests from netstandard2.0 to net10.0
- Update Microsoft.Extensions.* package versions from 2.2.0 to 10.0.0
- Replace FormatterServices.GetUninitializedObject() with RuntimeHelpers.GetUninitializedObject() in DurableType.cs
- Replace Assembly.LoadWithPartialName() with Assembly.Load(new AssemblyName()) in TypeResolver.cs
- Remove ToHashSet extension methods that conflict with built-in LINQ on net10.0
- Fix C# 14 field keyword conflict in Fields.cs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Create new STJ infrastructure replacing Newtonsoft.Json core:

- Add TotemJsonTypeInfoResolver: replaces JsonFormatContractResolver and
  JsonFormatSerializationBinder with DefaultJsonTypeInfoResolver + Modifiers
  - Many<T> collection creation via expression-compiled factory
  - Durable type object creation via RuntimeHelpers.GetUninitializedObject()
  - Property filtering: excludes [Transient], [CompilerGenerated], Notion-declared
  - Private field/property serialization for durable types
  - [WriteOnly] attribute support (serialize but don't deserialize)

- Add DurableTypeDiscriminatorConverter: JsonConverterFactory handling polymorphic
  \ property with 'durable:Prefix:TypeName' values for backward compatibility
  with Newtonsoft-serialized EventStore data. Includes TypeResolver fallback for
  legacy assembly-qualified type names.

- Redesign IJsonFormat: expose JsonSerializerOptions instead of Apply() pattern
- Rewrite JsonFormat: simple wrapper around JsonSerializerOptions
- Rewrite JsonFormatExtensions: use JsonSerializer/JsonNode instead of
  JsonConvert/JObject. Add ToJsonNode/ToJsonNodeUtf8 methods replacing JObject.
  Implement CopyProperties for PopulateObject equivalent.
- Rewrite JsonFormatOptions: SerializerOptions replaces SerializerSettings
- Rewrite JsonFormatOptionsSetup: configure WriteIndented, CamelCase naming,
  JsonStringEnumConverter, TotemJsonTypeInfoResolver, DurableTypeDiscriminatorConverter
- Update JsonServiceExtensions: use SerializerOptions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… STJ

Phase 3 - Timeline Converters:
- Rewrite FlowKeyConverter as JsonConverter<FlowKey> using Utf8JsonReader/Writer
- Rewrite TimelinePositionConverter as JsonConverter<TimelinePosition>
- Update TimelineJsonFormatOptionsSetup to use SerializerOptions

Phase 4 - EventStore Layer:
- Rewrite SubscribeCommand: replace JObject/JArray/JToken (Newtonsoft.Json.Linq)
  with JsonNode/JsonArray (System.Text.Json.Nodes)
- Use JsonNode.GetValue<T>() instead of JToken.Value<T>()
- Use JsonArray instead of JArray, pattern matching with 'is JsonArray'

Phase 5 - MVC Layer:
- Rewrite WebRuntimeOptionsSetup: replace IPostConfigureOptions<MvcNewtonsoftJsonOptions>
  with IPostConfigureOptions<JsonOptions>, copying STJ settings instead of 22
  Newtonsoft properties
- Remove .AddNewtonsoftJson() from ConfigureWebApp.cs (STJ is MVC default)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…iles

- Remove Newtonsoft.Json 13.0.3 package from Totem.Runtime.csproj
- Remove Microsoft.AspNetCore.Mvc.NewtonsoftJson 10.0.0 package from Totem.Timeline.Mvc.csproj
- Delete JsonFormatContractResolver.cs (replaced by TotemJsonTypeInfoResolver)
- Delete JsonFormatSerializationBinder.cs (merged into DurableTypeDiscriminatorConverter)

All direct Newtonsoft.Json dependencies are now removed. The only remaining
Newtonsoft reference is a transitive dependency from EventStore.Client.Grpc
(Newtonsoft.Json 9.0.1) which is outside our control.

Solution builds with 0 errors across all projects.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AlexanderJohnston and others added 10 commits February 17, 2026 09:38
The DurableTypeDiscriminatorConverter.Read method was only checking the first
property for the \ discriminator. Since JSON property order is not
guaranteed, this could miss the discriminator and deserialize as the base
type instead of the actual polymorphic type.

Now scans through all properties using Utf8JsonReader.TrySkip() to find
\ regardless of position in the JSON object.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…o STJ

STJ does not auto-detect [TypeConverter] attributes like Newtonsoft did.
This caused deserialization crashes for 20+ types (TypeName, Id, FileLink,
etc.) that use TextConverter for string-based serialization.

The factory detects types with a non-default TypeConverter that supports
string conversion, and serializes/deserializes them as JSON strings via
ConvertFromInvariantString/ConvertToInvariantString.

Registered in JsonFormatOptionsSetup.Configure() after JsonStringEnumConverter,
before the durable type converters in PostConfigure(). No conflicts since
TypeConverter types are not durable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ctory

Add TypeConverterJsonConverterFactory to bridge [TypeConverter] types to STJ
…only

The previous check matched all .NET built-in types with TypeConverters
(int, DateTime, bool, Guid, etc.), causing reader.GetString() to throw
on non-string JSON tokens. This silently broke ASP.NET model binding
for any model with non-string properties (e.g. SmartScanRecord.FileCount).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Narrow TypeConverterJsonConverterFactory and add dictionary key support
When TryStart() encounters FlowInfo.Stopped, complete the lifetime
directly instead of throwing through the ObserveNextPoint catch path,
which redundantly re-enters Stop() and writes a new checkpoint per
queued event.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The ConnectionString early-return path was missing the DefaultDeadline
assignment, so using a connection string bypassed the configured timeout
entirely. Restructure to converge both paths before applying shared
settings (LoggerFactory, DefaultDeadline).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@AlexanderJohnston AlexanderJohnston changed the title PR #7: Bug - Apply DefaultDeadline unconditionally in BuildClientSettings to fix silent timeout bypass PR #6: Bug - Apply DefaultDeadline unconditionally in BuildClientSettings to fix silent timeout bypass Feb 20, 2026
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