🇬🇧 English | 🇺🇦 Українська
10. JetStream: Key-Value¶
Key-Value (KV) is a "key → value" store built on top of JetStream; covered conceptually in
1. Introduction to NATS.
A key in KV is, in essence, the subject $KV.<bucket>.<key> in a hidden stream, so the
character requirements for a key are the same as for a subject: a dot separates a key's
"levels" (player.42.stats is a valid key with dots), and a key itself may not contain
spaces.
Buckets¶
CreateBucket / DeleteBucket¶
Get JetStream
│
└─► Get Key-Value Store
│
└─► Create KV Bucket
Config → Bucket : "PLAYER_STATS"
Config → History : 1
Config → Storage : File
UNatsKVStoreImpl* KV = Nats->GetJetStream()->KeyValue();
FJetStreamKVConfig Config = FJetStreamKVConfigBuilder()
.WithBucket(TEXT("PLAYER_STATS"))
.WithStorage(EJetStreamStorageType::File)
.WithHistory(5)
.Build();
KV->CreateBucket(Config, [](TJetStreamResult<bool> Result) {});
Like Create Stream, calling this again for an existing bucket returns success rather than
an error — safe to call on every game start.
Bucket configuration¶
FJetStreamKVConfig:
| Field | Type | Default | Description |
|---|---|---|---|
Bucket |
FString |
— | The bucket's name, e.g. "PLAYER_STATS" |
MaxAge |
int32 |
0 (no limit) |
The entry's lifetime, in seconds |
MaxValueSize |
int32 |
-1 (server limit) |
The maximum value size, in bytes |
History |
int32 |
1 |
How many versions to keep per key. 1 — the current value only |
Storage |
EJetStreamStorageType |
Memory |
Memory or File — the same principle as with streams |
Replicas |
int32 |
1 |
Cluster replication |
Delete KV Bucket
Bucket : "OLD_BUCKET"
Irreversible: deletes the bucket and every key in it.
Basic operations¶
KV Put / KV Get¶
KV Put
Bucket : "PLAYER_STATS"
Key : "player_42"
Value : "{\"level\":5,\"xp\":1200}"
│
└─ bSuccess → Entry → Revision : 1
KV Get
Bucket : "PLAYER_STATS"
Key : "player_42"
│
└─ bSuccess → Entry → Value : "{\"level\":5,\"xp\":1200}"
└─ !bSuccess → the key doesn't exist (Error Code = Key Not Found)
KV->Put(TEXT("PLAYER_STATS"), TEXT("player_42"), TEXT("{\"level\":5,\"xp\":1200}"),
[](TJetStreamResult<FJetStreamKVEntry> Result) {});
KV->Get(TEXT("PLAYER_STATS"), TEXT("player_42"),
[](TJetStreamResult<FJetStreamKVEntry> Result)
{
if (Result.IsSuccess())
{
UE_LOG(LogTemp, Log, TEXT("Level: %s, revision: %d"), *Result.Value.Value, Result.Value.Revision);
}
});
KV Delete¶
KV Delete
Bucket : "PLAYER_STATS"
Key : "player_42"
A known quirk of the current version. If a bucket's
Historyis greater than 1,KV GetafterKV Deletemay return the key's previous value instead of a "key not found" error — details in 11. Errors and Diagnostics. For buckets withHistory = 1(the default), behavior is correct.
KV List Keys¶
KV List Keys
Bucket : "PLAYER_STATS"
│
└─ bSuccess → Keys : ["player_1", "player_42", "player_99"]
Optimistic concurrency¶
KV Put With Revision¶
Write a value only if no one else has changed the key between your read and your write — the classic "read-modify-write" without races:
KV Get (Bucket: "PLAYER_STATS", Key: "player_42")
│
└─► Entry → Revision ───────────┐
│
[changed the data locally] │
▼
KV Put With Revision
Bucket : "PLAYER_STATS"
Key : "player_42"
Value : (updated data)
Expected Revision : (Revision from Get above)
│
└─ !bSuccess → someone else wrote first — read again and retry
KV->Get(TEXT("PLAYER_STATS"), TEXT("player_42"),
[this](TJetStreamResult<FJetStreamKVEntry> GetResult)
{
if (!GetResult.IsSuccess()) return;
const FString NewValue = ApplyChange(GetResult.Value.Value);
Nats->GetJetStream()->KeyValue()->PutWithRevision(
TEXT("PLAYER_STATS"), TEXT("player_42"), NewValue, GetResult.Value.Revision,
[](TJetStreamResult<FJetStreamKVEntry> PutResult)
{
if (!PutResult.IsSuccess())
{
// A version conflict — someone wrote between Get and Put. Retry the cycle.
}
});
});
KV Create¶
"Write only if the key doesn't already exist" — an atomic operation, useful for a distributed lock or "the first player gets the prize":
KV Create
Bucket : "LOCKS"
Key : "boss_fight_1"
Value : "owned_by_session_abc"
│
├─ bSuccess = true → the key was just created by you — you're the owner
└─ bSuccess = false → the key already existed — someone else owns it
KV->Create(TEXT("LOCKS"), TEXT("boss_fight_1"), SessionId,
[](TJetStreamResult<FJetStreamKVEntry> Result)
{
if (Result.IsSuccess())
{
StartBossFight(); // we're first
}
else
{
// Someone else already started the fight with this boss
}
});
Unlike "KV Get first, then KV Put if not found," KV Create is atomic: even if two
players call it at the exact same time, exactly one of them succeeds.
Watch: reacting to changes in real time¶
KV Watch / KV Stop Watch¶
KV Watch
Bucket : "PLAYER_STATS"
Key : "player_42"
│
Callback ──► (Entry) → update the UI whenever the value changes
FString WatchId = KV->Watch(TEXT("PLAYER_STATS"), TEXT("player_42"),
[](const FJetStreamKVEntry& Entry)
{
UpdateStatsWidget(Entry.Value);
});
// when no longer needed (e.g. the widget closes)
KV->StopWatch(WatchId);
You can leave Key empty to get changes for every key in the bucket — useful for
logging or full-state synchronization:
KV->Watch(TEXT("PLAYER_STATS"), FString(), // empty Key = every key
[](const FJetStreamKVEntry& Entry)
{
UE_LOG(LogTemp, Log, TEXT("Key %s changed: %s"), *Entry.Key, *Entry.Value);
});
Key also supports the same */> patterns as a Core subscription's subject
(1. Introduction to NATS) — for example
"player_*.status" for "any player's status."
Every
KV Watchcall always returns the key's current value on a change, fetched with a separate server request — not raw bytes from the internal message. This means a small delay between the physical change and the callback firing, usually unnoticeable.
History¶
KV History
Bucket : "PLAYER_STATS"
Key : "player_42"
│
└─ bSuccess → History : [Entry(rev=1), Entry(rev=2), Entry(rev=3)] ← oldest to newest
Only works if the bucket was created with History greater than 1 — otherwise only the
current entry is returned.
Binary values¶
The same "a Bytes variant alongside the text one" principle as elsewhere in the plugin (6. Binary Data):
| Text | Bytes |
|---|---|
KV Put |
KV Put Bytes |
KV Put With Revision |
KV Put Bytes With Revision |
KV Create |
KV Create Bytes |
TArray<uint8> IconBytes = ExportIconToBytes();
KV->PutBytes(TEXT("PLAYER_AVATARS"), TEXT("player_42"), IconBytes, [](auto) {});
A value read back (KV Get, KV Watch, KV History) always contains both
representations at once — Value (text) and ValueBytes (exact bytes), regardless of which
method wrote it:
KV Get
│
└─ bSuccess → Entry
├─ Value (text representation)
└─ Value Bytes (exact bytes — this is the field for binary data)
Example: hot-reloading configuration¶
A typical scenario where KV replaces a separate config file that would otherwise require a restart:
void AGameConfigManager::BeginPlay()
{
Super::BeginPlay();
UNatsKVStoreImpl* KV = Nats->GetJetStream()->KeyValue();
// Read the current value immediately at startup
KV->Get(TEXT("CONFIG"), TEXT("difficulty_multiplier"),
[this](TJetStreamResult<FJetStreamKVEntry> Result)
{
if (Result.IsSuccess())
{
ApplyDifficulty(FCString::Atof(*Result.Value.Value));
}
});
// And react to any change from then on, without restarting the game
KV->Watch(TEXT("CONFIG"), TEXT("difficulty_multiplier"),
[this](const FJetStreamKVEntry& Entry)
{
ApplyDifficulty(FCString::Atof(*Entry.Value));
});
}
A designer changes the value with nats kv put CONFIG difficulty_multiplier 1.5 (or their
own admin panel that calls KV Put under the hood) — every running server picks up the
change within a second, with no release and no restart.