#The Problem with Database-Backed Registries
When your platform manages 552 AI agents, 157 OSINT tool adapters, and hundreds of blog articles, every read matters. Querying PostgreSQL for metadata that rarely changes introduces unnecessary latency and database load. The solution is Erlang Term Storage (ETS), a built-in key-value store that lives in process memory and supports concurrent reads without locking.
#ETS Fundamentals for Registry Design
ETS tables are created by a process and destroyed when that process terminates. For registries, we use :named_table and :public access so any process can read without message passing:
:ets.new(:agent_registry, [
:set,
:named_table,
:public,
read_concurrency: true
])The read_concurrency: true option optimizes for workloads where reads vastly outnumber writes, which is exactly the pattern for registries.
#Compile-Time Loading Pattern
The platform populates ETS tables at application startup by scanning the filesystem. The agent registry, for example, walks the .aiad/agents/ directory and parses each .agent.md file:
defp load_agents do
Path.wildcard("#{root}/.aiad/agents/*.agent.md")
|> Enum.map(&parse_agent_file/1)
|> Enum.each(fn agent ->
:ets.insert(:agent_registry, {agent.slug, agent})
end)
endThis pattern means the registry is always consistent with the filesystem. Adding a new agent file automatically makes it discoverable at next startup.
#The Ensure-Table Pattern
A critical pattern for ETS registries is idempotent table creation. Multiple processes may attempt to access the registry before it is initialized:
def ensure_table do
case :ets.info(@table) do
:undefined ->
:ets.new(@table, [:set, :named_table, :public, read_concurrency: true])
load_data()
:ok
_ ->
:ok
end
endEvery public function calls ensure_table/0 before accessing data. This eliminates race conditions during startup without requiring a supervision tree dependency.
#Query Patterns
ETS supports pattern matching through :ets.match_object/2 and match specifications via :ets.select/2. For the OSINT tool registry, we filter by category:
def tools_by_category(category) do
ensure_table()
:ets.select(@table, [
{{:"$1", :"$2"},
[{:==, {:map_get, :category, :"$2"}, category}],
[:"$2"]}
])
endFor simpler lookups, :ets.lookup/2 returns results in constant time:
def get_by_slug(slug) do
ensure_table()
case :ets.lookup(@table, slug) do
[{^slug, article}] -> {:ok, article}
[] -> {:error, :not_found}
end
end#Performance Characteristics
ETS reads are measured in microseconds, not milliseconds. In benchmarks on the Prismatic Platform:
| Operation | Latency | Notes |
|---|---|---|
| Single lookup | ~0.5 us | Constant time |
| Full scan (552 agents) | ~45 us | Linear but fast |
| Pattern match (category filter) | ~12 us | Depends on selectivity |
| Write (single insert) | ~1 us | Concurrent reads unaffected |
Compare this to PostgreSQL queries that typically take 1-5ms even with connection pooling. For metadata registries, ETS provides a 1000x improvement.
#Table Lifecycle Management
ETS tables are owned by the process that creates them. If that process crashes, the table is destroyed. The platform uses two strategies to handle this:
Supervisor-owned tables: A dedicated GenServer creates and owns the table. The supervisor restarts it on crash, and
ensure_table/0repopulates data.Heir tables: Using the
:heiroption, table ownership transfers to another process on crash, preserving data across restarts.
For registries with cheap reload costs (filesystem scanning), strategy 1 is simpler and preferred. For registries with expensive initialization, strategy 2 avoids data loss.
#When Not to Use ETS
ETS is not a database replacement. It lacks transactions, persistence across restarts, and distributed replication. Use ETS for:
- Read-heavy metadata registries
- Caching layers with known invalidation patterns
- Counters and rate limiting
- Lookup tables loaded at startup
Use PostgreSQL (via Ecto) for data that must survive restarts, requires ACID transactions, or needs complex querying beyond key-value patterns.