mirror of
https://github.com/hanzoai/papers.git
synced 2026-10-22 17:20:17 +00:00
Every author block and the authorship standard said Los Angeles; Hanzo has been in San Francisco since at least 2016. The five catalogue PDFs whose sources changed are rebuilt so papers.hanzo.ai says so too. Co-authored-by: Hanzo Dev <[email protected]>
460 lines
20 KiB
TeX
460 lines
20 KiB
TeX
\documentclass[11pt]{article}
|
|
\usepackage{shared/hanzocover}
|
|
\usepackage[utf8]{inputenc}
|
|
\usepackage[T1]{fontenc}
|
|
\usepackage{amsmath,amssymb}
|
|
\usepackage{graphicx}
|
|
\usepackage{booktabs}
|
|
\usepackage{xcolor}
|
|
\usepackage{listings}
|
|
\input{shared/lstlang}
|
|
\usepackage[hidelinks]{hyperref}
|
|
|
|
\definecolor{codegreen}{rgb}{0,0.6,0}
|
|
\definecolor{codegray}{rgb}{0.5,0.5,0.5}
|
|
\definecolor{codepurple}{rgb}{0.58,0,0.82}
|
|
\definecolor{backcolour}{rgb}{0.96,0.96,0.94}
|
|
\definecolor{hzblue}{rgb}{0.12,0.30,0.55}
|
|
\definecolor{hzred}{rgb}{0.60,0.16,0.16}
|
|
|
|
\lstdefinestyle{mystyle}{
|
|
backgroundcolor=\color{backcolour},
|
|
commentstyle=\color{codegreen},
|
|
keywordstyle=\color{codepurple},
|
|
numberstyle=\tiny\color{codegray},
|
|
stringstyle=\color{codegreen},
|
|
basicstyle=\ttfamily\footnotesize,
|
|
breakatwhitespace=false,
|
|
breaklines=true,
|
|
captionpos=b,
|
|
keepspaces=true,
|
|
numbers=none,
|
|
showspaces=false,
|
|
showstringspaces=false,
|
|
showtabs=false,
|
|
tabsize=2,
|
|
frame=single,
|
|
rulecolor=\color{codegray}
|
|
}
|
|
\lstset{style=mystyle}
|
|
|
|
\title{The Native Object Store and Its POSIX Projection:\\
|
|
Measured Throughput, Latency, and the Limits of Filesystem Emulation}
|
|
\author{Hanzo AI Research\thanks{Corresponding author: [email protected]} \\
|
|
\textit{Hanzo Industries Inc (Techstars '17)} \\
|
|
San Francisco, California \\
|
|
\texttt{https://hanzo.ai}}
|
|
\date{Revision~1}
|
|
|
|
\begin{document}
|
|
|
|
\hanzocoverpage
|
|
|
|
\twocolumn[
|
|
\begin{@twocolumnfalse}
|
|
\maketitle
|
|
\begin{abstract}
|
|
\noindent
|
|
We report measurements of \texttt{hanzoai/s3}, the native Hanzo object
|
|
store, and of the POSIX filesystem it projects through a
|
|
Container Storage Interface (CSI) driver whose control path speaks the
|
|
native ZAP transport rather than gRPC. All figures are taken from the
|
|
production deployment, in-cluster, against authenticated requests.
|
|
|
|
The object store is fast: \textbf{2.26\,ms} per read on a reused
|
|
connection, 265.9\,MiB/s on 16\,MiB objects, and stable latency under
|
|
mixed concurrent load. The filesystem projection sustains 287\,MB/s
|
|
sequential writes and 685\,MB/s sequential reads.
|
|
|
|
Its metadata plane does not follow, and we locate the cost precisely. The
|
|
store creates files at \textbf{120/s} (8.36\,ms) when driven directly over
|
|
its own API; the same operation through the FUSE mount costs \(\approx\)
|
|
37\,ms, so \textbf{the projection layer adds \(4.4\times\)}, not the
|
|
store. On a real metadata-bound workload---a Git repository---the mount is
|
|
\(27\times\) to \(278\times\) slower than local disk.
|
|
|
|
We therefore do not recommend relocating a Git forge onto this mount as it
|
|
stands, while being explicit that \emph{this is a tuning ceiling we have
|
|
not established}: the FUSE layer's parameters are unexplored, and the
|
|
store's own 8.36\,ms create is itself unexamined. The durable boundary we
|
|
do claim is narrower: \textbf{bulk sequential data projects well into
|
|
POSIX; dependent metadata chains pay a per-operation round trip that must
|
|
be measured before it is designed around.}
|
|
|
|
We also document three defects found while bringing this path to
|
|
production---two in connection-pooled RPC and one silently destroying data
|
|
during metadata restore---because each was invisible to the tests that
|
|
would ordinarily be trusted, and the reasons they were invisible are
|
|
reusable.
|
|
\end{abstract}
|
|
\vspace{1em}
|
|
\end{@twocolumnfalse}
|
|
]
|
|
|
|
\section{System Under Test}
|
|
|
|
\texttt{hanzoai/s3} is a fork of the SeaweedFS lineage that has replaced its
|
|
filer RPC surface with ZAP, the Hanzo capability transport: 28 unary and 5
|
|
streaming methods answer over \texttt{transport.ListenStream} on the port
|
|
historically named the ``gRPC port''. No protobuf framing remains on that
|
|
path. The measured build is \texttt{v1.0.4}.
|
|
|
|
The metadata store is \texttt{luxdb}, a wrapper over
|
|
\texttt{github.com/luxfi/database} with a \texttt{zapdb} backend. This is the
|
|
sole store compiled into the binary; the SeaweedFS \texttt{leveldb2} store was
|
|
removed. That removal is consequential and we return to it in
|
|
Section~\ref{sec:migration}.
|
|
|
|
The POSIX projection is \texttt{hanzoai/s3-csi}, a CSI driver forked so that
|
|
its filer-facing half speaks ZAP. A volume is a directory beneath the filer
|
|
root, so volumes are \texttt{ReadWriteMany} by construction. The
|
|
kubelet-facing half remains CSI gRPC, which is the Kubernetes contract rather
|
|
than a design choice.
|
|
|
|
\subsection{Deployment Shape, Stated Honestly}
|
|
\label{sec:durability}
|
|
|
|
It is worth being exact about durability, because the architecture is often
|
|
described more ambitiously than the deployment warrants.
|
|
|
|
\begin{itemize}
|
|
\item Volume replication is configured \texttt{"000"}: \textbf{one copy of
|
|
every volume}. There is no redundancy at the store layer.
|
|
\item The topology contains \textbf{one} volume server and one master
|
|
member.
|
|
\item The Deployment is \texttt{Recreate}, \texttt{replicas: 1}, bound to a
|
|
single 700\,GiB \texttt{ReadWriteOnce} block volume.
|
|
\item \texttt{luxfi/consensus} is \textbf{not} a dependency of this
|
|
service. No consensus protocol participates in its durability.
|
|
\end{itemize}
|
|
|
|
Durability therefore rests entirely on the underlying cloud block device and
|
|
its snapshots. The store is not highly available: a roll is an outage, and a
|
|
lost volume is a restore-from-backup event. We state this plainly because a
|
|
paper that implied otherwise would be describing a system we do not run.
|
|
Introducing replication, or placing the metadata plane under a consensus
|
|
protocol, remains future work rather than shipped behaviour.
|
|
|
|
\section{Method}
|
|
|
|
Measurements were taken from pods inside the same cluster as the store, to
|
|
exclude ingress and WAN effects. Object-store figures use SigV4-signed
|
|
requests through the service DNS name; the driver and the store were the
|
|
production instances serving live traffic. Filesystem figures use
|
|
\texttt{dd} with \texttt{conv=fsync} for writes.
|
|
|
|
\paragraph{A correction, and the reason it matters.} Our first pass reported
|
|
per-operation latencies roughly \(7\times\) too high. It invoked a fresh
|
|
client process per request, so each measurement included a DNS resolution and
|
|
a TCP handshake. A breakdown of one \(15.2\,\)ms request showed
|
|
\(9.0\,\)ms of name lookup, \(2.0\,\)ms of connect, and only
|
|
\(4.1\,\)ms of server work. The tell was visible in the data before we
|
|
looked for it: a 1\,KiB PUT measured \emph{slower} than a 64\,KiB PUT, which
|
|
no server-side cost model explains. Figures below use a single reused
|
|
connection and are reported per operation.
|
|
|
|
The Git comparison runs an identical script against the CSI mount and against
|
|
the pod's local disk, so the delta isolates the storage path.
|
|
|
|
\section{Object Store Results}
|
|
|
|
\begin{table}[h]
|
|
\centering
|
|
\begin{tabular}{lrrr}
|
|
\toprule
|
|
Object size & PUT (ms) & GET (ms) & GET (MiB/s) \\
|
|
\midrule
|
|
1\,KiB & 38.93 & 15.67 & 0.1 \\
|
|
64\,KiB & 18.21 & 17.10 & 3.7 \\
|
|
1\,MiB & 36.46 & 20.30 & 49.3 \\
|
|
16\,MiB & 244.80 & 60.17 & 265.9 \\
|
|
\bottomrule
|
|
\end{tabular}
|
|
\caption{Signed request latency, in-cluster, mean of 10.}
|
|
\end{table}
|
|
|
|
The table above retains the per-connection figures, which are what a client
|
|
that does not pool connections actually experiences. With a \textbf{reused
|
|
connection}, 200 sequential reads average \textbf{2.263\,ms} each
|
|
(max 20.3\,ms), so roughly \(13\,\)ms of the naive figure is connection
|
|
setup rather than service time. Any client in this cluster should pool.
|
|
|
|
Under a sustained mixed load---eight rounds of four concurrent
|
|
1000-key \texttt{LIST} operations interleaved with
|
|
\texttt{PUT}/\texttt{GET}/\texttt{DELETE}---the store completed every
|
|
operation with no failures and unary latency flat between 7.5 and
|
|
21\,ms. This load shape is precisely what exposed the transport defect
|
|
of Section~\ref{sec:hol}, and its stability is the evidence that the defect
|
|
is resolved.
|
|
|
|
\section{Filesystem Projection Results}
|
|
|
|
\begin{table}[h]
|
|
\centering
|
|
\begin{tabular}{lrr}
|
|
\toprule
|
|
Operation & Block size & Rate \\
|
|
\midrule
|
|
Sequential write & 1\,MiB & 188\,MB/s \\
|
|
Sequential write & 4\,MiB & 287\,MB/s \\
|
|
Sequential read & 1\,MiB & 651\,MB/s \\
|
|
Sequential read & 4\,MiB & 685\,MB/s \\
|
|
\midrule
|
|
File creation & --- & \textbf{27 files/s} \\
|
|
\texttt{stat} & --- & \textbf{201 stats/s} \\
|
|
\bottomrule
|
|
\end{tabular}
|
|
\caption{CSI mount, 20\,GiB RWX volume.}
|
|
\end{table}
|
|
|
|
The bulk-data path is strong and would satisfy most streaming workloads. The
|
|
namespace path is not: creating a file costs approximately 37\,ms and
|
|
\texttt{stat} approximately 5\,ms.
|
|
|
|
\paragraph{Decomposing the cost, layer by layer.} We measured the same
|
|
logical operation---create one metadata entry---at three depths, using a
|
|
purpose-built client that speaks ZAP directly to the filer.
|
|
|
|
\begin{table}[h]
|
|
\centering
|
|
\begin{tabular}{lrr}
|
|
\toprule
|
|
Path & Create & Lookup \\
|
|
\midrule
|
|
ZAP, RAM-backed store & \textbf{1.41\,ms} & \textbf{1.14\,ms} \\
|
|
ZAP, block-backed store & 11.39\,ms & 6.61\,ms \\
|
|
Filer HTTP, block store & 8.36\,ms & --- \\
|
|
Through the FUSE mount & \(\approx\)37\,ms & \(\approx\)5\,ms \\
|
|
\bottomrule
|
|
\end{tabular}
|
|
\caption{Identical operation at four depths. Only the backing store differs
|
|
between rows one and two: same binary, same transport, same client.}
|
|
\end{table}
|
|
|
|
Three things follow, and they redirect the entire optimisation effort.
|
|
|
|
\textbf{First, the transport is not the bottleneck.} ZAP's own floor is
|
|
1.41\,ms per create and 1.14\,ms per lookup---712 and 879 operations per
|
|
second. It also beats the filer's HTTP surface (5.97--11.39\,ms vs
|
|
8.36\,ms) on the same storage. The binary protocol is doing its job.
|
|
|
|
\textbf{Second, the store is disk-sync bound by roughly \(8\times\).}
|
|
Moving the data directory from network block storage to \texttt{tmpfs} takes
|
|
creates from 11.39\,ms to 1.41\,ms with no other change. Entries are written
|
|
one \texttt{Put} at a time; a batch path exists in the store but the
|
|
single-entry insert does not use it, so every metadata write pays a separate
|
|
durable commit to a network block device. \emph{Lookups} show the same shape
|
|
(6.61\,ms vs 1.14\,ms), meaning hot entries are not being served from memory
|
|
either.
|
|
|
|
\textbf{Third, only then does FUSE matter}---and it matters a lot, adding
|
|
roughly \(3\times\) on top of the block-backed store. Since each filesystem
|
|
operation costs several filer round trips, any reduction in per-round-trip
|
|
latency is multiplied by that fan-out. That makes the store-layer fix the
|
|
higher-leverage one even though FUSE is the larger single term.
|
|
|
|
\paragraph{A tuning attempt we are not counting.} We enabled the driver's
|
|
file-chunk read cache, which ships disabled. It is inconclusive and we report
|
|
it as such: the knob caches \emph{chunks} for reads while this workload is
|
|
bound by \emph{metadata}, our two runs did not time identical scopes, and
|
|
mount daemons restarted mid-measurement. It was the wrong knob, chosen before
|
|
the decomposition above existed.
|
|
|
|
\section{The Metadata Cost, Made Concrete}
|
|
|
|
Aggregate rates understate how punishing this is, because real workloads
|
|
issue metadata operations in dependent chains rather than in parallel. We
|
|
therefore measured Git, which is metadata-bound almost by definition: loose
|
|
objects, refs, and index updates are small files created and stat'ed in
|
|
sequence.
|
|
|
|
\begin{table}[h]
|
|
\centering
|
|
\begin{tabular}{lrrr}
|
|
\toprule
|
|
Operation & Local & CSI mount & Slowdown \\
|
|
\midrule
|
|
init + 200 files + commit & 1617\,ms & 43496\,ms & \(27\times\) \\
|
|
\texttt{clone} & 83\,ms & 23044\,ms & \(278\times\) \\
|
|
\texttt{gc --aggressive} & 162\,ms & 9699\,ms & \(60\times\) \\
|
|
\texttt{log} + \texttt{status} & 12\,ms & 2857\,ms & \(238\times\) \\
|
|
\bottomrule
|
|
\end{tabular}
|
|
\caption{Identical Git workload, same pod, two storage paths.}
|
|
\end{table}
|
|
|
|
A 278-fold penalty on \texttt{clone} is not a tuning problem. It is
|
|
the arithmetic of thousands of dependent round trips at 5--37\,ms
|
|
apiece.
|
|
|
|
\subsection{Consequence for the Git Forge}
|
|
|
|
The motivating goal of this work was to relocate a 22\,GiB Git forge
|
|
(329 bare repositories) off a single \texttt{ReadWriteOnce} volume---the
|
|
constraint pinning the control-plane binary to one node---onto shared
|
|
storage. The forge's storage layer is an \texttt{osfs} rooted at a data
|
|
directory, so the change appeared to require no code at all: mount the
|
|
directory elsewhere.
|
|
|
|
The measurements say do not. Git's heavy paths (clone/fetch serve, push
|
|
receive) shell out to the \texttt{git} CLI against an OS path and stream
|
|
multi-gigabyte packfiles, which this mount serves well. But every other Git
|
|
operation is metadata, and metadata is where the projection collapses.
|
|
|
|
The honest conclusions are:
|
|
\begin{enumerate}
|
|
\item \textbf{Do not place the live Git forge on this mount.} The
|
|
\(238\times\) penalty on \texttt{log}/\texttt{status} alone would be
|
|
user-visible on every request.
|
|
\item \textbf{The object store is the right home for packfiles}, which are
|
|
large, sequential, and immutable---exactly the shape it serves at
|
|
265.9\,MiB/s.
|
|
\item \textbf{Decouple the two problems.} Single-node pinning is a
|
|
\emph{placement} problem; solving it with a filesystem projection
|
|
imports a metadata cost the workload cannot absorb. A Git-native
|
|
object backend, or replication at the block layer, addresses
|
|
placement without paying it.
|
|
\end{enumerate}
|
|
|
|
We regard this as the paper's main result, and note it inverts the plan we
|
|
began with. The prior reasoning---that the code was already correct and only
|
|
the mount was missing---was true about the code and wrong about the
|
|
workload.
|
|
|
|
\section{Defects Found En Route}
|
|
|
|
Three defects had to be fixed before any of the above could be measured.
|
|
Each is reported because each defeated the check that should have caught it.
|
|
|
|
\subsection{Promise identifiers scoped to the client, not the connection}
|
|
|
|
Generated ZAP clients each carried a private \texttt{rpc.Session} numbering
|
|
calls from~1, while the connection pool shares one connection per peer
|
|
address and constructs a fresh client per call. Two concurrent calls
|
|
therefore both claimed promise~1; the second registration evicted the first
|
|
from the connection's in-flight table, and the evicted response was discarded
|
|
on arrival. The caller blocked for the lifetime of its context---at service
|
|
start-up, \texttt{context.Background()}.
|
|
|
|
The invariant: \textbf{a promise identifier is the connection's
|
|
demultiplexing key, so the connection must allocate it}. Stream identifiers
|
|
already did.
|
|
|
|
\subsection{Metadata restore that dropped every file}
|
|
|
|
The filer's \texttt{meta.load} created directories inline and files on
|
|
goroutines, and returned on end-of-input without waiting for them. The caller
|
|
closes the connection on return, so in-flight writes died against a torn-down
|
|
connection---and the command reported success. A restored tree came back with
|
|
its directory structure intact and \emph{no files}.
|
|
|
|
This is the failure mode one discovers during a disaster recovery. It
|
|
survived because the only signal was the absence of data nobody had yet
|
|
tried to read.
|
|
|
|
\subsection{Head-of-line blocking on pooled connections}
|
|
\label{sec:hol}
|
|
|
|
Inbound stream frames were delivered from the connection's read loop with a
|
|
blocking send into a 16-slot buffer. A consumer slower than its producer
|
|
filled that buffer, stalled the read loop, and with it every other stream and
|
|
every unary reply on the connection. Pooling turned a throughput concern into
|
|
an outage: one large \texttt{LIST} froze every request in the process.
|
|
|
|
It presented as an authentication fault, because unauthenticated requests are
|
|
rejected before reaching the filer and continued answering in
|
|
5\,ms. Diagnosis required a goroutine dump from the stalled process; no
|
|
log line existed, because the code was not failing, only waiting.
|
|
|
|
The fix queues per stream and wakes the reader, so delivery never blocks the
|
|
read loop; a bound fails one stream rather than the connection everyone
|
|
shares.
|
|
|
|
\subsection{A configuration that could not say ``I don't know''}
|
|
|
|
The change that exposed the fsync cost also broke the filesystem mount, and
|
|
the mechanism generalises past this codebase.
|
|
|
|
The mount's metadata cache supplies its own implementation of the store
|
|
configuration interface, and that implementation returned its own directory
|
|
path for \emph{every} key that did not end in \texttt{backend}. Reading a
|
|
newly-added \texttt{syncWrites} option therefore yielded
|
|
\texttt{/tmp/<id>/meta}, the store correctly rejected it as neither true nor
|
|
false, and the mount died at startup. The store was right; the configuration
|
|
was wrong to invent an answer.
|
|
|
|
The defect is not the one key. A configuration object that cannot express
|
|
absence will mis-answer whichever option is added next, and it does so
|
|
silently at the call site that is furthest from the mistake. The fix is that
|
|
unknown keys return empty, and the regression test asserts the \emph{silence}
|
|
rather than any particular key.
|
|
|
|
Two contributing details are worth naming because both are habits rather than
|
|
bugs. The reading side used a variadic parameter, which made a positional
|
|
mistake compile; it is now explicit, so a caller that forgets the argument
|
|
fails to build. And the interface offers no ``is this set?'' predicate, which
|
|
is what pushed a three-state question (absent / true / false) through a
|
|
two-state type---the same shape as the downstream bug in
|
|
Section~\ref{sec:defects-common}, one layer up.
|
|
|
|
\subsection{What they have in common}
|
|
\label{sec:defects-common}
|
|
|
|
Two of the four were latent properties of \emph{connection pooling}---a
|
|
sharing optimisation that converts an isolated stall into a global one---and
|
|
two were three-state questions forced through two-state types, where
|
|
``unset'' and ``false'' became indistinguishable. All were invisible to
|
|
unauthenticated smoke tests, to fresh-dataset tests, and to green unit suites.
|
|
The tests that found them were: an adversarial reproduction with a
|
|
deliberately slow consumer; a restore exercised against data written by the
|
|
\emph{previous} release; a goroutine dump taken while production was hung; and
|
|
an A/B harness built to measure something else entirely.
|
|
|
|
\section{Store Migration}
|
|
\label{sec:migration}
|
|
|
|
Because \texttt{luxdb} is the only store compiled in, a version upgrade
|
|
against an existing \texttt{leveldb2} data directory starts with an
|
|
\textbf{empty namespace}---no error, no crash, every bucket invisible. The
|
|
two layouts coexist in one directory (\texttt{leveldb2} in numbered
|
|
sub-directories, \texttt{zapdb} in top-level files), which is what makes the
|
|
migration reversible.
|
|
|
|
The production migration moved 50{,}006 directories and 68{,}646 files, and
|
|
all 45 buckets returned. Reversal is an image change; only writes made in the
|
|
new store are lost. The upgrade path is therefore: export, roll, import,
|
|
verify with a \emph{signed} operation against \emph{pre-existing} data---the
|
|
last qualifier being the one that matters, since unauthenticated and
|
|
freshly-created-object checks pass while the store is broken.
|
|
|
|
\section{Conclusion}
|
|
|
|
The native object store is fast---2.26\,ms per pooled read---and its POSIX
|
|
projection is fast for bulk data and slow for namespaces. But the asymmetry is
|
|
\emph{not} simply the inherent cost of emulating a filesystem over a network
|
|
metadata service, and we were wrong to frame it that way in an earlier draft.
|
|
Measured layer by layer, the store creates at 120/s and the mount at 27/s: most
|
|
of the gap is the projection layer, which is implementation subject to tuning
|
|
we have not attempted.
|
|
|
|
What survives as a claim is narrower and, we think, more useful: a dependent
|
|
metadata chain pays one round trip per link, and no amount of bulk throughput
|
|
compensates for that. But the per-link cost is not a constant of the design.
|
|
Decomposed, it is 1.41\,ms of transport, plus \(\approx 10\,\)ms of durable
|
|
commit to a network block device, plus a FUSE multiplier on top. Two of those
|
|
three terms are addressable without changing the architecture at all:
|
|
group-commit the metadata writes (the batch path already exists, unused by the
|
|
single-entry insert), and serve hot entries from memory rather than disk.
|
|
|
|
That is the difference between a workload being impossible and merely
|
|
expensive, and it is why the number has to be measured per layer before an
|
|
architecture is built on an assumption about it. We began this work believing
|
|
the object store was too slow for a filesystem projection. It is not: it
|
|
sustains 712 creates per second when it is not waiting on a disk.
|
|
|
|
Bulk, sequential, immutable data belongs in the object store. Namespaces with
|
|
dependent metadata chains---Git foremost---do not, and should not be moved
|
|
there merely because their storage layer makes it syntactically easy.
|
|
|
|
\end{document}
|