This guide covers Lotus's caching system, which improves query performance by storing and reusing results from expensive database operations.
Lotus does not enable caching by default. To turn it on, configure a cache adapter under config :lotus — Lotus's supervisor starts automatically with your app and will boot the configured cache backend for you.
Overview
Lotus provides a flexible caching system with the following features:
- Pluggable adapters — support for different cache backends
- TTL-based expiration — automatic cache invalidation based on time-to-live
- Cache profiles — different caching strategies for different use cases
- Tag-based invalidation — selective cache clearing using tags
- Scope-aware keys — per-actor or per-tenant result isolation
- Pluggable key builders — replace the key scheme through a behaviour
- Multiple cache modes — fine-grained control over cache behavior
- Namespace support — cache isolation and organization
Quick Start
Basic Configuration
Lotus ships with built-in cache profiles (:results, :schema, :options) that work without any configuration. To enable caching, add a cache adapter to your Lotus configuration:
# config/config.exs
config :lotus,
storage_repo: MyApp.Repo,
data_sources: %{
"main" => MyApp.Repo
},
cache: %{
adapter: Lotus.Cache.ETS,
namespace: "myapp_lotus"
}The
:cachevalue must be a map. Configuration validation accepts a map ornil; a keyword list raises anArgumentErrorat boot.
Note: Even with minimal configuration, you get sensible caching defaults:
- Query results cached for 60 seconds (
:resultsprofile) - Schema information cached for 1 hour (
:schemaprofile) - Options/reference data cached for 5 minutes (
:optionsprofile)
OTP Application Setup
Lotus is an OTP application: as long as :lotus is in your mix.exs dependencies, its supervisor starts automatically with your app and boots the cache backend declared under config :lotus, :cache. No supervision-tree wiring is required on your end.
The Lotus.Cache.ETS GenServer is always started by the supervisor to ensure cache tables are available. If you configure a different cache adapter (e.g. Lotus.Cache.Cachex), it is started in addition to the ETS tables.
Note: If you accidentally include
Lotusas a child in your own supervision tree, the double-start is handled gracefully —Lotus.Supervisorreturns{:ok, pid}for an already-running instance.
Using Cache in Queries
Once configured and started, caching works automatically:
# First call - executes query and caches result
{:ok, result1} = Lotus.run_statement("SELECT COUNT(*) FROM users")
# Second call - returns cached result
{:ok, result2} = Lotus.run_statement("SELECT COUNT(*) FROM users")Configuration
Cache Adapter
Lotus ships with two cache adapters:
Lotus.Cache.ETS— local-only in-memory caching using ETS, implemented as a GenServer with automatic expiration cleanupLotus.Cache.Cachex— distributed caching using Cachex
ETS Adapter
The Lotus.Cache.ETS adapter provides in-memory caching using Erlang Term Storage (ETS):
config :lotus,
cache: %{
adapter: Lotus.Cache.ETS,
namespace: "myapp_lotus"
}Cachex Adapter
The Lotus.Cache.Cachex adapter uses the Cachex library for distributed setups.
First, add Cachex to your dependencies in mix.exs:
{:cachex, "~> 4.0"}Then configure Lotus to use Cachex in config/runtime.exs (or wherever your runtime config lives):
config :lotus,
cache: %{
adapter: Lotus.Cache.Cachex,
namespace: "myapp_lotus",
cachex_opts: [] # Optional Cachex options (see Cachex docs)
}Note: You MUST configure Cachex at runtime. This is because Cachex uses Records, which are not available in compile-time configuration.
cachex_opts accepts all options supported by Cachex. If not specified, the default Cachex configuration used is:
[router: router(module: Cachex.Router.Ring, options: [monitor: true])]Cache Profiles
Profiles let you configure different TTL strategies for different kinds of data. Lotus comes with three predefined profiles that are always available.
Predefined Profiles
:results— 60 seconds TTL — for query results and fast-changing data:schema— 1 hour TTL — for database schema information that changes rarely:options— 5 minutes TTL — for dropdown options and reference data
These profiles are always available, even without any cache configuration. You can override their settings or add custom profiles:
config :lotus,
cache: %{
adapter: Lotus.Cache.ETS,
profiles: %{
# Override built-in profiles
results: [ttl_ms: 30_000], # Override default 60s to 30s
schema: [ttl_ms: 7_200_000], # Override default 1h to 2h
options: [ttl_ms: 600_000], # Override default 5m to 10m
# Add custom profiles
reports: [ttl_ms: 1_800_000] # 30 minutes - business reports
},
default_profile: :results, # Used when no profile specified
default_ttl_ms: 60_000 # Fallback TTL
}Profile Fallback Behavior
When you don't configure cache profiles:
:resultsuses 60 seconds TTL:schemauses 1 hour TTL:optionsuses 5 minutes TTL
When you configure default_ttl_ms but don't define a :results profile:
:resultsuses yourdefault_ttl_msvalue:schemaand:optionskeep their built-in defaults
A profile name that is not configured and is not one of the three built-ins falls back to default_ttl_ms, and then to 60 seconds.
Namespace Support
The namespace is prefixed to every cache key, which isolates one app's entries from another sharing the same backend:
config :lotus,
cache: %{
adapter: Lotus.Cache.ETS,
namespace: "myapp_lotus" # Default: "lotus:v1"
}Cache Modes
Lotus provides three cache modes for different scenarios.
Default Mode (Automatic Caching)
When no cache mode is specified, Lotus automatically caches results:
# Uses cache if available, otherwise queries the source and caches the result
{:ok, result} = Lotus.run_statement("SELECT * FROM products")Bypass Mode
Skip the cache entirely — always query the source:
# Always hits the database, never reads from or writes to cache
{:ok, result} = Lotus.run_statement("SELECT * FROM products", [], cache: :bypass)Use cases:
- Real-time data requirements
- Testing scenarios
- One-off queries where cache isn't beneficial
Refresh Mode
Execute the query and overwrite the cache entry with the fresh result:
{:ok, result} = Lotus.run_statement("SELECT * FROM products", [], cache: :refresh)Use cases:
- Force cache refresh after data changes
- Scheduled cache warming
- Manual cache updates
The mode atoms also work inside the option list, so you can combine a mode with other cache options:
{:ok, result} = Lotus.run_statement("SELECT * FROM products", [],
cache: [:refresh, profile: :options])Cache Options
Profile Selection
Choose a specific cache profile for a query:
{:ok, result} = Lotus.run_statement("SELECT * FROM countries", [], cache: [profile: :options])TTL Override
Override the profile TTL for specific queries:
# Cache for exactly 2 minutes regardless of profile
{:ok, result} = Lotus.run_statement("SELECT * FROM users", [], cache: [ttl_ms: 120_000])Tag-Based Caching
Tag cache entries for selective invalidation:
# Tag this cache entry
{:ok, user} = Lotus.run_statement("SELECT * FROM users WHERE id = $1", [123],
cache: [tags: ["user:123", "user_data"]])
# Later, invalidate all entries with these tags
Lotus.Cache.invalidate_tags(["user:123"])Entry Size, Compression and Lock Timeout
Three settings apply to every cached entry. They are deployment policy, so they belong in config, and any call can override them for one query.
| Option | Default | What it does |
|---|---|---|
max_bytes | 5_000_000 | Skip writing an entry whose serialized size exceeds this. The query still returns its result; it is simply not cached, so one enormous result set cannot evict everything else. |
compress | true | Store the entry compressed. Trades CPU for memory. |
lock_timeout | 10_000 | On a miss, one caller computes the value while the others wait for it. This bounds that wait, after which a waiter computes the value itself. |
Set them for the whole application:
config :lotus,
cache: %{
adapter: Lotus.Cache.Cachex,
namespace: "lotus",
max_bytes: 2_000_000,
compress: true,
lock_timeout: 5_000
}Override for a single call:
# Don't cache this result if it serializes to more than 1 MB
{:ok, result} = Lotus.run_statement("SELECT * FROM events", [], cache: [max_bytes: 1_000_000])
# Skip compression for a result that doesn't compress well
{:ok, result} = Lotus.run_statement("SELECT * FROM blobs", [], cache: [compress: false])A key you leave unset in both places falls back to the adapter default in the table above. Lotus.Config.cache_entry_options/0 returns what the operator configured; Lotus.Cache.build_options/2 is what layers a per-call option over it.
Before v1.0.0 these three were declared in the cache config but never read from it.
max_bytesandcompressworked only as a per-call option andlock_timeoutcould not be set at all.
Combined Options
{:ok, result} = Lotus.run_statement("SELECT * FROM products", [],
cache: [
profile: :reports,
ttl_ms: 600_000, # Override profile TTL
tags: ["products", "inventory"]
])Cache Key Generation
Lotus builds two kinds of cache key.
Result keys (run_query/2, run_statement/3) hash:
- Statement body — the adapter-native payload (SQL text for Ecto sources, a JSON/DSL term for others)
- Bound values — variable bindings or positional parameters, including the pagination window
- Data source name — which source the statement targets
- Search path — the schema search path, when set
- Lotus version — so entries do not survive an upgrade
- Scope — when non-nil, its digest is appended to the key
Discovery keys (schema introspection) hash the operation kind, the source name, the kind-specific components (such as schema and table name), the Lotus version, and the scope digest when set.
Scope and cache correctness
The result cache key includes :scope. It does not include :context.
That distinction matters. :context is an opaque value for middleware and telemetry — an audit trail. :scope is the identity that Lotus treats as part of the cache identity and hands to the visibility resolver.
Warning
If middleware rewrites statements per actor, or your column policies mask
values per actor, and the actor is carried only in :context, then two
different actors running the same statement share one cache entry — and
the second actor is served the first actor's rows. Pass a :scope that
identifies the actor (or the security boundary: tenant, role) whenever
what a query returns depends on who is asking.
# Wrong when masking or a before_query rewrite depends on the actor:
Lotus.run_query(query, context: %{actor: current_user})
# Right — the actor is part of the cache identity and reaches the resolver:
Lotus.run_query(query,
context: %{request_id: request_id},
scope: %{tenant_id: current_user.tenant_id, role: current_user.role}
)Use the narrowest scope that captures the security boundary. A scope of %{user_id: id} gives every user their own cache entry, which is correct but has a low hit rate; %{tenant_id: id, role: role} is usually the right granularity when masking is decided by tenant and role.
The same rule applies to discovery: Lotus.list_tables/2, describe_table/3 and friends pass :scope to the visibility resolver and hash it into the discovery key, so a resolver that hides tables per tenant does not leak one tenant's table list into another's.
Custom Key Builder
The key scheme can be replaced by implementing the Lotus.Cache.KeyBuilder behaviour. This is useful when you need extra components in the key or a different hashing strategy.
defmodule MyApp.CustomKeyBuilder do
@behaviour Lotus.Cache.KeyBuilder
@impl true
def discovery_key(params, scope) do
# Add the deployment environment to discovery keys
env = Application.get_env(:my_app, :env, :prod)
Lotus.Cache.KeyBuilder.Default.discovery_key(
%{params | components: Tuple.append(params.components, env)},
scope
)
end
@impl true
def result_key(body, bound, opts, scope) do
Lotus.Cache.KeyBuilder.Default.result_key(body, bound, opts, scope)
end
endConfigure it in your cache settings:
config :lotus,
cache: %{
adapter: Lotus.Cache.ETS,
key_builder: MyApp.CustomKeyBuilder
}The behaviour defines two callbacks:
discovery_key/2— keys for schema introspection entries. Takes a map with:kind(e.g.:list_schemas,:list_tables),:source_name,:components(a tuple of kind-specific parts) and:version, plus the scope (ornil)result_key/4— keys for query result entries. Takes the statement body (term()— a SQL string for Ecto adapters, a JSON or AST payload for others), the bound values (map or list), an options keyword list carrying:data_source,:search_pathand:lotus_version, and the scope (ornil)
Lotus.Cache.KeyBuilder.scope_digest/1 is a public helper: it returns a 16-character hex digest of any term, and "" for nil. Use it if your implementation builds its own scope-specific keys or tags.
Lotus.Cache.KeyBuilder.scope_digest(nil)
# ""
Lotus.Cache.KeyBuilder.scope_digest(%{tenant_id: 42})
# a 16-character lowercase hex digest of the termWhen no key_builder is configured, Lotus.Cache.KeyBuilder.Default is used.
Warning
A custom key builder that ignores its scope argument removes the
per-scope isolation described above. If you delegate, delegate the scope
too.
Schema Function Caching
All Lotus schema introspection functions are cached automatically:
Lotus.list_schemas/2— lists schemas (namespaces) in the sourceLotus.list_tables/2— lists tables and viewsLotus.describe_table/3— column information for a tableLotus.get_table_stats/3— row counts and table statisticsLotus.list_relations/2— tables with schema information
Default Cache Behavior
# Schema metadata - uses the :schema profile (1 hour TTL)
{:ok, schemas} = Lotus.list_schemas("postgres")
{:ok, tables} = Lotus.list_tables("postgres")
{:ok, columns} = Lotus.describe_table("postgres", "users")
{:ok, relations} = Lotus.list_relations("postgres")
# Table statistics - uses the :results profile (60 second TTL)
{:ok, stats} = Lotus.get_table_stats("postgres", "users")Why different profiles?
- Schema metadata (tables, columns) changes rarely, so longer caching is safe
- Table statistics (row counts) change constantly, so a short TTL keeps them useful
Schema Cache Options
Schema functions support all cache modes and options:
# Bypass cache for fresh data
{:ok, tables} = Lotus.list_tables("postgres", cache: :bypass)
# Refresh cache with latest data
{:ok, columns} = Lotus.describe_table("postgres", "users", cache: :refresh)
# Use a different profile
{:ok, stats} = Lotus.get_table_stats("postgres", "users", cache: [profile: :options])
# Override TTL
{:ok, relations} = Lotus.list_relations("postgres", cache: [ttl_ms: 600_000])
# Add tags for invalidation
{:ok, columns} = Lotus.describe_table("postgres", "products",
cache: [tags: ["metadata"]])Schema Cache Invalidation
Discovery entries are tagged automatically, so you can clear exactly what changed:
# After schema changes (migrations, table creation, etc.)
Lotus.Cache.invalidate_tags(["source:postgres", "schema:list_tables"])
# After specific table changes
Lotus.Cache.invalidate_tags(["table:public.users"])Automatic tags on discovery entries:
"source:#{source_name}"— every entry for one data source"schema:#{kind}"— one discovery operation:schema:list_schemas,schema:list_tables,schema:describe_table,schema:get_table_stats,schema:list_relations,schema:resolve_table_namespace"table:#{schema}.#{table}"— table-specific entries (describe_table,get_table_stats,resolve_table_namespace); the schema part is omitted for schema-less sources"scope:<digest>"— added when a non-nil:scopeoption is passed
Renamed in v1.0
The source tag prefix was "repo:" before v1.0 and is now "source:".
Host code that invalidates by tag must be updated. Entries written by a
pre-v1 install are never found again after the upgrade; they expire on
their own and re-seed on the next read, so this is a cold cache, not a
correctness problem.
Per-Scope Cache Invalidation
When you pass :scope to a discovery or execution function, the entry is tagged with a scope digest. That lets you clear everything for one scope without flushing the cache:
# Populate cache for different scopes
{:ok, _} = Lotus.list_tables("postgres", scope: %{tenant_id: 1})
{:ok, _} = Lotus.list_tables("postgres", scope: %{tenant_id: 2})
# Invalidate only tenant 1's cached entries
:ok = Lotus.invalidate_scope(%{tenant_id: 1})
# Tenant 2's cache is untouched — this is still a cache hit
{:ok, _} = Lotus.list_tables("postgres", scope: %{tenant_id: 2})This is what you want when visibility rules change for one scope — a tenant's permissions are updated, a role gains a table — and stale entries for that scope must go. Lotus.invalidate_scope/1 (delegating to Lotus.Cache.invalidate_scope/1) clears both discovery and result entries carrying the scope tag.
invalidate_scope/1 accepts any term and returns :ok. Passing nil is a no-op, because there is no scope tag to invalidate.
The scope must match the one used when the entry was written: the digest is taken over the term itself, so %{tenant_id: 1} and %{tenant_id: "1"} are different scopes.
Working with run_query
Saved queries support all the same cache options:
# Automatic caching based on configuration
{:ok, result} = Lotus.run_query(query_id)
# Bypass cache
{:ok, result} = Lotus.run_query(query_id, cache: :bypass)
# Use a specific profile
{:ok, result} = Lotus.run_query(query_id, cache: [profile: :reports])
# Tag for invalidation
{:ok, result} = Lotus.run_query(query_id, cache: [tags: ["dashboard"]])
# Per-tenant results
{:ok, result} = Lotus.run_query(query_id, scope: %{tenant_id: 42})Query variables are part of the bound values, so the same saved query with different vars produces independent cache entries. So does each page of a windowed query.
Cache Management
Manual Cache Invalidation
# Invalidate specific entries
Lotus.Cache.invalidate_tags(["user:123"])
# Invalidate multiple tags
Lotus.Cache.invalidate_tags(["user_data", "reports", "dashboard"])
# Invalidate every cached entry for a scope
Lotus.invalidate_scope(%{tenant_id: 42})invalidate_tags/1 is a no-op when no cache adapter is configured, or when the configured adapter does not support tag invalidation.
Automatic Tagging
Lotus adds these tags to result entries:
"query:#{query_id}"— forrun_query/2calls on a saved query"source:#{source_name}"— the data source the statement ran against"scope:#{digest}"— when a non-nil:scopeis passed
Discovery entries carry the tags listed under Schema Cache Invalidation. Your own cache: [tags: [...]] are added on top of the automatic ones.
Performance Considerations
Cache Effectiveness
Cache activity is emitted as telemetry — [:lotus, :cache, :hit], [:lotus, :cache, :miss] and [:lotus, :cache, :put] — so you can measure hit ratio without instrumenting call sites. See the Telemetry Guide.
Memory Usage
ETS cache memory grows with cached data. Consider:
- Appropriate TTLs — don't cache data longer than it is useful
- Selective caching — use
:bypassfor large result sets that are not reused - Size limits — oversized entries are skipped rather than stored (
max_bytes, default 5 MB, set in config or per call) - Scope granularity — a per-user scope multiplies entries by your user count; prefer the narrowest boundary that is still correct
- Regular cleanup — expired ETS entries are swept by a janitor every 30 seconds
Cache Warming
Pre-populate the cache with commonly used queries:
# During application startup or from a scheduled job
{:ok, _} = Lotus.run_statement("SELECT * FROM lookup_tables", [], cache: :refresh)
{:ok, _} = Lotus.run_query(dashboard_query_id, cache: :refresh)Best Practices
Profile Strategy
config :lotus,
cache: %{
adapter: Lotus.Cache.ETS,
profiles: %{
# Built-in profiles (customize as needed)
results: [ttl_ms: 30_000], # Default: 60s - fast-changing data
options: [ttl_ms: 300_000], # Default: 5m - reference data
schema: [ttl_ms: 3_600_000], # Default: 1h - schema information
# Add custom profiles for specific use cases
reports: [ttl_ms: 1_800_000] # 30 minutes - business reports
}
}Default TTL Guidelines:
:results(60s) — query results, table statistics, transactional information:options(5m) — dropdown options, lookup tables, reference data:schema(1h) — database schema, table structure, metadata
Tagging Strategy
# User-specific data
cache: [tags: ["user:#{user_id}", "user_data"]]
# Feature-specific data
cache: [tags: ["dashboard", "reports"]]
# Entity-specific data
cache: [tags: ["product:#{product_id}", "inventory"]]When to Use Each Mode
- Default mode: most queries — let the cache do its job
:bypassmode: real-time data, large one-off queries, testing:refreshmode: after data updates, scheduled cache warming, manual refresh
Cache Invalidation
# After updating user data
Lotus.Cache.invalidate_tags(["user:#{user.id}"])
# After bulk data updates
Lotus.Cache.invalidate_tags(["products", "inventory"])
# After schema changes (migrations, DDL operations)
Lotus.Cache.invalidate_tags(["source:postgres", "schema:list_tables"])
# After table-specific changes
Lotus.Cache.invalidate_tags(["table:public.users"])
# After a tenant's permissions change — clear only that tenant's entries
Lotus.invalidate_scope(%{tenant_id: tenant.id})Troubleshooting
Cache Not Working
- Check configuration: a cache adapter must be set under
config :lotus, :cache, and the value must be a map - Check the
:lotusapp is running: Lotus's supervisor starts with the:lotusOTP app, so make sure it isn't excluded fromincluded_applicationsor otherwise prevented from starting - Verify identical calls: keys come from the exact statement body, bound values, source, search path and scope — a different scope is a different entry by design
- Check TTL: make sure the entry has not expired between calls
- Check entry size: a result larger than
max_bytes(5 MB default) is never stored, so it misses every time
Common Error: ** (ArgumentError) argument error or :noproc errors usually mean the :lotus application failed to boot. The ETS cache GenServer is always started by the supervisor, so cache tables should be available as long as :lotus is running.
Stale or Leaking Results
- Check
:scopevs:context: results that differ per actor must carry that actor in:scope. See Scope and cache correctness - Check a custom key builder: an implementation that drops the
scopeargument removes per-scope isolation - Check tag prefixes after upgrading: invalidation code written for v0.x used
"repo:", which now matches nothing
Memory Issues
- Review TTL settings: shorter TTLs mean less memory
- Review scope granularity: a per-user scope multiplies entry count
- Use selective caching: don't cache large result sets unnecessarily
Performance Issues
- Cache hit ratio: a low ratio may mean the scope is too narrow or the TTL too short
- TTL tuning: balance data freshness against cache effectiveness
- Query optimization: caching works best on top of already-reasonable queries