Table of Contents

Hosting and Dependency Injection

AddDatabento binds the Databento configuration section and registers no clients; AddDatabentoHistorical and AddDatabentoReference add the two HTTP clients on IServiceCollection, and AddDatabentoLive runs one named live session as a hosted service, with bounded reconnection, an opt-in health check, and metrics built in. This page covers DatabentoDotNet.Extensions.Hosting end to end: registration, the configuration shape, writing a handler, running more than one session, and what a hosted session does when the gateway drops.

AddDatabento on its own gives you no clients — it is the section marker every other call reads, plus the one LiveSessionMetrics meter every session shares, and the three Add* calls below are what register something you can resolve. Calling only AddDatabento() and then asking for a HistoricalClient is No service for type 'DatabentoDotNet.Historical.HistoricalClient' has been registered.

For the client underneath the hosted service, see Live Streaming — this page does not repeat the session lifecycle, the record loop, or the timeout rules, all of which apply unchanged to a session run this way.

Note

This page describes DatabentoDotNet.Extensions.Hosting 0.10.0, its first release. The version is 0.x deliberately: this package's surface has had nothing building against it yet, and 0.x is how the other four bought that evidence before 1.0 promised anything. Expect it to change, and say so on an issue if it should. See ROADMAP.md §8.


Install

dotnet add package DatabentoDotNet.Extensions.Hosting

In a plain console app, add Microsoft.Extensions.Hosting as well. This package references only the Abstractions half of it — IHostedService, IHostApplicationLifetime, the pieces a host implements rather than provides — so Host.CreateApplicationBuilder is not reachable through it and the first snippet below would not compile. The Worker and Web SDKs already carry the real host, and samples/DatabentoDotNet.Samples.HostedLive takes it directly for exactly this reason.

One package reference brings all four core packages with it — DatabentoDotNet.Dbn, .Live, .Historical and .Reference — because registering HistoricalClient in a web API and never touching the live client is a legitimate way to use this package, and a split into an HTTP-only package and a live-hosting package was rejected: two baselines, two READMEs and two guides, to save a transitive reference to four packages that are small, pure managed, and AOT-clean.

The shortest thing that works

A minimal session, using the default name and the parameterless registration:

using DatabentoDotNet.Dbn;
using DatabentoDotNet.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddDatabento();
builder.Services.AddDatabentoLive().AddRecordHandler<TradePrinter>();

using var host = builder.Build();
await host.RunAsync();

internal sealed class TradePrinter : ILiveRecordHandler
{
    public void OnRecord(scoped RecordRef record)
    {
        if (record.TryGet(out TradeMsg trade))
        {
            Console.WriteLine($"{record.Header.InstrumentId} {trade.Price} x {trade.Size}");
        }
    }

    public ValueTask OnFlushAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
}
{
  "Databento": {
    "Live": {
      "Default": {
        "Dataset": "EQUS.MINI",
        "Subscriptions": [
          { "Schema": "trades", "Symbols": ["AAPL", "MSFT"] }
        ]
      }
    }
  }
}

No ApiKey anywhere in that file. LiveSessionResolver looks for one in three places, in order: the session's own ApiKey, then Databento:ApiKey, then the DATABENTO_API_KEY environment variable — so exporting the variable is enough to run the example above. A configuration that supplies none of the three fails at startup (ValidateOnStart), naming all three places it looked.

Default is a literal, not a placeholder — it is DatabentoLiveBuilder.DefaultSessionName, and the parameterless AddDatabentoLive() always binds it. That keeps every session's configuration at Databento:Live:{name}, named or not, rather than making the unnamed case a special shape.

Registering the historical and reference clients

builder.Services.AddDatabento();
builder.Services.AddDatabentoHistorical();
builder.Services.AddDatabentoReference();

Both are ordinary singletons, not hosted services — there is no session lifecycle to run, so nothing here needs a BackgroundService. AddDatabentoReference composes with AddDatabentoHistorical in either order: whichever runs first registers the shared HistoricalClient, and the other reuses it, so calling both — directly or because your code and a library you depend on each call one — still yields one client, one connection pool, and no disposal race. See HistoricalOptions for PooledConnectionLifetime, the one setting that exists because a singleton HistoricalClient in a host that stays up for weeks would otherwise keep talking to whatever address it resolved on its first request.

