Skip to main content

TTL and limits

Every key in INVENTORY so far lives until you overwrite or delete it. That's the right default for a stock count, but it's not the only thing a bucket holds. Some values should clean themselves up, such as a flash-sale price, a short-lived session token, or a "this SKU is locked for the next 30 minutes" flag.

This page adds two ways to bound the bucket by time. The first is the per-key TTL, a single key that expires on its own. The second is the set of bucket limits that bound the whole thing: total size, value size, history depth. When a key expires, the server leaves a marker so the warehouse dashboard learns the value is gone, the same way it learned about every other change.

You still have the INVENTORY bucket from the previous pages, with widget-blue decremented to 40 and the warehouse dashboard watching. You'll add a key with a TTL to it.

A per-key TTL expires a single value

A TTL (time-to-live) is how long a value stays in the bucket before the server removes it. A per-key TTL attaches that clock to one key. The key lives for its TTL, then disappears on its own, with no service having to remember to delete it.

Per-key TTL is set at create time, and only at create time. You hand the TTL to create alongside the value, and the key starts its countdown the moment it lands. This is a deliberate restriction: a TTL belongs to the value you're writing now, not to a value that might be put over it later.

One setup step comes first. Per-key TTLs ride on a bucket feature called limit markers, the same mechanism that leaves a trace when a value expires. A bucket needs limit markers enabled before any key in it can carry a TTL. INVENTORY was created without them, so you turn them on once, then create the timed key:

#!/bin/bash

# Give a single key its own lifetime with a per-key TTL.
#
# Per-key TTL rides on the bucket's Limit Markers, so the bucket must have
# them enabled first. INVENTORY was created without them, so turn them on
# once (the duration is how long the bucket keeps the expiry marker):
nats kv edit INVENTORY --marker-ttl 1h

# Now create flash-sale with a 30-minute TTL. The value expires on its own
# 30 minutes after this create; no service has to remember to clean it up.
# Note: TTL is accepted on CREATE only.
nats kv create INVENTORY flash-sale 99 --ttl 30m

# Expected: the key is written and counts down on its own.
#
# INVENTORY > flash-sale created
#
# Per-key TTL requires nats-server 2.11 or newer. On an older server the
# create is rejected because the bucket cannot enable Limit Markers.

# --- Per-key TTL is create-only: to change it, delete then create ---------
#
# There is no --ttl on put or update. Passing one does nothing; the key
# keeps its original TTL. To give flash-sale a different lifetime, remove
# it and create it again with the new TTL:
nats kv del INVENTORY flash-sale --force
nats kv create INVENTORY flash-sale 99 --ttl 10m

# The key now expires in 10 minutes instead of 30.

The flash-sale key now holds 99 and will remove itself 30 minutes later. Because the TTL is stored with the value, this happens without a cron job, a separate cleanup service, or a sweep.

Per-key TTL needs nats-server 2.11 or newer; that's the release that added limit markers. On an older server, enabling markers on the bucket is rejected, and the timed create fails with it. If you're on 2.11, the feature is available.

Bucket limits bound the whole bucket

Where a per-key TTL bounds one value, bucket limits bound the whole bucket. They keep a key-value store from growing without end, and you set them when you create the bucket. Three of them matter most:

  • Max bucket size: the total bytes the bucket may hold across every key and every kept revision. The bucket won't grow past it.
  • Max value size: the largest a single value may be. A put of something bigger is rejected. Key-value values are meant to be small; large values belong in the Object Store.
  • History depth: how many prior revisions each key keeps, which you already met on the previous page. It caps at 64, and it doubles as a per-key cap: it's the most messages any single key may hold.

Here those limits go on a throwaway CACHE bucket, so the numbers stand on their own and don't imply anything about INVENTORY:

#!/bin/bash

