The transaction above already used two of the three: kv.put stored the order’s state, and timer() scheduled tomorrow’s reminder, which arrives as a message.
The third is a lock. Some work no message triggers: a nightly report, a cleanup, a singleton. Take a lock for it. A lock is a lease, and a slow holder can outlive it, so guard the transaction with it: the push commits only while the lock is still yours.
const lock = queen.lock('daily-report', { ttl: '30s' }) // a lease with a fencing token
if (await lock.acquire()) { // false: another worker has it
try {
await queen.transaction()
.guard(lock) // commits only while the lock is ours
.queue('reports').push([{ transactionId: 'report-2026-10-22', data: { day: '2026-10-22' } }])
.commit()
} finally {
await lock.release()
}
}lock = queen.lock("daily-report", ttl_seconds=30) # a lease with a fencing token
if await lock.acquire(): # False: another worker has it
try:
await (
queen.transaction()
.guard(lock) # commits only while the lock is ours
.queue("reports")
.push([{"transactionId": "report-2026-10-22", "data": {"day": "2026-10-22"}}])
.commit()
)
finally:
await lock.release()lock := client.Lock("daily-report", 30*time.Second) // a lease with a fencing token
held, err := lock.TryAcquire(ctx) // false: another worker has it
if err != nil {
log.Fatal(err)
}
if held {
_, err = client.Transaction().
Guard(lock). // commits only while the lock is ours
Queue("reports").
Push(queen.PushItem{TransactionID: "report-2026-10-22", Payload: map[string]interface{}{"day": "2026-10-22"}}).
Commit(ctx)
_, _ = lock.Release(ctx)
if err != nil {
log.Fatal(err)
}
}let lock = queen.lock("daily-report", Duration::from_secs(30)); // a lease with a fencing token
if lock.acquire().await? { // false: another worker has it
let committed = queen
.transaction()
.guard(&lock) // commits only while the lock is ours
.push_item(TxnPushItem {
queue: "reports".into(),
partition: None,
payload: json!({ "day": "2026-10-22" }),
transaction_id: Some("report-2026-10-22".into()),
trace_id: None,
})?
.commit()
.await;
lock.release().await?;
committed?;
}$lock = $queen->lock('daily-report', ['ttlSeconds' => 30]); // a lease with a fencing token
if ($lock->acquire()) { // false: another worker has it
try {
$queen->transaction()
->guard($lock) // commits only while the lock is ours
->queue('reports')
->push([['transactionId' => 'report-2026-10-22', 'data' => ['day' => '2026-10-22']]])
->commit();
} finally {
$lock->release();
}
}queen::Lock lock = client.lock("daily-report", 30); // a lease with a fencing token
if (lock.acquire()) { // false: another worker has it
try {
client.transaction()
.guard(lock) // commits only while the lock is ours
.queue("reports")
.push({{{"transactionId", "report-2026-10-22"},
{"data", {{"day", "2026-10-22"}}}}})
.commit();
} catch (...) {
lock.release(); // given back when the commit throws, too
throw;
}
lock.release();
}queen.semaphore(name, n)) allows n holders at once.KV
KV holds the state a step changes, in the same log as the messages. Keep there an entity’s current state, the markers that make a step safe to repeat, and counters. It also works on its own, outside a transaction. Every write names its lifetime, and the version a read returns makes the next write conditional:
// Every write names its lifetime: a ttl, or forever.
await queen.kv.put('carts', 'customer-42', { items: 3 }, { ttl: '7d' })
const cart = await queen.kv.get('carts', 'customer-42') // { found, value, version, ... }
// Write only if nobody wrote since we read it: the version is the condition.
const saved = await queen.kv.put('carts', 'customer-42', { items: 4 },
{ ttl: '7d', expect: cart.version })
console.log(saved.applied) // false if the version moved
// A counter with a ceiling: past max, the increment does not apply.
const hit = await queen.kv.incr('limits', 'api:customer-42', 1, { max: 100, ttl: '1m' })
if (!hit.applied) console.log('over the limit') // reason: 'limit'# Every write names its lifetime: a ttl, or forever.
await queen.kv.put("carts", "customer-42", {"items": 3}, ttl=timedelta(days=7))
cart = await queen.kv.get("carts", "customer-42") # {found, value, version, ...}
# Write only if nobody wrote since we read it: the version is the condition.
saved = await queen.kv.put("carts", "customer-42", {"items": 4},
ttl=timedelta(days=7), expect=cart["version"])
print(saved["applied"]) # False if the version moved
# A counter with a ceiling: past max, the increment does not apply.
hit = await queen.kv.incr("limits", "api:customer-42", delta=1, max=100,
ttl=timedelta(minutes=1))
if not hit["applied"]:
print("over the limit") # reason: 'limit'// Every write names its lifetime: a ttl, or forever.
_, err = client.KV().Put(ctx, "carts", "customer-42", map[string]int{"items": 3},
queen.TTL(7*24*time.Hour))
if err != nil {
log.Fatal(err)
}
cart, err := client.KV().Get(ctx, "carts", "customer-42") // Found, Value, Version, ...
if err != nil {
log.Fatal(err)
}
// Write only if nobody wrote since we read it: the version is the condition.
saved, err := client.KV().Put(ctx, "carts", "customer-42", map[string]int{"items": 4},
queen.TTL(7*24*time.Hour), queen.KVWriteOptions{Expect: queen.Expect(cart.Version)})
if err != nil {
log.Fatal(err)
}
fmt.Println(saved.Applied) // false if the version moved
// A counter with a ceiling: past max, the increment does not apply.
hit, err := client.KV().Incr(ctx, "limits", "api:customer-42", 1, queen.TTL(time.Minute),
queen.KVIncrOptions{Max: queen.Int64(100)})
if err != nil {
log.Fatal(err)
}
if !hit.Applied {
fmt.Println("over the limit") // reason: "limit"
}use queen_mq::Expiry;
// Every write names its lifetime: a ttl, or forever.
queen
.kv()
.put("carts", "customer-42", json!({ "items": 3 }), Expiry::seconds(7 * 86400))
.send()
.await?;
let cart = queen.kv().get("carts", "customer-42").await?; // found, value, version, ...
// Write only if nobody wrote since we read it: the version is the condition.
let saved = queen
.kv()
.put("carts", "customer-42", json!({ "items": 4 }), Expiry::seconds(7 * 86400))
.expect(cart.version.unwrap_or(0))
.send()
.await?;
println!("{}", saved.applied()); // false if the version moved
// A counter with a ceiling: past max, the increment does not apply.
let hit = queen
.kv()
.incr("limits", "api:customer-42", 1, Expiry::seconds(60))
.max(100)
.send()
.await?;
if !hit.applied() {
println!("over the limit"); // reason: KvReason::Limit
}// Every write names its lifetime: a ttl, or forever.
$queen->kv()->put('carts', 'customer-42', ['items' => 3], ['ttlSeconds' => 7 * 86400]);
$cart = $queen->kv()->get('carts', 'customer-42'); // [found, value, version, ...]
// Write only if nobody wrote since we read it: the version is the condition.
$saved = $queen->kv()->put('carts', 'customer-42', ['items' => 4],
['ttlSeconds' => 7 * 86400, 'expect' => $cart['version']]);
var_dump($saved['applied']); // false if the version moved
// A counter with a ceiling: past max, the increment does not apply.
$hit = $queen->kv()->incr('limits', 'api:customer-42', 1,
['max' => 100, 'ttlSeconds' => 60]);
if (!$hit['applied']) echo "over the limit\n"; // reason: 'limit'// Every write names its lifetime: a ttl, or forever.
client.kv("carts").put("customer-42", {{"items", 3}}, queen::KvTtl::seconds(7 * 86400));
json cart = client.kv("carts").get("customer-42"); // {found, value, version, ...}
// Write only if nobody wrote since we read it: the version is the condition.
queen::KvWriteOptions unchanged;
unchanged.expect = cart["version"].get<long long>();
json saved = client.kv("carts").put("customer-42", {{"items", 4}},
queen::KvTtl::seconds(7 * 86400), unchanged);
std::cout << saved["applied"] << "\n"; // false if the version moved
// A counter with a ceiling: past max, the increment does not apply.
queen::KvIncrOptions ceiling;
ceiling.max = 100;
json hit = client.kv("limits").incr("api:customer-42", 1,
queen::KvTtl::seconds(60), ceiling);
if (!hit["applied"].get<bool>()) std::cout << "over the limit\n"; // reason: "limit"expect, the second write finds the version moved and is refused, with the current value in the answer, so nobody overwrites what they never saw. A counter with max is a rate limit in one call.| Operation | What it does |
|---|---|
get, getMany |
Read one key, or several of one namespace: the value, its version and when it expires. |
getPrefix |
One page of keys under a prefix, with a cursor for the next page. Not inside a transaction. |
put, putIfAbsent |
Write a value. putIfAbsent writes only when the key does not exist: of several callers, one wins. |
delete |
Remove a key. |
incr |
Add to a counter with no read first. With max (or min), an increment past the bound does not apply. |
check |
Assert a key’s version and write nothing, to make a call depend on a key it does not change. |
Every write names its lifetime: a TTL, or forever. Every write gives the key a new, higher version, and expect makes a write depend on it. A missed condition answers applied: false with the key’s current value, so read applied on every write.
Timers
A timer is a message scheduled for later. It is named by its queue and its key, and scheduling the same key again replaces it, so moving a reminder is the same call. When it fires, the broker pushes the message into its queue, through the log like any push, and your consumers read it like any message. Delayed messages shows one scheduled, moved and cancelled.
- Give a timer the entity’s partition, so it arrives in order with the entity’s other events.
- A fire is never early, and a little late: the leader checks every 50 ms, then the message waits for a consumer.
- A cancel that answers
absentmay mean the timer already fired. A handler that compensates (a payment timeout) checks the entity’s state before it acts. - For the same delay on every message of a queue, use the queue’s
delayedProcessinginstead.
Locks and semaphores
A lock is a lease: one holder at a time, for a lifetime the holder declares and renews. A semaphore is the same with n holders. A lock somebody else holds is not an error: the answer says acquired: false and who holds it. One worker at a time runs a nightly job with one.
- A lock expires without telling its holder. A paused or slow process can carry on after another one took the lock. Fence the work:
.guard(lock)on the transaction inside Queen, or the lock’s token outside it (a resource that refuses a token lower than the highest it has seen). - The token changes at every renew. Use the token of the last answer; the SDK handles do.
- The JavaScript, Python, Go and Rust handles renew in the background, every third of the lifetime, and tell you when the lock is lost.
See also
- Transactions: KV writes and timers that commit with the step.
- HTTP API: the KV, timers and locks endpoints.
- Limits: key sizes, value sizes and how many operations a call takes.