AddDatabentoHistorical takes the same lambda-overload pattern as AddDatabentoLive below, applied after binding: AddDatabentoHistorical(options => options.UserAgentExtension = "my-app/1.0"). AddDatabentoReference has no lambda overload, and will not gain one — it configures nothing of its own, since the reference client shares Databento:Historical with the historical client, so configure both through AddDatabentoHistorical's. An AddDatabentoReference(Action<HistoricalOptions>) would be a second name for a method that already exists, over the same options object: call both and you have written what reads as two independent configurations and got one, last writer winning on every key they share. If reference data ever gains a setting genuinely its own, that setting brings the overload with it.

Both calls are idempotent, in either order and however many times: one HistoricalClient, one named HttpClient, one connection pool.

Reaching the transport

The HttpClient those two share is registered with IHttpClientFactory under the name DatabentoServiceCollectionExtensions.HttpClientName — the string DatabentoDotNet.Historical. That is a public constant because the standard way to layer a proxy, a corporate HttpMessageHandler, or a resilience policy onto a factory registration has no form that does not name the client:

builder.Services.AddDatabentoHistorical();
builder.Services.AddHttpClient(DatabentoServiceCollectionExtensions.HttpClientName)
       .AddHttpMessageHandler<CorrelationIdHandler>();

The same call is where AddStandardResilienceHandler() goes if you have Microsoft.Extensions.Http.Resilience installed. Guessing the string instead of using the constant fails silently rather than loudly: it configures a second, unused client that nothing resolves.

One thing not to do on that name: ConfigurePrimaryHttpMessageHandler replaces the SocketsHttpHandler this package installs, and with it the PooledConnectionLifetime rotation that is the whole reason the registration exists. Add delegating handlers, or set PooledConnectionLifetime yourself on whatever you put there.

Configuration reference

The Databento root is a default, not a fixture

Every heading below is written Databento:… because that is what AddDatabento() binds from. It is a default. AddDatabento takes a path, or an IConfigurationSection, when your configuration puts these keys somewhere else:

builder.Services.AddDatabento("MyApp:Feeds");
builder.Services.AddDatabento(builder.Configuration.GetSection("MyApp:Feeds"));

Everything below then hangs off that root instead — MyApp:Feeds:Historical, MyApp:Feeds:Live:equitiesincluding the paths in startup-failure messages, which name the section you registered and never the literal Databento. A message naming a key that is not in your file would be worse than one naming no key at all: it sends you looking.

The overloads are equivalent: the section form reads the section's Path and discards the rest, because the binding happens when the options are built and resolves its own IConfiguration from the container then.

Call it before the other Add* methods, and you will be told if you do not. The first registration that needs a root fixes it for the container — AddDatabento when you call it first, and the conventional Databento when something else runs first — so this throws:

builder.Services.AddDatabentoHistorical();       // pins the default root, Databento
builder.Services.AddDatabento("MyApp:Feeds");    // InvalidOperationException

AddDatabento("MyApp:Feeds") cannot run: this IServiceCollection is already bound to the configuration section "Databento".

It used to bind the historical options from Databento:Historical and everything registered afterwards from MyApp:Feeds, in a container that started and resolved and read half its settings from a key you never wrote. Naming the second root is a contradiction rather than a preference, so it is an error.

Calling AddDatabento() after another Add* is fine, because that names the root already in force rather than a second one. Ordering only matters when the path does.

Every duration below is an ISO-8601 duration, not a TimeSpan shorthand — "30s" and "00:00:30" both fail to parse and are reported as a startup failure naming the configuration path that held them. PT30S is thirty seconds, PT5M is five minutes, PT1H30M is one hour and thirty minutes. The T matters: it separates the date part (years, months, weeks, days) from the time part (hours, minutes, seconds), which is how P1M (one month) and PT1M (one minute) are different strings rather than an ambiguity. A month or year component is rejected outright — a month has no fixed length, so it cannot become the Duration these values resolve to.

