AI Infrastructure
One agent can live on a database connection. A fleet of agents needs a shared data layer: live ingestion, a consistent state layer, serving paths for pull, cache and push, memory that feeds back, and access control per agent. This guide lays out that architecture, compares the stacks teams build it from, and sets the whole thing up in SQL.
Last reviewed 2026-11-09. Covers RisingWave 2.6 and later.
Companion guide: Real-Time Context for AI Agents →
The Problem
Most companies already have three data systems: an operational database that applications write to, a warehouse that analysts read from, and some event streaming in between. Agents fit none of them well. The warehouse is hours behind and answers in seconds per scan. The operational database is current but holds raw rows, so an agent that needs a balance or a risk score has to aggregate on the production schema, and ten agents doing that is an incident. The event stream is current and cheap to read, but it is a log, not a lookup, and no agent framework reads Kafka directly.
Agents also bring two requirements that analytics never had. They write: every decision, hold, refund or reorder should be recorded where the next decision can see it. And they react: a fraud agent should wake when a pattern appears, not poll a table every ten seconds. Bolting these onto a warehouse means a second database for writes and a scheduler for polling, and the facts the agent reads come from three systems that agree only by coincidence.
The table below lists what agent workloads need against what the three usual systems give them. The right-hand column is what a streaming database provides, because it keeps aggregations current as events arrive, serves them by key, and lets the agent write into the same system it reads from.
| Requirement | Warehouse | Operational database | Streaming database |
|---|---|---|---|
| Freshness | Hours (scheduled loads) | Live, but raw and unaggregated | Seconds, computed once per event |
| Read latency | Seconds per scan | Milliseconds, if the query is simple | Milliseconds, indexed point lookup |
| Aggregations and joins | Yes, at query time | Dangerous on the production database | Precomputed and maintained |
| Semantic retrieval | Separate vector store | Separate vector store | Native vector(n) and HNSW |
| Write-back of memory | Not designed for it | Possible, but mixes agent writes with app data | Tables the agent owns, views over them |
| Consistency across facts | Per load | Per query, per table | One snapshot across all views |
| Push on change | No | Triggers and LISTEN/NOTIFY, per table | Subscriptions on any view |
Reference Architecture
The architecture has five responsibilities. In a conventional stack each is a separate product with its own deployment, schema language and failure modes. In the design on this page every layer is a SQL object in one streaming database: sources for ingestion, materialized views for state and retrieval, indexes, sinks and subscriptions for serving, and ordinary tables for memory. The agents themselves stay where they are, in Python, in an MCP host or in a workflow engine, and talk to the layer over the PostgreSQL protocol.
| Layer | Responsibility | RisingWave object | Conventional equivalent |
|---|---|---|---|
| Ingestion | Bring every fact in as an event: topics for behaviour, CDC for operational state. | CREATE SOURCE (Kafka, Pulsar, Kinesis), shared CDC source plus CREATE TABLE ... FROM | Kafka Connect, Debezium, Fivetran into a warehouse |
| State | Turn raw events into the facts agents reason over: balances, positions, rolling scores, candidate lists. | CREATE MATERIALIZED VIEW, maintained incrementally, consistent at every barrier | Flink or Spark jobs writing to a key-value store or warehouse tables |
| Retrieval | Make documents and long text searchable by meaning, kept current as they change. | openai_embedding in a view, vector(n) columns, HNSW index | Batch embedding pipeline plus Pinecone, Weaviate or pgvector |
| Serving | Deliver state to agents on their terms: on demand, from a cache, or pushed on change. | PostgreSQL protocol plus CREATE INDEX; CREATE SINK to Redis; CREATE SUBSCRIPTION and cursors; MCP server | A REST layer in front of several stores |
| Memory and archive | Keep what agents decided, feed it back into state, and retain the history. | Tables the agent INSERTs into, views over them, CREATE SINK to Iceberg | Application database tables plus a nightly export |
Two properties come from putting the layers in one engine and are hard to reproduce otherwise. First, consistency: RisingWave advances every view at the same one-second barrier, so an agent that reads account_state, account_risk_1h and account_recent_actions in one query sees the same instant in all three. Second, composability: a derived view such as fraud_candidates is defined over other views, and a subscription on it becomes an event feed for an agent without any extra job.
Two concerns cut across all five layers. Governance is handled with PostgreSQL-style users and GRANTs, so each agent sees only its views and writes only its own memory table. Observability uses the catalog: SHOW JOBS for backfill progress, rw_catalog for state size and parallelism, and Prometheus metrics from every node, including per-function counters for embedding calls.
Reference Implementation
The scenario is a payments and commerce company running three agents over the same streams. A support agent answers account questions and pulls context per turn. A fraud-review agent reacts to risk patterns and must be woken, not polled. A restock agent reads inventory positions at very high rates and needs cache speed. Payments and inventory events arrive on Kafka; accounts and merchants live in PostgreSQL. Everything below runs on a single-node RisingWave started with the Docker command from the companion guide, or on RisingWave Cloud.
Topics become append-only sources with watermarks so windows can close. Operational tables arrive through one shared CDC source, which means one replication slot on the production database regardless of how many tables or agents read from it. No agent will ever query these directly.
-- Behavioural and transactional events arrive on Kafka as append-only streams.
CREATE SOURCE payments (
payment_id BIGINT,
account_id BIGINT,
merchant_id BIGINT,
amount NUMERIC,
currency VARCHAR,
status VARCHAR, -- authorized | captured | declined | refunded
paid_at TIMESTAMPTZ,
WATERMARK FOR paid_at AS paid_at - INTERVAL '30 seconds'
) WITH (
connector = 'kafka',
topic = 'payments',
properties.bootstrap.server = 'broker:9092'
) FORMAT PLAIN ENCODE JSON;
CREATE SOURCE inventory_events (
sku VARCHAR,
warehouse VARCHAR,
delta INT, -- positive for receipts, negative for picks
on_hand INT, -- absolute count after the event
event_ts TIMESTAMPTZ,
WATERMARK FOR event_ts AS event_ts - INTERVAL '10 seconds'
) WITH (
connector = 'kafka',
topic = 'inventory',
properties.bootstrap.server = 'broker:9092'
) FORMAT PLAIN ENCODE JSON;
-- Operational tables come in as change streams through one shared CDC source.
-- Every agent that needs account or merchant state reads these, never the OLTP database.
CREATE SOURCE core_pg WITH (
connector = 'postgres-cdc',
hostname = 'pg', port = '5432',
username = 'rw', password = '...',
database.name = 'core', schema.name = 'public'
);
CREATE TABLE accounts (
account_id BIGINT PRIMARY KEY, email VARCHAR, country VARCHAR,
kyc_level VARCHAR, created_at TIMESTAMPTZ
) FROM core_pg TABLE 'public.accounts';
CREATE TABLE merchants (
merchant_id BIGINT PRIMARY KEY, name VARCHAR, category VARCHAR, risk_tier VARCHAR
) FROM core_pg TABLE 'public.merchants';Four views cover the three agents. account_state is the entity profile every agent starts from. account_risk_1h is a hopping window that advances every minute. inventory_position keeps the latest count per SKU and warehouse using the group Top-N pattern, which RisingWave recognises and maintains with bounded state. fraud_candidates is a view over views: it encodes the trigger condition in SQL, so the fraud team can change what counts as suspicious without touching agent code.
-- Entity state: one row per account, every agent's starting point.
CREATE MATERIALIZED VIEW account_state AS
SELECT a.account_id, a.country, a.kyc_level,
COUNT(p.payment_id) FILTER (WHERE p.status = 'captured') AS payments_total,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'captured'), 0) AS volume_total,
COUNT(p.payment_id) FILTER (WHERE p.status = 'declined') AS declines_total,
MAX(p.paid_at) AS last_payment_at
FROM accounts a
LEFT JOIN payments p ON p.account_id = a.account_id
GROUP BY a.account_id, a.country, a.kyc_level;
-- Rolling risk signal: declines and volume per account over the last hour, advancing every minute.
CREATE MATERIALIZED VIEW account_risk_1h AS
SELECT account_id, window_start, window_end,
COUNT(*) FILTER (WHERE status = 'declined') AS declines_1h,
COUNT(DISTINCT merchant_id) AS merchants_1h,
COALESCE(SUM(amount) FILTER (WHERE status = 'captured'), 0) AS volume_1h
FROM HOP(payments, paid_at, INTERVAL '1 minute', INTERVAL '1 hour')
GROUP BY account_id, window_start, window_end;
-- Live position: the latest inventory count per SKU and warehouse (group Top-N keeps one row per key).
CREATE MATERIALIZED VIEW inventory_position AS
SELECT sku, warehouse, on_hand, event_ts AS as_of
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY sku, warehouse ORDER BY event_ts DESC) AS rn
FROM inventory_events
) t
WHERE rn = 1;
-- Derived candidates: a view other agents and a push subscription can consume.
CREATE MATERIALIZED VIEW fraud_candidates AS
SELECT r.account_id, r.window_end AS flagged_at, r.declines_1h, r.merchants_1h, r.volume_1h,
s.kyc_level, s.country
FROM account_risk_1h r
JOIN account_state s ON s.account_id = r.account_id
WHERE r.declines_1h >= 3
OR (r.merchants_1h >= 5 AND s.kyc_level = 'basic');The support agent pulls: an index on account_state makes its per-turn read a point lookup, and to_jsonb returns the context ready for the prompt. The restock agent reads from Redis: a sink mirrors inventory_position into the cache and keeps it current, so the view definition remains the single source of truth. The fraud agent is pushed to: a subscription on fraud_candidates retains a day of changes, and the agent holds a cursor on it.
-- Path 1: pull. An index turns the account lookup into a millisecond point read.
CREATE INDEX account_state_by_id ON account_state (account_id);
-- The support agent's per-turn read (bind account_id as $1).
SELECT to_jsonb(s) AS account,
(SELECT to_jsonb(r) FROM account_risk_1h r
WHERE r.account_id = s.account_id
ORDER BY r.window_end DESC LIMIT 1) AS risk_last_hour
FROM account_state s
WHERE s.account_id = $1;
-- Path 2: cache. Mirror a hot view into Redis for an agent that reads thousands of keys per second.
CREATE SINK inventory_cache FROM inventory_position
WITH (
connector = 'redis',
primary_key = 'sku,warehouse',
redis.url = 'redis://cache:6379/'
) FORMAT PLAIN ENCODE JSON (force_append_only = 'true');
-- Path 3: push. A subscription records every change to fraud_candidates for a day.
CREATE SUBSCRIPTION fraud_feed FROM fraud_candidates WITH (retention = '1D');The push consumer is a loop around FETCH NEXT. The blocking form waits up to the timeout for the next change, so the process idles between events. Each row carries an op column and an rw_timestamp; persisting the timestamp lets the agent resume with a since clause after a restart instead of reprocessing or missing rows.
import json, psycopg
# The fraud-review agent does not poll. It holds a cursor on the subscription
# and wakes when a row is added to or removed from fraud_candidates.
rw = psycopg.connect("postgresql://fraud_agent@rw:4566/dev", autocommit=True)
cur = rw.cursor()
cur.execute("DECLARE fraud_cur SUBSCRIPTION CURSOR FOR fraud_feed") # changes from now on
while True:
cur.execute("FETCH NEXT FROM fraud_cur WITH (timeout = '5s')") # blocks up to 5 s
row = cur.fetchone()
if row is None:
continue # nothing changed
*fields, op, rw_ts = row
if op == "Insert" or op == "UpdateInsert":
case = dict(zip(["account_id", "flagged_at", "declines_1h", "merchants_1h",
"volume_1h", "kyc_level", "country"], fields))
decision = review_with_llm(case) # your agent loop
cur.execute(
"INSERT INTO agent_actions VALUES (%s, 'fraud_review', %s, %s, %s, now())",
(f"{case['account_id']}-{rw_ts}", case["account_id"],
decision["action"], json.dumps(decision)))
persist_checkpoint(rw_ts) # store rw_timestamp; resume later with 'since <unix_ms>'All three agents write to agent_actions. Two views turn that log into state: the last five actions per account, which the support agent includes in its context, and stale_holds, which checks memory against the live risk view and surfaces holds that should be lifted. An Iceberg sink keeps the full history in open tables for audit and for evaluating agents offline with Spark, Trino or DuckDB.
-- Memory: every agent writes what it did to one table. The key includes the agent's name.
CREATE TABLE agent_actions (
action_id VARCHAR PRIMARY KEY,
agent VARCHAR,
account_id BIGINT,
action VARCHAR, -- e.g. 'hold', 'clear', 'refund', 'reorder'
detail JSONB,
acted_at TIMESTAMPTZ
);
-- Feed memory back into state: the last five actions per account, visible to every agent.
CREATE MATERIALIZED VIEW account_recent_actions AS
SELECT account_id,
ARRAY_AGG(agent || ':' || action ORDER BY acted_at DESC) AS recent_actions
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY acted_at DESC) AS rn
FROM agent_actions
) t
WHERE rn <= 5
GROUP BY account_id;
-- Check memory against reality: holds placed on accounts that have since cleared the risk view.
CREATE MATERIALIZED VIEW stale_holds AS
SELECT a.account_id, a.acted_at AS held_at
FROM agent_actions a
LEFT JOIN fraud_candidates f ON f.account_id = a.account_id
WHERE a.action = 'hold' AND f.account_id IS NULL;
-- Archive: the full action history lands in Iceberg for audit and offline evaluation.
CREATE SINK agent_actions_archive FROM agent_actions
WITH (
connector = 'iceberg',
type = 'upsert',
primary_key = 'action_id',
warehouse.path = 's3://agent-lake/warehouse',
database.name = 'agents',
table.name = 'actions',
create_table_if_not_exists = 'true',
commit_checkpoint_interval = '60',
catalog.type = 'glue',
catalog.name = 'agent_catalog',
s3.region = 'us-east-1'
);Each agent gets a user that can read its views and write only the shared actions table. Sources and CDC tables are not granted to anyone, so raw PII in accounts never reaches a prompt unless a view exposes it deliberately. The same connection strings go into MCP server configurations, one per agent, so an MCP host inherits exactly the same permissions as the Python code would.
-- One user per agent. No agent touches a source, a CDC table or another agent's memory.
CREATE USER support_agent WITH PASSWORD '...';
CREATE USER fraud_agent WITH PASSWORD '...';
CREATE USER restock_agent WITH PASSWORD '...';
GRANT SELECT ON MATERIALIZED VIEW account_state, account_risk_1h, account_recent_actions
TO support_agent;
GRANT SELECT ON MATERIALIZED VIEW fraud_candidates, account_state, account_recent_actions
TO fraud_agent;
GRANT INSERT ON TABLE agent_actions TO fraud_agent;
GRANT SELECT ON MATERIALIZED VIEW inventory_position TO restock_agent;
GRANT INSERT ON TABLE agent_actions TO restock_agent;
-- Observe the layer itself.
SHOW JOBS; -- backfill progress of views being created
SELECT * FROM rw_catalog.rw_table_stats; -- key count and bytes of state per table and view
SELECT * FROM rw_catalog.rw_streaming_parallelism;{
"mcpServers": {
"risingwave-support": {
"command": "python",
"args": ["/opt/risingwave-mcp/src/main.py"],
"env": {
"MCP_TRANSPORT": "stdio",
"RISINGWAVE_CONNECTION_STR": "postgresql://support_agent:...@rw:4566/dev"
}
}
}
}Every statement above is documented RisingWave SQL: see the subscription, Redis sink, Iceberg sink and GRANT pages. The MCP server is at github.com/risingwavelabs/risingwave-mcp.
Alternatives
Each stack below is in production somewhere. The differences that matter for agents are how fresh the state is, how many systems sit between an event and the agent, and whether retrieval, write-back and push are first-class or bolted on.
| Stack | Fits when | Freshness | Systems to run | Gap for agents |
|---|---|---|---|---|
| Warehouse plus scheduled transforms (Snowflake dynamic tables, BigQuery, dbt) | Teams whose agents answer analytical questions and can tolerate minutes of lag | Minutes to hours | Warehouse, orchestrator, separate vector store | No push, no write-back path designed for agents, scan-oriented read latency |
| OLTP replica plus an API layer | A single agent reading a handful of rows from one application | Live | Database, read replica, API service, cache | Aggregations and joins run on the production schema; each new fact is a new endpoint |
| Kafka plus Flink plus Redis plus a vector database | Teams with a stream processing platform and the engineers to run it | Seconds | Four, plus the embedding job | No SQL at read time, no cross-object consistency, every context field is a Java or Python job |
| Feature platform (Tecton, Databricks Feature Store, Feast) | ML teams already serving features to models | Seconds to minutes | Feature platform, online store, separate retrieval | Fixed-schema numeric features; text retrieval, ad hoc joins and agent memory live elsewhere |
| PostgreSQL plus pgvector plus cron | Small corpora and low event rates | Per cron run | One, until load forces a split | Aggregations are recomputed on a schedule and compete with application traffic |
| Streaming database (RisingWave, this page) | Several agents sharing live operational state, retrieval and memory | About one second | One, plus optional Redis and Iceberg sinks | Vector indexes need append-only inputs; very large static corpora still favour a dedicated vector store |
Capacity and Cost
The cost model of a streaming data layer is inverted compared with a warehouse. A warehouse charges for queries, so cost grows with the number of agents and turns. A streaming database does the work when events arrive, so cost grows with event rate and view complexity and is nearly flat in the number of agents reading. That is the right shape for agents, whose read volume is unpredictable and whose event volume is not.
| Resource | Scales with | Does not scale with |
|---|---|---|
| Streaming compute | Event rate and view complexity (joins, windows, distinct counts) | Number of agents or queries |
| State storage | Join state and open windows; object storage, so cheap per GB | Query volume |
| Serving compute | Point lookups per second; can run on dedicated serving nodes | Event rate |
| Cache (Redis sink) | Hot keys read thousands of times per second | Views read a few times per minute |
| Embedding API spend | Document versions published | Agent questions asked |
Three practical rules follow. Keep join state bounded by preferring temporal joins against CDC tables over regular joins between two unbounded streams, and give every window a watermark so its state can be dropped. Put agent reads on dedicated serving compute nodes if the fleet grows, so a burst of agent traffic cannot delay stream processing. And watch rw_catalog.rw_table_stats: the state size of each view tells you which definition to revisit before it tells you in the bill.
Design Decisions
Start shared. The state layer is more valuable when every agent reads the same account_state and the same fraud_candidates, and consistency across views only holds inside one cluster. Separate by database user and schema, not by cluster. Split only when one team's stream volume or SLA is clearly different from the rest.
When a single view is read far more often than it changes, typically thousands of reads per second on a few keys, or when an agent needs sub-millisecond reads inside a tight loop. The sink keeps Redis current from the same view, so the definition lives in one place. Most agent reads are a few lookups per turn and do not need it.
Pull for conversational agents, whose turns are triggered by a user. Push, through a subscription cursor, for agents whose job is to react: fraud review, restock, escalation, incident response. The condition that defines the trigger is a view, so changing what wakes the agent is a SQL change, not a code change.
Create the new definition under a new name, let it backfill (watch SHOW JOBS), point the agent's query or GRANT at it, then drop the old view. Because views are cheap to create and backfill from retained state, running two versions side by side for a day is normal practice, and the agent's user can be granted both during the switch.
In the same cluster as the structured state, as a view with a vector column and an HNSW index, so a retrieval query can filter on live metadata in the same statement. Keep an external vector database only if it already holds a large corpus you do not want to move; in that case sink changed documents to it from RisingWave rather than running a second pipeline.
Keep the operational view short (the last five actions, the last 90 days) so prompts stay small, and archive everything to Iceberg through a sink. The archive is what you evaluate agents against later: join actions to outcomes offline, without touching the serving cluster.
When Not to Use This
Run the setup above on RisingWave Cloud or a single Docker container.
Start Free