# Create a throwaway CACHE bucket to show the three bucket-level limits.
# We use CACHE, not the pinned INVENTORY bucket, on purpose: INVENTORY's
# TTLs are per-key, not bucket-wide, and we do not want to imply the whole
# INVENTORY bucket expires.
#
# --ttl 1h bucket-wide max age: every value older than 1h is
# removed, regardless of key. This is the bucket's
# MaxAge, not a per-key TTL.
# --max-bucket-size the bucket's total size cap, in bytes.
# --max-value-size the largest a single value may be, in bytes.
nats kv add CACHE \
--history 1 \
--ttl 1h \
--max-bucket-size 16MB \
--max-value-size 64KB

# Expected output ends with the configured limits, e.g.
#
# CACHE Key-Value Store
#
# Configuration:
# Bucket Name: CACHE
# History: 1
# TTL: 1h0m0s
# Max Bucket Size: 16 MiB
# Max Value Size: 64 KiB
# ...
#
# A bucket at its size cap discards the OLDEST value to make room for a new
# one (discard 'new' is not used here) — size the bucket for the working
# set, not the average, or live values fall off the back.

The --ttl on the bucket above is a different clock from the per-key TTL. A bucket TTL expires every value once it reaches that age; the per-key TTL expires one value. The bucket-wide form is the general per-message expiry mechanism applied to a whole bucket, and its details live with message TTL in JetStream. This chapter teaches the per-key form, which is the one unique to key-value.

The full set of bucket configuration options is documented in Reference → Create Stream, because a bucket is created as a stream and these limits map onto stream fields. Here you only need the three above.

Expiry leaves a marker for watchers

When a per-key TTL fires, the server leaves a marker instead of silently dropping the value: a small message that records the key is gone and why. You may have heard the marker called a tombstone elsewhere; the term in key-value is marker, and a TTL expiry leaves one with the reason MaxAge: the value aged out.

The marker matters because of the warehouse dashboard. A watcher receives the marker as a delete on the key, exactly as if someone had removed it by hand. Without the marker, a watcher that saw flash-sale appear would never learn it had vanished, and its view of the bucket would drift out of date. The marker is how live readers stay correct when a value expires on its own.

The animation walks the timeline: the inventory service creates flash-sale with a 30-minute TTL; the clock advances past it; the server places a marker on the key with reason MaxAge; and the warehouse dashboard receives that marker as a delete. The value expired without any service modifying it, and the watcher was still notified through the marker.

Pitfalls

Two mistakes are common the first time you add a TTL or a limit to a bucket. Both come from expecting a TTL or a limit to behave in a way it does not.

A per-key TTL is set at create, and only at create. There's no --ttl on put or on update; passing one does nothing, and the key keeps whatever TTL it had. The instinct to "extend the TTL" by writing the key again doesn't work: a put leaves the original clock running, and an update resets it to no TTL at all. To give a key a different TTL, you delete it and create it again with the new TTL. Use delete-then-create to change a TTL, not put or update.

The handling is in the create snippet above: after the timed create, it deletes flash-sale and creates it again with a shorter TTL, which is the only way to change one.

Limits reject, they do not make room for an oversized value. A put of a value larger than the bucket's max value size is rejected outright, and so is a put that would push the bucket past its max size; the server returns an error and leaves every existing value in place. The inventory service sees that error on the put, not later on a get, so a CACHE bucket sized for the average load starts refusing writes the moment a burst pushes it to the cap. Size the bucket for the working set you actually need to hold, not the average, so a busy minute doesn't bounce writes you needed to land, and cap max value size above the largest value you legitimately store.

Where you are

You now have:

  • An INVENTORY bucket with limit markers enabled and a flash-sale key that expires on its own after a per-key TTL.
  • A feel for the three bucket limits (max bucket size, max value size, and history depth) that bound the whole bucket.
  • A working model of the expiry marker: when a value ages out, the server leaves a marker with reason MaxAge, and the warehouse dashboard receives it as a delete.

The bucket is now complete. It holds keys with values, keeps history, supports safe concurrent writes, and has values that remove themselves on a TTL.

What's next

The next page shows the internals. It covers the KV_INVENTORY stream that's been under the bucket the whole time, the direct read path, and the difference between delete and purge.

Continue to Under the hood.

See also