Databento — root

Key Type Default Notes
ApiKey string none Used by any client or session that does not carry its own. Checked after a session's own key and before DATABENTO_API_KEY.

Databento:Historical — the shared historical/reference transport

Key Type Default Notes
ApiKey string the root's
BaseUrl string (URL) Databento's gateway For a proxy or a test harness
UserAgentExtension string none Appended to this library's own User-Agent
PooledConnectionLifetime ISO-8601 duration PT5M How long a pooled connection is reused before rotation

Databento:Live:{name} — one session

Key Type Default Notes
ApiKey string the root's, then DATABENTO_API_KEY
Dataset string required Wire name, e.g. EQUS.MINI, GLBX.MDP3
Subscriptions array required, at least one See below
Reconnect object see below
SendTsOut bool false Ask the gateway to stamp each record with its send time
Compression string none none or zstd
SlowReaderBehavior string the gateway's default warn or skip
HeartbeatInterval ISO-8601 duration the gateway's default 5–1800 seconds; see Live Streaming
ReadTimeout ISO-8601 duration derived from the heartbeat interval Same page
CloseTimeout ISO-8601 duration PT5S How long shutdown waits for a courteous close; see below. Must be positive
Gateway host:port derived from Dataset Override, e.g. to point a test at a mock

Databento:Live:{name}:Subscriptions[]

Key Type Default Notes
Schema string required Wire spelling, e.g. trades, mbp-1, ohlcv-1s
StypeIn string raw_symbol Wire spelling, e.g. parent, continuous, instrument_id
Symbols array of string required Or a single entry of ALL_SYMBOLS for the whole dataset
Start ISO-8601 instant none Intraday replay, e.g. 2024-01-01T00:00:00Z
UseSnapshot bool false Book snapshot first. MBO only, and not with Start

Databento:Live:{name}:Reconnect

Key Type Default Notes
Enabled bool true
InitialDelay ISO-8601 duration PT1S The first backoff delay
MaxDelay ISO-8601 duration PT30S The backoff ceiling
MaxAttempts int 10 Consecutive failures tolerated — see Reconnection

A session can also be configured, or overridden after binding, with a lambda:

builder.Services.AddDatabentoLive("equities", options => options.Dataset = "XNAS.ITCH");

The lambda runs after BindConfiguration, so it wins over a bound value — the same order AddDatabentoHistorical's lambda overload uses.

Drop the name to configure the default session. It is the same overload with DatabentoLiveBuilder.DefaultSessionName, which is the one name in this family you should never have to type:

builder.Services.AddDatabentoLive(options =>
{
    options.Dataset = "XNAS.ITCH";
    options.Subscriptions.Add(new SubscriptionOptions { Schema = "trades", Symbols = { "AAPL" } });
}).AddRecordHandler<TradePrinter>();

Databento:Live:Default still binds first, so a key in appsettings.json and a lambda compose the way they do for a named session — and a host whose one session is configured entirely in Program.cs can leave that section out of the file altogether.

Shutdown, and CloseTimeout

When the host stops, each session breaks out of its record loop and then half-closes: the gateway gets to finish rather than having the socket dropped on it. CloseTimeout bounds that courtesy, so a gateway that never answers cannot hold shutdown open. It defaults to five seconds, and expiry is logged as a warning — event id 6, "did not close within …; the socket is being dropped instead" — rather than faulting the session, because a slow close is not a failed stream.

Keep it below your host's own ShutdownTimeout. The close runs while the host is already stopping, and the host stops waiting when its own budget expires, so a ceiling above that budget describes a wait that cannot happen. The generic host's default is thirty seconds, which leaves the five-second default plenty of room; a host tuned down near or below five wants this tuned with it:

builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(4));
{ "Databento": { "Live": { "equities": { "CloseTimeout": "PT2S" } } } }

It is not derived from ShutdownTimeout automatically, and that is deliberate rather than an oversight: reading HostOptions means depending on Microsoft.Extensions.Hosting — the application composition-root package, with the configuration and logging providers behind it — from a library that otherwise needs only Microsoft.Extensions.Hosting.Abstractions. That is a large dependency for every consumer in exchange for one property read, so the number is yours to set.

