This is an automated email from the ASF dual-hosted git repository.

polyzos pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss-blog.git


The following commit(s) were added to refs/heads/main by this push:
     new 8be51ce  [blog] Fluss storage hierarchy (#8)
8be51ce is described below

commit 8be51ce8af66811dbf5f1a95d7e0f73025ee0897
Author: Giannis Polyzos <[email protected]>
AuthorDate: Mon Jun 1 22:19:25 2026 +0300

    [blog] Fluss storage hierarchy (#8)
    
    * [blog] initial commit for fluss storage hierarchy
    
    * complete blog post
    
    * update text
    
    * update rocksdb image
    
    * apply feedback
    
    * add ticket tracking for open issues
---
 blog/2026-06-02-fluss-storage-hierarchy.md         | 174 +++++++++++++++++++++
 blog/assets/storage_hierarchy/banner.png           | Bin 0 -> 2410983 bytes
 blog/assets/storage_hierarchy/fig1.png             | Bin 0 -> 273984 bytes
 blog/assets/storage_hierarchy/fig2.png             | Bin 0 -> 188709 bytes
 blog/assets/storage_hierarchy/fig3.png             | Bin 0 -> 203736 bytes
 blog/assets/storage_hierarchy/fig4.png             | Bin 0 -> 220566 bytes
 blog/assets/storage_hierarchy/fig5.png             | Bin 0 -> 241344 bytes
 blog/assets/storage_hierarchy/fig6.png             | Bin 0 -> 209087 bytes
 blog/assets/storage_hierarchy/fig7.png             | Bin 0 -> 148192 bytes
 blog/assets/storage_hierarchy/fig8.png             | Bin 0 -> 201604 bytes
 blog/assets/storage_hierarchy/fig9.png             | Bin 0 -> 223357 bytes
 src/theme/BlogPostItem/Header/Title/index.tsx      |  27 ++++
 .../BlogPostItem/Header/Title/styles.module.css    |  22 +++
 13 files changed, 223 insertions(+)

diff --git a/blog/2026-06-02-fluss-storage-hierarchy.md 
b/blog/2026-06-02-fluss-storage-hierarchy.md
new file mode 100644
index 0000000..484189e
--- /dev/null
+++ b/blog/2026-06-02-fluss-storage-hierarchy.md
@@ -0,0 +1,174 @@
+---
+slug: fluss-storage-hierarchy
+title: "The Storage Hierarchy: Hot, Remote, and Lake"
+date: 2026-06-02
+authors: [giannis]
+image: ./assets/storage_hierarchy/banner.png
+---
+
+![Banner](assets/storage_hierarchy/banner.png)
+
+**Apache Fluss stores data in three places:** local disk on the tablet server, 
remote object storage like S3, and the lakehouse. Which place holds which data 
at any given moment, and what is responsible for moving it between them, is the 
foundation everything else rests on. Your capacity plan depends on it. Your 
latency targets depend on it. Your disaster-recovery story depends on it. So 
does your ability to predict, in advance, that a particular configuration 
change is going to fill up  [...]
+
+<!-- truncate -->
+
+This post walks through that layering. We'll cover what each tier holds, the 
two background tasks that move data between them, what changes for primary-key 
tables, and how recovery actually works when a tablet server loses its disk. 
+
+By the end you should be able to look at a Fluss deployment and say, for any 
given record, where it lives right now and where it will live in an hour.
+
+## The Three-Tier Storage Hierarchy
+
+**Tier 1 is local disk on the tablet server.** It holds the hot data: recent 
log segments, the full live RocksDB state for every primary-key table, and a 
staging view of the most recent KV snapshots (hard links to live SST files 
while uploads are in flight). Reads from this tier are in milliseconds.
+
+**Tier 2 is remote object storage** (S3, GCS, or similar), used for two 
distinct purposes that share the same `remote.data.dir` filesystem. 
+* **First:** older log segments uploaded by the `remote-log tiering task` in 
Fluss's native binary format, which extends local retention without growing 
local disk. 
+* **Second:** durable KV snapshots for every primary-key table, uploaded 
periodically so that a tablet server can recover after disk loss.
+
+Remote log storage is **enabled by default**. It's controlled by 
`remote.log.task-interval-duration` (default `1min`), and is only disabled when 
that value is set to `0`. KV snapshot upload is independent of remote-log 
tiering and is governed by `kv.snapshot.interval` (default `10min`). 
+
+**Tier 3 is the lakehouse.** Paimon, Iceberg, or Lance are holding data in 
analytical file formats queryable by any engine. Hudi support is in active 
development under [FIP-24](https://github.com/apache/fluss/issues/3254). Reads 
from the lakehouse cost seconds.
+
+![](assets/storage_hierarchy/fig1.png)
+
+### A Note On Single-Copy Storage
+
+**A Fluss table is a single logical abstraction across all three tiers, each 
holding data at a different freshness level.** Local disk has the hot, most 
recent data; remote object storage extends retention beyond what fits locally; 
the lakehouse holds the analytical projection. Across those tiers, each record 
has one home at a time and no tier permanently holds a second copy of what 
another tier already owns.
+
+**There is one temporary exception**: when lakehouse tiering is enabled, a 
remote log segment is only deleted once **both** its TTL has expired **and** 
the lakehouse has ingested it. That's a safety net against lakehouse lag, and 
it creates a bounded transition window where the same data exists in both Tier 
2 and Tier 3, governed by `table.log.ttl` (default 7 days). Shorten the TTL if 
minimizing that overlap matters more to you than a long lakehouse catch-up 
window.
+
+Once the lakehouse catches up, the remote copy is removed and the overlap 
closes.
+
+## Log Tables on Local Disk
+
+A log table on disk is a sequence of log segments. Each segment is a `.log` 
file holding raw records alongside a small set of companion files: a `.index` 
offset index and a `.timeindex` time index for fast seek, plus per-segment 
writer-state snapshots used for idempotent producers. The active segment is 
open for appends; every other segment is immutable and named by its starting 
offset.
+
+What happens to those sealed segments is governed by two retention controls.
+* `table.log.ttl` (default 7 days) is the global retention contract for the 
log. It defines the maximum age of any log data in the table, regardless of 
which tier it currently lives on.
+* `table.log.tiered.local-segments` (default 2) is a count-based floor for 
local disk, only meaningful when remote-log tiering is on. The remote-log task 
keeps at least this many recent segments on local disk after upload, so 
consumers reading near the head don't pay an S3 round-trip for the freshest 
data.
+
+A segment becomes a candidate for upload **the moment it is sealed and its 
records are below the high watermark** (i.e., committed/acked). Sealing happens 
when the active segment hits its size threshold, fills the offset or time 
index, or can no longer encode records as relative offsets, and then rolls 
over: Fluss closes the current active segment (which becomes immutable) and 
opens a new one for subsequent writes. The freshly-closed segment is now 
something the remote-log task can pick  [...]
+
+### What remote-log Tiering Actually Does
+
+By default, remote-log tiering is on 
(`remote.log.task-interval-duration=1min`) and TTL is 7 days. The remote-log 
task does three things on each pass:
+
+1. Uploads newly-sealed segments to S3.
+2. Advances the local log's `remoteLogEndOffset`, which causes the local log 
to trim every sealed segment now in S3, keeping at least 
`table.log.tiered.local-segments` recent ones.
+3. Deletes S3 segments past TTL.
+
+![](assets/storage_hierarchy/fig2.png)
+
+Local disk is bounded primarily by the count-based floor, usually a handful of 
recent segments. The TTL value applies most visibly on the S3 side, because S3 
is where data lives the longest.
+
+### Disabling Remote Tiering
+
+Setting `remote.log.task-interval-duration=0` opts out of Tier 2 entirely, but 
this comes with an additional consequence: it also disables the scheduled 
cleanup task itself, because that task is what runs both the upload and the 
segment-deletion paths. With the task disabled, **nothing trims local 
segments**. **There is no automatic fallback to the lakehouse on the write 
path.**
+
+The end result is **unbounded local-disk growth**. Eventually the tablet 
server runs out of disk and write batches start failing with storage exceptions.
+
+![](assets/storage_hierarchy/fig3.png)
+
+## Remote Tiering and Lakehouse Tiering Are Different Features
+
+These two features are frequently conflated, which is fair because the names 
suggest a relationship, but they solve different problems and produce different 
output.
+
+**Remote tiering** is about disk economics on the tablet server. It copies raw 
log segments in Fluss's native binary format to S3, extending local retention 
without growing local disk. The tablet server can then read from S3 when a 
consumer requests an offset that has been trimmed locally. It's managed 
entirely server-side, by a background task. As a side effect, it's also **the 
only mechanism that trims local log segments**.
+
+**Lakehouse tiering** is about analytical access. It converts Fluss data into 
lakehouse-native formats, like ORC, Parquet, Lance and writes them to the 
lakehouse via an external Flink job (the Tiering Service). The output is 
queryable by Spark, Trino, and Flink independently of Fluss.
+
+These are complementary layers. You can run any combination of them. When both 
are enabled, the lakehouse confirmation acts as an additional safety gate on 
top of TTL-based S3 deletion: a remote log segment is not expired until TTL has 
passed and the lake has confirmed it.
+
+
+## Primary-Key Tables 
+A log table has one thing on disk: the log. A primary-key table has three. 
They serve different roles, they live in different places, and they fail in 
different ways. **Operating primary-key tables without seeing them as three 
distinct structures is one of the faster routes to a confusing production 
incident.**
+
+![](assets/storage_hierarchy/fig4.png)
+
+### Structure 1: Live RocksDB Store
+
+This is the current state of the table. One entry per primary key, always up 
to date, sitting on the tablet server's local disk inside a RocksDB instance. 
Every point lookup reads from here. Every upsert merges into here. The live 
store is created when the tablet opens and deleted only when the table is 
dropped.
+
+Nothing moves the live store. There is no setting that puts it on S3, in the 
lakehouse, or anywhere else. **RocksDB on local disk is where the work happens, 
and that's the only place it can happen.**
+
+The role to understand here is "what serves traffic," not "what is durably 
stored." **The live store is what serves traffic. What survives a disk loss is 
the snapshot in remote storage**, which is Structure 2. Two roles, two copies, 
related data. You need local disk for the full merged state of every bucket the 
tablet server is responsible for; **the lakehouse cannot stand in for this**, 
and the tablet server doesn't read PK state from the lake under any 
circumstance.
+
+### Structure 2: KV Snapshots
+
+Every ten minutes by default (`kv.snapshot.interval=10min`), the tablet server 
takes a snapshot of the live RocksDB and writes it to remote storage. **This is 
the system's only durable record of the table's merged state at a point in 
time.** If the tablet server's local disk evaporates, recovery begins from the 
most recent snapshot and then replays the changelog forward from that 
snapshot's offset to reach the present. The two-stage process described in the 
Recovery section below.
+
+**Step one** happens locally and completes immediately. The tablet server 
hard-links the current RocksDB SST files into a staging directory. **No bytes 
are copied, just new pointers to existing files.** This is what lets a snapshot 
start instantly regardless of how large the table is, because nothing is being 
duplicated on disk.
+
+**Step two** is the one that actually moves data. Those files, plus a bit of 
metadata, get uploaded to remote storage. The remote copy is the durable one; 
the local staging directory is there so the uploader sees a frozen, consistent 
view of the files while RocksDB keeps writing and compacting underneath it. The 
snapshot is considered durable once the upload finishes.
+
+![](assets/storage_hierarchy/fig5.png)
+
+Fluss keeps the last two snapshots in remote storage by default. When a new 
snapshot supersedes an old one, the old one is deleted, with one guard: if 
anything (most commonly a long-running lakehouse tiering job on its first 
round) is still reading the older snapshot, a lease prevents the cleanup from 
removing it underneath the reader. This sounds like a detail, and it is most of 
the time. It becomes load-bearing the first time a large primary-key table 
takes longer to tier than the gap  [...]
+
+### Structure 3: The Changelog
+
+Every upsert and every delete also gets appended to a log, in the order it 
happened. This log behaves exactly like a regular log table on disk, same 
retention rules, same tiering to remote storage, same handoff to the lakehouse.
+
+Two things make the changelog different from the rest of the primary-key table.
+
+**It grows with the number of writes, not the number of unique keys.** A 
primary-key table that updates the same 100 keys ten million times has a small 
live store and an enormous changelog. RocksDB collapses by key; the log does 
not. This is what makes the changelog useful as a CDC feed · downstream 
consumers see every change in order, not just the latest value.
+
+**Deleting old changelog segments has no effect on the live store.** The live 
store is complete on its own; it doesn't need the log to know the current value 
of any key. The log is there for replay (when a tablet needs to recover) and 
for downstream feed (when something is reading change events). **It is not a 
place where state lives.**
+
+![](assets/storage_hierarchy/fig6.png)
+
+> **Note:** This is a simplified version of the changelog for illustrative 
purposes. 
+
+## Recovery: Independent Tracks, Coupled Outcomes
+
+The snapshot upload track and the log upload track look independent from a 
configuration standpoint. Separate settings, separate schedulers, separate 
remote subdirectories. **They are not independent when you actually need to 
recover from disk loss.**
+
+Recovery on a fresh tablet server works in two stages. The snapshot brings the 
live state up to whatever point it was taken at. The changelog then replays 
every change since that point to catch up to the current moment.
+
+If remote-log tiering is off, that changelog tail lives only on the failed 
tablet server's local disk, which is the disk you just lost. The snapshot, 
however durably stored, can only restore the state as of its own offset. 
Everything written since then is gone.
+
+![](assets/storage_hierarchy/fig7.png)
+
+**The two upload tracks are independent on the way in. The recovery story 
stitches them back together on the way out, and breaks if either piece is 
missing.**
+
+## Standby Replicas
+
+Standby replicas are a new feature. The election machinery that designates a 
follower as the standby has landed 
([#2828](https://github.com/apache/fluss/issues/2828)). The **hot-standby 
path** that keeps the standby's RocksDB current with the leader, and the 
bootstrap path that downloads the latest KV snapshot when a fresh server 
becomes a standby, are tracked by PR 
[#2835](https://github.com/apache/fluss/pull/2835). The description below 
reflects the target behavior once those land.
+
+Everything described so far is the cold-start path; the one that runs when no 
other copy of a bucket is still alive. Most production recoveries aren't cold 
restarts.
+
+**Fluss replicates each bucket across multiple tablet servers**: one leader 
handling writes, plus followers continuously tailing the same log. One of those 
followers is the designated **standby**, the replica the controller will 
promote on leader failure, and the one that maintains a live RocksDB kept 
current with the leader in near real time.
+
+When the leader fails, the controller promotes the standby. **The standby's 
live RocksDB is already current, so traffic resumes in seconds, with no S3 
download and no log replay.** The snapshot path still matters, it's the safety 
net when an entire replica set is lost at once, when a bucket gets reassigned 
to a brand-new tablet server, or when a fresh follower is bootstrapping into 
the cluster. But that path is the fallback, not the everyday failure handler.
+
+This refines the framing of remote storage. Calling it the recovery substrate 
and the durability floor was accurate. It just isn't the recovery path you 
exercise most often in healthy production. **The everyday path is one replica 
picking up where another left off**, which is precisely why **running with 
replication factor 1 in production is a bad idea, however durable your 
snapshots are**.
+
+![](assets/storage_hierarchy/fig8.png)
+
+## Combining Tiers
+
+There are four ways to combine **remote-log tiering** and **Lakehouse 
tiering**. Three are useful; one isn't.
+
+![](assets/storage_hierarchy/fig9.png)
+
+| Remote | Lakehouse | What you get | When to use                              
                                                                                
                                             |
+|--------|---|---|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Off**  | **Off** | Local disk only. Bounded by physical local-disk size. | 
**Don't run this configuration in production :)**                               
                                                                                
                              |
+| **On**     | **Off** | Production-grade log retention via S3. No analytical 
projection. | The most common starting point. Sensible when Fluss is the 
durable log for streaming consumers, not yet a streaming lakehouse. A good 
first step when adopting Fluss. |
+| **Off**    | **On**  | Lakehouse works normally; local disk grows until 
writes start failing. The remote-log task is what trims local segments, and 
disabling it means nothing trims them. | **Don't run this configuration in 
production :)**                                                                 
                                                    |
+| **On**     | **On**  | Full streaming-lakehouse setup. Logs are tiered to 
S3, snapshots are uploaded to S3, the Tiering Service produces the lakehouse 
projection, and the lake-confirmation gate stacks on top of TTL. | The 
configuration Fluss is designed around.                                         
                                                                                
  |
+
+The primary-key snapshot track is orthogonal to all of this. It runs on its 
own cadence (`kv.snapshot.interval`, default 10 minutes), writes to its own 
remote subdirectory (`/kv`), and is what makes primary-key tables recoverable 
after disk loss. **Disabling remote-log tiering does not disable KV snapshot 
upload.** Three independent tracks, three independent config keys · the 
configuration vocabulary does not make this obvious, but the runtime behavior 
does.
+
+## Closing Thoughts
+
+Fluss's storage layer is structurally simple -- three tiers, two background 
tasks -- and the simplicity is what makes it easy to misread. 
+
+* **Tier 1** looks like the tier that matters, because it's the only one on 
the live query path. 
+* **Tier 2** looks like an implementation detail, because it's **"just S3"**. 
+* **Tier 3** looks like a destination, because it's the lakehouse.
+
+Each shortcut is wrong in a way that only becomes visible after you've 
configured something based on it.
+
+The model here is: three tiers with three different jobs, two background tasks 
that should be reasoned about independently, a small set of defaults 
deliberately tuned for production. 
+
+Disabling those defaults is almost always the wrong move. Tuning them to your 
workload is almost always the right one. 🌊
\ No newline at end of file
diff --git a/blog/assets/storage_hierarchy/banner.png 
b/blog/assets/storage_hierarchy/banner.png
new file mode 100644
index 0000000..9519ffb
Binary files /dev/null and b/blog/assets/storage_hierarchy/banner.png differ
diff --git a/blog/assets/storage_hierarchy/fig1.png 
b/blog/assets/storage_hierarchy/fig1.png
new file mode 100644
index 0000000..4e4a302
Binary files /dev/null and b/blog/assets/storage_hierarchy/fig1.png differ
diff --git a/blog/assets/storage_hierarchy/fig2.png 
b/blog/assets/storage_hierarchy/fig2.png
new file mode 100644
index 0000000..dbad81f
Binary files /dev/null and b/blog/assets/storage_hierarchy/fig2.png differ
diff --git a/blog/assets/storage_hierarchy/fig3.png 
b/blog/assets/storage_hierarchy/fig3.png
new file mode 100644
index 0000000..9ba114e
Binary files /dev/null and b/blog/assets/storage_hierarchy/fig3.png differ
diff --git a/blog/assets/storage_hierarchy/fig4.png 
b/blog/assets/storage_hierarchy/fig4.png
new file mode 100644
index 0000000..192e4c0
Binary files /dev/null and b/blog/assets/storage_hierarchy/fig4.png differ
diff --git a/blog/assets/storage_hierarchy/fig5.png 
b/blog/assets/storage_hierarchy/fig5.png
new file mode 100644
index 0000000..f48ac14
Binary files /dev/null and b/blog/assets/storage_hierarchy/fig5.png differ
diff --git a/blog/assets/storage_hierarchy/fig6.png 
b/blog/assets/storage_hierarchy/fig6.png
new file mode 100644
index 0000000..0ada15c
Binary files /dev/null and b/blog/assets/storage_hierarchy/fig6.png differ
diff --git a/blog/assets/storage_hierarchy/fig7.png 
b/blog/assets/storage_hierarchy/fig7.png
new file mode 100644
index 0000000..1c21c26
Binary files /dev/null and b/blog/assets/storage_hierarchy/fig7.png differ
diff --git a/blog/assets/storage_hierarchy/fig8.png 
b/blog/assets/storage_hierarchy/fig8.png
new file mode 100644
index 0000000..75a2919
Binary files /dev/null and b/blog/assets/storage_hierarchy/fig8.png differ
diff --git a/blog/assets/storage_hierarchy/fig9.png 
b/blog/assets/storage_hierarchy/fig9.png
new file mode 100644
index 0000000..d682381
Binary files /dev/null and b/blog/assets/storage_hierarchy/fig9.png differ
diff --git a/src/theme/BlogPostItem/Header/Title/index.tsx 
b/src/theme/BlogPostItem/Header/Title/index.tsx
new file mode 100644
index 0000000..a1f55b0
--- /dev/null
+++ b/src/theme/BlogPostItem/Header/Title/index.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import clsx from 'clsx';
+import Link from '@docusaurus/Link';
+import {useBlogPost} from '@docusaurus/plugin-content-blog/client';
+import styles from './styles.module.css';
+
+type Props = {
+  className?: string;
+};
+
+export default function BlogPostItemHeaderTitle({className}: Props): 
JSX.Element {
+  const {metadata, isBlogPostPage} = useBlogPost();
+  const {permalink, title, frontMatter} = metadata;
+  const subtitle = (frontMatter as {subtitle?: string}).subtitle;
+  const TitleHeading = isBlogPostPage ? 'h1' : 'h2';
+
+  return (
+    <>
+      <TitleHeading className={clsx(styles.title, className)} 
itemProp="headline">
+        {isBlogPostPage ? title : <Link to={permalink} 
itemProp="url">{title}</Link>}
+      </TitleHeading>
+      {subtitle && (
+        <p className={clsx(styles.subtitle, 
'blog-post-subtitle')}>{subtitle}</p>
+      )}
+    </>
+  );
+}
diff --git a/src/theme/BlogPostItem/Header/Title/styles.module.css 
b/src/theme/BlogPostItem/Header/Title/styles.module.css
new file mode 100644
index 0000000..b4c9534
--- /dev/null
+++ b/src/theme/BlogPostItem/Header/Title/styles.module.css
@@ -0,0 +1,22 @@
+.title {
+  font-size: 3rem;
+}
+
+.subtitle {
+  font-size: 1.5rem;
+  font-weight: 400;
+  color: var(--ifm-color-emphasis-700);
+  margin-top: -0.25rem;
+  margin-bottom: 1rem;
+  line-height: 1.3;
+}
+
+@media (max-width: 576px) {
+  .title {
+    font-size: 2rem;
+  }
+
+  .subtitle {
+    font-size: 1.15rem;
+  }
+}

Reply via email to