Object Storage and Caching
Persist state in the object store and memoize with cache scopes.
Flows are stateless by default: each message flows through and is gone. This
guide adds state — object-read/object-write for values that persist between
runs, and cache-scope to memoize expensive work such as an LLM call.
Read and write objects
object-write persists a value under a key; object-read loads it back into a
variable.
samples/object-store.yaml
reads a key before and after writing it, so a single run shows both a miss and a
hit:
process:
# Cold read: the key is absent, so the default is folded in and
# profileExists is false.
- type: object-read
name: load-before
settings:
key: '"profile:" + body.id'
as: profile
default: '{"plan": "free"}'
existsVar: profileExists
- type: object-write
name: save-profile
settings:
key: '"profile:" + body.id'
value: '{"plan": body.plan}'
# Warm read: the key now exists, so the stored value wins over the
# default and profileExists is true.
- type: object-read
name: load-after
settings:
key: '"profile:" + body.id'
as: profile
default: '{"plan": "free"}'
existsVar: profileExistsRun it:
octo invoke --config samples/object-store.yaml --flow profile \
--data '{"id":"A-1","plan":"pro"}'
# -> logs "before write: A-1 exists=false plan=free" (miss -> default)
# then "after write: A-1 exists=true plan=pro" (hit)The settings that make object-read pleasant to use:
keyis a CEL expression on both blocks — build per-entity keys like'"profile:" + body.id'.asnames the variable the value lands in (vars.profile), leaving the body untouched.defaultsupplies a fallback expression on a miss, so the flow never has to branch on a null.existsVarwrites a boolean telling a genuine hit from a served default — branch on it only when the difference matters.
Memoize with cache-scope
cache-scope wraps a sub-flow and memoizes the body it produces under a CEL
key with a TTL. On a hit, the wrapped blocks never run.
samples/runtime-services.yaml
generates a "report" every 5 seconds, but the expensive part only executes once
per minute:
- type: cache-scope
name: cached-report
key: '"report"'
ttl: 1m
body:
process:
- type: log
name: compute
settings:
logger: out
message: '"cache MISS — generating report"'
- type: set-payload
settings:
value: '{"report": "generated", "event": eventID}'Note the shape: key, ttl, and body sit directly on the block, and body
holds a nested process list. Evict an entry with invalidate-cache and the
same key expression:
- name: reset
process:
- type: invalidate-cache
name: bust-report
settings:
key: '"report"'octo run --config samples/runtime-services.yaml
# "cache MISS — generating report" appears once, then ticks serve the cache.
# In another terminal, bust it and the next tick recomputes:
octo invoke --config samples/runtime-services.yaml --flow resetOnly the message body is cached. Variables the wrapped flow sets are not restored on a hit — keep anything a later block needs inside the body.
A few rules the runtime applies:
ttlis a Go duration (30s,5m,1h). Omit it for the default of 60 seconds;"0"means the entry never expires (until invalidated).- The evaluated key is hashed, so long or exotic keys are fine.
- Storing is best-effort: if another worker cached first, your result is still correct — just served fresh this once.
Caching an LLM call
Caching earns its keep when the wrapped work is slow and metered.
samples/ai-quote-cache.yaml
puts an ai-mapping call to Gemini behind a cache scope on an HTTP endpoint:
process:
# Memoize the slow/paid Gemini call under a constant key. The first
# request is a MISS; requests within the 5m TTL replay the cached quote
# with no model call.
- type: cache-scope
name: cached-quote
key: '"seneca:quote"'
ttl: 5m
body:
process:
- type: log
name: cache-miss
settings:
logger: log
message: '"cache MISS — calling Gemini"'
- type: ai-mapping
name: seneca
settings:
connector: gemini
prompt: >
Ignore the input entirely. Produce a single, genuine quotation
from the Stoic philosopher Seneca (Lucius Annaeus Seneca the
Younger). Return only the mapped object.
outputExample: |
{ "quote": "We suffer more often in imagination than in reality.", "author": "Seneca" }export GEMINI_API_KEY=...
octo run --config samples/ai-quote-cache.yaml
curl -s localhost:8080/quote # first call: MISS, ~seconds (model call)
curl -s localhost:8080/quote # within 5m: HIT, sub-millisecond, same quoteThe pattern generalizes: put the paid call inside the scope, key it by whatever
makes responses reusable (a constant, vars.query.topic, a user ID), and do
per-request derivation after the scope so it runs on hits too — the sample
appends a multi-transform for exactly that. For a full application of this
pattern, see An AI Web App End to End.
Where state lives
Both the object store and the cache ride on the runtime's KV service, so the same YAML runs everywhere:
- Standalone (
octo run,octo invoke): the store is in-process and per-run — great for development, gone when the process exits. - Platform: the store is durable and scoped to your deployment, shared by all replicas. Cache hits and object reads work across the whole cluster. See KV and Storage for scoping, quotas, and inspection.