Zero is rejected at startup. It would lose the race on every shutdown, so the session would drop the socket immediately and log a close timeout that never happened.

A LiveSessionRunner constructed directly, with no host, takes the same five seconds and exposes AwaitCloseAsync(Task) for a caller closing the client themselves.

What startup validation covers, and what it does not

"Validated at startup" means every value above was parsed and converted, not that every constraint in those tables was checked. ValidateOnStart runs LiveSessionResolver, which is the one crossing from these strings to the library's real types, and every failure it reports names its configuration path:

Databento:Live:equities:Subscriptions:0:Schema — 'mbp1' is not a Databento schema.

That covers the API key, the dataset, each subscription's schema, symbology and symbol set, every ISO-8601 duration and instant, MaxAttempts, the InitialDelayMaxDelay pair, and the Gateway endpoint including its port range.

Three of the constraints above are enforced by the library rather than by the resolver, and surface later:

Constraint Checked by Surfaces as
HeartbeatInterval is 5–1800 seconds LiveClient.HeartbeatInterval ArgumentOutOfRangeException naming the property
ReadTimeout is positive LiveClient.ReadTimeout ArgumentOutOfRangeException naming the property
UseSnapshot is mbo-only and never with Start Subscription.Validate ArgumentException naming the parameter

The resolver does not re-check them on purpose: a second copy of a rule the library already holds is a copy free to drift from it, and the one that silently disagrees is the one nobody is looking at. Subscription.Validate is internal to DatabentoDotNet.Live besides, so there is no delegating to it either.

The practical consequence is narrow. All three still fail the host's boot rather than a background task — LiveSessionService.StartAsync awaits the session's start before base.StartAsync, so the process does not come up reporting itself healthy. What you lose is the configuration path in the message: the exception names HeartbeatInterval, and you have to know it came from Databento:Live:{name}:HeartbeatInterval.

Writing a handler

internal sealed class TradePrinter : ILiveRecordHandler
{
    public void OnRecord(scoped RecordRef record)
    {
        // Copy out what you need. The RecordRef points into the runner's read buffer and is
        // valid for this call only — the next fill may shift it.
        if (record.TryGet(out TradeMsg trade))
        {
            Console.WriteLine($"{record.Header.InstrumentId} {trade.Price} x {trade.Size}");
        }
    }

    public ValueTask OnFlushAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
}

The rule, stated plainly: copy out what you need in OnRecord; the reference is valid for that call only. OnRecord is synchronous and cannot await anything — a RecordRef cannot survive an await, so there is no way to hand it somewhere that could. OnFlushAsync runs once per socket fill, after every buffered record has been drained, and is where I/O belongs.

A handler is registered once and constructed as a singleton — a DI scope per record would allocate, in the one package whose reason to exist is that it does not. A handler that needs a scoped service (a DbContext, a per-request HttpClient) opens a scope inside OnFlushAsync itself, batching records in a field between flushes:

public sealed class TradeWriter(IServiceScopeFactory scopeFactory) : ILiveRecordHandler
{
    private readonly List<TradeMsg> _batch = [];

    public void OnRecord(scoped RecordRef record)
    {
        if (record.TryGet(out TradeMsg trade))
        {
            _batch.Add(trade);   // TradeMsg is a plain struct — this copies, it does not alias
        }
    }

    public async ValueTask OnFlushAsync(CancellationToken cancellationToken)
    {
        if (_batch.Count == 0)
        {
            return;   // an already-completed ValueTask allocates nothing
        }

        await using var scope = scopeFactory.CreateAsyncScope();
        await scope.ServiceProvider.GetRequiredService<ITradeSink>()
                   .WriteAsync(_batch, cancellationToken);

        _batch.Clear();
    }
}

An exception from either method ends the session — swallowing one would lose market data invisibly. A handler that wants to carry on catches its own. Full member documentation, including why the interface has exactly these two methods and no third: ILiveRecordHandler.

