Skip to main content

Crate dynamic_config_nats

Crate dynamic_config_nats 

Source
Expand description

Read dynamic-config configuration from a NATS JetStream key/value bucket.

NATS is a streaming protocol and its client is async throughout, so this implements the async AsyncRemoteSource trait rather than the blocking one.

use dynamic_config_nats::Nats;

DbConfig::set_remote_async(
    Nats::new("nats://nats.internal:4222", "config", "db.json").await?,
);

// Fetching is explicit; the load that follows touches no network.
DbConfig::refresh_remote_async().await?;

§What it reads

One key in one bucket, whose value is a whole configuration document — the same bytes that would be in a config file. The format comes from the key’s extension, or from with_format.

Like Consul and unlike Vault, that is a deliberate difference: a KV bucket stores opaque bytes, so the natural unit is the document. Vault’s KV v2 stores a JSON object of fields, so the natural unit there is the field.

§Several keys as one document

A deployment that splits its configuration across several keys of one bucket can have one source read the lot, and Keys says which:

// Named keys: a list of layers, merged in the order given, later wins.
let nats = Nats::new(
    "nats://nats.internal:4222",
    "config",
    Keys::several(["base.json", "local.json"]),
)
.await?;

A named list is one get per key, and a bucket read is a request to the stream: there is no batch get in the KV API, so the set is not read atomically. A write landing between two of the gets can produce a document that never existed as a whole.

There is deliberately no prefix form, and the reason is the client’s rather than a preference. Store::keys() is the only listing there is, and it lists the whole bucket: it builds an ordered consumer filtered on $KV.{bucket}.> and streams a header for every key in it. async-nats keeps the filtered constructor behind a private method, so a prefix here would be a full-bucket scan wearing a prefix’s name — the 512-key bound would have to be a bound on the bucket, and a bucket of a hundred thousand keys would stream a hundred thousand headers to find three. Name the keys, or put the set in its own bucket, which is the partition NATS actually offers. dynamic-config-consul and dynamic-config-etcd have a real range read and take a prefix for that reason.

Two consequences the multi-key form shares with the rest of the family:

  • Provenance becomes store-grained. The merged document is one layer, so source_of names the set rather than which key supplied a value.
  • One unreadable key fails the whole fetch. A configuration quietly missing a section is worse than a refresh that failed and left the last document serving.

§JetStream must be enabled

A key/value bucket is a JetStream feature. A NATS server started without it answers with a “JetStream is not enabled” error, which is reported as it arrives rather than translated into something vaguer.

§The connection is made once

Nats::new connects and resolves the bucket; fetch reuses that handle. Unlike a gRPC client this connects eagerly, so an unreachable server is a construction failure.

The store handle is Clone and its reads take &self, so — unlike etcd — nothing here needs a lock.

§Reconnecting is the client’s job, and it does it

async-nats reconnects on its own, indefinitely, and re-establishes subscriptions when it does. So there is deliberately no retry logic here: adding one would mean a second, worse implementation of something the client already does properly, layered on top of it.

Two consequences worth knowing. A fetch during a disconnect fails rather than blocking until the connection returns — configuration that hangs is worse than configuration that reports. And a watch survives a reconnect without the caller noticing, which is why it ending at all is treated as an error.

§Credentials

Everything NATS understands — a token, a user and password, an NKey, a JWT, a .creds file, TLS — goes through ConnectOptions, which is NATS’ own type re-exported. See Nats::with_options.

A credential the server refuses fails at construction, and reports as ErrorKind::Auth rather than Remote — the one distinction that separates “the password is wrong” from “the server is down”, and the only place async-nats draws it. A later read refused for want of permission arrives as an undifferentiated KV error, so it stays Remote: guessing there would stop a watch loop that a reconnect would have fixed.

A credential in the URLnats://token@host:4222 is a shape NATS accepts — is redacted before the address is stored, because the address is quoted into every error message and into Debug.

§Timeouts

Nats::with_timeout is the deadline for a single fetch attempt, excluding retries the underlying client performs — the sentence every store in this family answers to. Ten seconds by default.

ConnectOptions::request_timeout is its twin on the connection side, set through Nats::with_options before there is a connection to bound. Neither applies to Nats::watch, which is long-lived on purpose.

§Watching

A KV bucket is a stream, so Nats::watch is a future the caller spawns and cancels by dropping — no runtime is imposed and no flag is polled.

A multi-key source cannot be watched, and refuses rather than pretending to: what a watch delivers here is the document that changed, and for a merged document that means re-reading the whole set on every event. Poll refresh_remote_async() on a timer instead.

let task = tokio::spawn(async move {
    nats.watch(move |document| sink(document)).await
});

// Dropping or aborting the task stops the watch.
task.abort();

§A watch that is failing says so

A watch is the half of a store dynamic-config cannot see: a delivery keeps RemoteStatus current, and a stream that broke delivers nothing and would otherwise report nothing — so dynamic_config_remote_up would describe the last delivery rather than the last attempt. reporting_to closes that: the sink the loop already holds is told about every attempt that came back with nothing, and a store that stopped answering an hour ago reads as down without anything having to call refresh_remote_async().

What that covers here follows from the section above: a server that goes away is not a failed watch, because async-nats keeps recreating the subscription for as long as it takes and the loop waits through it. What reaches this crate is a stream that stopped — a deleted bucket, a consumer that is gone, a value that is not a document — and that is what is reported.

§Every failure branch of the watch loop, and what it reports

A watch is the half of a store dynamic-config cannot see, and reporting_to is what lets it speak: the sink the loop already holds is told about every attempt that came back with nothing. Which attempts those are is a table rather than prose, because the question an operator asks is which silence is deliberate.

Three rules decide the column, and they are the same three in all seven store crates:

  1. A failure the loop survives by retrying reports. That is the case the whole feature exists for: the stream is down, the last delivery is old, and nothing else would ever say so out loud.
  2. A recovery that worked stays silent. Only a delivery or a fetch clears the streak, so reporting a five-minute token turning over on a healthy cluster would drive remote_up to zero and leave it there.
  3. A refusal that never asked the store reports nowhere. No format, a key shape that cannot be watched, material that will not build a client: RemoteStatus::reachable() is whether the store answered the last time it was asked, and these never ask. They are returned to the caller, who is the one holding the mistake — and a status cannot correct them, since it carries a kind and a path and no message.
BranchReports
the format is missing, or the source names several keysno — rule 3: nothing has been asked of the server
the bucket refuses the watchyes — the first round trip
the stream errorsyes, and the watch ends — async-nats reconnects on its own, so reaching here means it could not
an operation that is not a Putno — nothing changed
the value is not UTF-8yes — the same failure a fetch of it would have recorded
on_change refuses the documentno — the store answered; apply counted the delivery, and what the document did next is ConfigStatus’s half
the stream ends without an erroryes — the connection went away, or the bucket did

Structs§

Client
NATS’ own connection options, re-exported so authenticating needs no direct dependency on async-nats.
ConnectOptions
NATS’ own connection options, re-exported so authenticating needs no direct dependency on async-nats.
Nats
A key in a JetStream bucket, as a configuration source.
TlsConfig
A private certificate authority and a client certificate, as data.

Enums§

Keys
What a source reads: one key, or several named ones.