mirror of
https://github.com/hanzoai/papers.git
synced 2026-10-22 17:20:17 +00:00
235 lines
7.2 KiB
TeX
235 lines
7.2 KiB
TeX
\section{System Design}
|
|
|
|
|
|
\subsection{Architecture Overview}
|
|
|
|
Verus comprises three layers:
|
|
|
|
\begin{enumerate}
|
|
\item \textbf{Ingestion layer}: Receives events, validates, and routes to processing.
|
|
\item \textbf{Processing layer}: Maintains real-time aggregations and writes to storage.
|
|
\item \textbf{Query layer}: Routes queries to optimal backend and merges results.
|
|
\end{enumerate}
|
|
|
|
\begin{figure}[h]
|
|
\centering
|
|
\fbox{\parbox{0.9\linewidth}{
|
|
\centering
|
|
\textit{[Architecture diagram]}\\[0.5em]
|
|
Events $\rightarrow$ Kafka $\rightarrow$ Stream Processor $\rightarrow$ Redis (aggregates)\\
|
|
\hspace{4em} $\searrow$ Columnar Store (raw events)\\[0.5em]
|
|
Query Router $\rightarrow$ Redis / Columnar / Both
|
|
}}
|
|
\caption{Verus architecture overview}
|
|
\label{fig:arch}
|
|
\end{figure}
|
|
|
|
\subsection{Ingestion Layer}
|
|
|
|
Events arrive via HTTP endpoints or Kafka consumers:
|
|
|
|
\begin{lstlisting}[language=Python,caption=Event ingestion]
|
|
class EventIngester:
|
|
def __init__(self, kafka_producer, validator):
|
|
self.producer = kafka_producer
|
|
self.validator = validator
|
|
|
|
async def ingest(self, event: CommerceEvent) -> None:
|
|
# Validate schema and business rules
|
|
self.validator.validate(event)
|
|
|
|
# Enrich with server-side data
|
|
event.server_timestamp = datetime.utcnow()
|
|
event.geo = geoip.lookup(event.ip_address)
|
|
|
|
# Partition by store for ordering guarantees
|
|
partition_key = event.store_id
|
|
|
|
await self.producer.send(
|
|
topic="commerce-events",
|
|
key=partition_key,
|
|
value=event.to_json()
|
|
)
|
|
\end{lstlisting}
|
|
|
|
Events partition by store ID, ensuring ordering within a store while enabling parallel processing across stores.
|
|
|
|
\subsection{Stream Processing Layer}
|
|
|
|
The stream processor maintains real-time aggregations:
|
|
|
|
\subsubsection{Aggregation Windows}
|
|
|
|
We maintain aggregations at multiple time granularities:
|
|
|
|
\begin{itemize}
|
|
\item \textbf{1-minute windows}: Operational dashboards.
|
|
\item \textbf{1-hour windows}: Intraday trends.
|
|
\item \textbf{1-day windows}: Daily summaries.
|
|
\end{itemize}
|
|
|
|
Windows use event time with allowed lateness of 5 minutes. Late events update existing windows; very late events are logged but excluded.
|
|
|
|
\subsubsection{Materialized Views}
|
|
|
|
Common query patterns are pre-computed as materialized views:
|
|
|
|
\begin{lstlisting}[language=SQL,caption=Materialized view definition]
|
|
CREATE MATERIALIZED VIEW revenue_by_source AS
|
|
SELECT
|
|
store_id,
|
|
TUMBLE(event_time, INTERVAL '1' MINUTE) AS window,
|
|
utm_source,
|
|
COUNT(*) AS order_count,
|
|
SUM(revenue) AS total_revenue,
|
|
COUNT(DISTINCT user_id) AS unique_customers
|
|
FROM events
|
|
WHERE event_type = 'purchase'
|
|
GROUP BY store_id, window, utm_source;
|
|
\end{lstlisting}
|
|
|
|
The stream processor maintains these views incrementally:
|
|
|
|
\begin{lstlisting}[language=Python,caption=Incremental aggregation]
|
|
class MaterializedViewProcessor:
|
|
def __init__(self, view_def, state_store):
|
|
self.view_def = view_def
|
|
self.state = state_store
|
|
|
|
def process(self, event):
|
|
if not self.view_def.filter(event):
|
|
return
|
|
|
|
# Compute aggregation key
|
|
key = self.view_def.group_key(event)
|
|
|
|
# Update state
|
|
current = self.state.get(key) or self.view_def.initial_state()
|
|
updated = self.view_def.aggregate(current, event)
|
|
self.state.put(key, updated)
|
|
|
|
# Emit to downstream
|
|
self.emit(key, updated)
|
|
\end{lstlisting}
|
|
|
|
\subsubsection{State Management}
|
|
|
|
Aggregation state resides in Redis for fast access:
|
|
|
|
\begin{lstlisting}[language=Python,caption=Redis state store]
|
|
class RedisStateStore:
|
|
def __init__(self, redis_client, ttl_seconds):
|
|
self.redis = redis_client
|
|
self.ttl = ttl_seconds
|
|
|
|
def get(self, key: str) -> Optional[Dict]:
|
|
data = self.redis.get(key)
|
|
return json.loads(data) if data else None
|
|
|
|
def put(self, key: str, value: Dict) -> None:
|
|
self.redis.setex(key, self.ttl, json.dumps(value))
|
|
|
|
def increment(self, key: str, field: str, delta: float) -> float:
|
|
return self.redis.hincrbyfloat(key, field, delta)
|
|
\end{lstlisting}
|
|
|
|
State keys encode view name, dimensions, and time window:
|
|
|
|
\begin{verbatim}
|
|
verus:revenue_by_source:store_123:google:2015-06-15T14:30
|
|
\end{verbatim}
|
|
|
|
\subsection{Columnar Storage Layer}
|
|
|
|
Raw events persist to columnar storage for ad-hoc queries:
|
|
|
|
\subsubsection{Storage Format}
|
|
|
|
We use Apache Parquet with the following optimizations:
|
|
|
|
\begin{itemize}
|
|
\item \textbf{Partitioning}: By store ID and date for partition pruning.
|
|
\item \textbf{Sorting}: By timestamp within partitions for range queries.
|
|
\item \textbf{Compression}: Snappy for balance of speed and size.
|
|
\item \textbf{Statistics}: Min/max per column for predicate pushdown.
|
|
\end{itemize}
|
|
|
|
\begin{lstlisting}[language=Python,caption=Parquet writer]
|
|
class ParquetWriter:
|
|
def __init__(self, base_path, partition_cols):
|
|
self.base_path = base_path
|
|
self.partition_cols = partition_cols
|
|
self.buffers = {} # partition -> event buffer
|
|
|
|
def write(self, event):
|
|
partition = self.compute_partition(event)
|
|
self.buffers.setdefault(partition, []).append(event)
|
|
|
|
if len(self.buffers[partition]) >= BATCH_SIZE:
|
|
self.flush(partition)
|
|
|
|
def flush(self, partition):
|
|
events = self.buffers.pop(partition)
|
|
path = f"{self.base_path}/{partition}/data_{uuid4()}.parquet"
|
|
|
|
table = pa.Table.from_pylist(events, schema=EVENT_SCHEMA)
|
|
pq.write_table(table, path, compression='snappy')
|
|
\end{lstlisting}
|
|
|
|
\subsubsection{Query Engine}
|
|
|
|
Ad-hoc queries execute on columnar storage via a distributed query engine (we use Presto):
|
|
|
|
\begin{lstlisting}[language=SQL,caption=Ad-hoc query example]
|
|
SELECT
|
|
product_category,
|
|
COUNT(*) AS views,
|
|
COUNT(DISTINCT session_id) AS sessions,
|
|
SUM(CASE WHEN event_type = 'purchase' THEN 1 ELSE 0 END) AS purchases
|
|
FROM events
|
|
WHERE store_id = 'store_123'
|
|
AND event_date = '2015-06-15'
|
|
AND device_type = 'mobile'
|
|
GROUP BY product_category
|
|
ORDER BY views DESC
|
|
LIMIT 20;
|
|
\end{lstlisting}
|
|
|
|
\subsection{Query Routing Layer}
|
|
|
|
The query router examines incoming queries and routes to the optimal backend:
|
|
|
|
\begin{lstlisting}[language=Python,caption=Query routing]
|
|
class QueryRouter:
|
|
def __init__(self, redis_client, presto_client, view_catalog):
|
|
self.redis = redis_client
|
|
self.presto = presto_client
|
|
self.views = view_catalog
|
|
|
|
def execute(self, query: str) -> ResultSet:
|
|
parsed = parse_sql(query)
|
|
|
|
# Check if query matches a materialized view
|
|
view_match = self.views.match(parsed)
|
|
|
|
if view_match and self.is_recent_enough(parsed, view_match):
|
|
# Serve from Redis
|
|
return self.query_redis(view_match, parsed)
|
|
elif view_match:
|
|
# Merge Redis (recent) + Presto (historical)
|
|
return self.merge_results(
|
|
self.query_redis(view_match, parsed),
|
|
self.query_presto(parsed)
|
|
)
|
|
else:
|
|
# Full scan on Presto
|
|
return self.query_presto(parsed)
|
|
|
|
def is_recent_enough(self, parsed, view):
|
|
# Check if query time range fits in Redis TTL
|
|
query_start = parsed.get_time_range()[0]
|
|
redis_start = datetime.utcnow() - timedelta(seconds=view.ttl)
|
|
return query_start >= redis_start
|
|
\end{lstlisting}
|
|
|