Two sessions in one host

builder.Services.AddDatabentoLive("equities").AddRecordHandler<EquityHandler>();
builder.Services.AddDatabentoLive("futures").AddRecordHandler<FutureHandler>();

One LiveClient is one TCP connection to one dataset — Dataset is required and singular. Streaming two datasets is therefore two sessions, not one session with two subscriptions, and the registration reflects that: two names, bound from Databento:Live:equities and Databento:Live:futures, each with its own handler, its own LiveSessionRunner, and its own independent reconnect state. A gateway drop on one does not touch the other.

Reconnection

On by default. ReconnectOptions.Enabled starts true, and a hosted session reconnects on a transient failure — a dropped connection, a heartbeat timeout, a protocol error — without any code beyond registration. A rejected API key is not transient and is not retried: retrying a wrong key bills nothing and fixes nothing.

Every successful reconnect is a newly billed session, and MaxAttempts is what bounds that cost — budget for it accordingly. How the bound counts attempts, what resets it, and why the jitter it applies is not configurable are all on ReconnectSupervisor, the type that implements it.

A clean close is not a failure, and does not reconnect. When the gateway ends the stream on purpose, the session moves to LiveSessionState.Stopped, not Faulted, and nothing retries it — retrying a deliberate close would turn "the gateway said stop" into "reconnect forever."

Health checks and metrics

A health check is opt-in: nothing in AddDatabentoLive registers one, so a consumer who never calls AddHealthCheck pays nothing for it.

builder.Services.AddDatabentoLive("equities")
    .AddRecordHandler<EquityHandler>()
    .AddHealthCheck();

It reports LiveSessionRunner.State, mapped as:

State Result
Running Healthy
NotStarted, Starting Degraded — coming up, not yet serving
Reconnecting Degraded — the backoff is running and bounded, and most drops recover on the first attempt
Stopped Unhealthy — the worker is alive and reading nothing, which is the failure a probe exists to surface
Faulted Unhealthy, carrying the fault's message and exception

Full parameter documentation — the registration name, and failureStatus for a session whose loss should degrade rather than take the process out of rotation — is on DatabentoLiveBuilder.

Four instruments publish on every session, on the meter named DatabentoDotNet.Extensions.Hosting — pass that name to AddMeter when wiring up OpenTelemetry:

Instrument Unit Reports
databento.live.records.received {record} Records handed to the handler, once per flush
databento.live.sessions.started {session} Sessions opened, including ones re-established by a reconnect
databento.live.reconnects.attempted {attempt} Reconnection attempts, successful or not
databento.live.flush.duration ms How long OnFlushAsync took, once per drained buffer

Every measurement carries a databento.session tag naming the session, so two sessions in one host are two distinct series on each instrument rather than one that mixes them. Full detail on why each of these publishes exactly where it does — and why none of them is called once per record — is on LiveSessionMetrics.

What is not here, and never will be

There is no Task<RecordRef>, and there never can be one. An async method cannot return a ref struct, so a per-record await is not available at any price — not in this package and not in DatabentoDotNet.Live underneath it. OnRecord is synchronous for the same reason LiveClient.TryNextRecord is: the compiler enforces the lifetime this package's own zero-allocation guarantee depends on. See Zero-Copy and Allocation for the full argument.

If that split is more than a given handler wants to think about, LiveClient.RecordsAsync — the plain client's own IAsyncEnumerable<OwnedRecord> surface — is still there and needs no help from this package. It costs two allocations per record. Nothing prevents building a handler around it; this package's guarantee is simply that its own path, the one described above, costs none.

See also

  • Live Streaming — the client a hosted session runs underneath: the CRAM handshake, subscriptions, timeouts, and what reconnecting means at the protocol level
  • Zero-Copy and Allocation — why OnRecord takes a scoped RecordRef and what the compiler will and will not let you do with one
  • DatabentoDotNet.Extensions.Hosting — the full API reference for this package
  • ROADMAP.md §8 — why this ships at 0.x rather than alongside a promised 1.0, and the HttpMessageHandler seam that designing it turned up in HistoricalClient