Skip to content

Commit 11479f3

Browse files
fix(clickhouse): add validation and improve s3-tier-move-factor UX
Addresses PR review feedback: - Add input validation to reject values outside [0.0, 1.0] range - Fix option description to correctly describe ClickHouse move_factor semantics (move when free space falls BELOW threshold, not above) - Add unit tests for boundary validation (0.0, 1.0, -0.1, 1.1) - Update docs with s3_tier policy comparison and usage guide - Add DROP TABLE cleanup in E2E test to prevent leftover state
1 parent dcd1ca2 commit 11479f3

File tree

4 files changed

+139
-9
lines changed

4 files changed

+139
-9
lines changed

bin/end-to-end-test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,9 @@ EOF
934934
return 1
935935
fi
936936

937+
echo "--- Cleaning up test table ---"
938+
clickhouse-query "DROP TABLE IF EXISTS test_s3_tier"
939+
937940
echo "=== ClickHouse S3 tier test completed ==="
938941
}
939942

docs/user-guide/clickhouse.md

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ easy-db-lab clickhouse init --s3-cache-on-write false
3434
|--------|-------------|---------|
3535
| `--s3-cache` | Size of the local S3 cache | 10Gi |
3636
| `--s3-cache-on-write` | Cache data during write operations | true |
37+
| `--s3-tier-move-factor` | Move data to S3 tier when local disk free space falls below this fraction (0.0-1.0) | 0.2 |
38+
| `--replicas-per-shard` | Number of replicas per shard | 3 |
3739

3840
Configuration is saved to the cluster state and applied when you run `clickhouse start`.
3941

@@ -189,14 +191,14 @@ ClickHouse is configured with two storage policies. You select the policy when c
189191

190192
### Policy Comparison
191193

192-
| Aspect | `local` | `s3_main` |
193-
|--------|---------|-----------|
194-
| **Storage Location** | Local NVMe disks | S3 bucket with configurable local cache |
195-
| **Performance** | Best latency, highest throughput | Higher latency, cache-dependent |
196-
| **Capacity** | Limited by disk size | Virtually unlimited |
197-
| **Cost** | Included in instance cost | S3 storage + request costs |
198-
| **Data Persistence** | Lost when cluster is destroyed | Persists independently |
199-
| **Best For** | Benchmarks, low-latency queries | Large datasets, cost-sensitive workloads |
194+
| Aspect | `local` | `s3_main` | `s3_tier` |
195+
|--------|---------|-----------|-----------|
196+
| **Storage Location** | Local NVMe disks | S3 bucket with configurable local cache | Hybrid: starts local, moves to S3 when disk fills |
197+
| **Performance** | Best latency, highest throughput | Higher latency, cache-dependent | Good initially, degrades as data moves to S3 |
198+
| **Capacity** | Limited by disk size | Virtually unlimited | Virtually unlimited |
199+
| **Cost** | Included in instance cost | S3 storage + request costs | S3 storage + request costs |
200+
| **Data Persistence** | Lost when cluster is destroyed | Persists independently | Persists independently |
201+
| **Best For** | Benchmarks, low-latency queries | Large datasets, cost-sensitive workloads | Mixed hot/cold workloads with automatic tiering |
200202

201203
### Local Storage (`local`)
202204

@@ -251,6 +253,50 @@ SETTINGS storage_policy = 's3_main';
251253
- Cache is automatically managed by ClickHouse
252254
- First query on cold data will be slower; subsequent queries use cache
253255

256+
### S3 Tiered Storage (`s3_tier`)
257+
258+
The S3 tiered policy provides automatic data movement from local disks to S3 based on disk space availability. This policy starts with local storage and automatically moves data to S3 when local disk space runs low, providing the best of both worlds: fast local performance for hot data and unlimited S3 capacity for cold data.
259+
260+
**Prerequisite**: Your cluster must be initialized with an S3 bucket. Set this during `init`:
261+
262+
```bash
263+
easy-db-lab init my-cluster --s3-bucket my-clickhouse-data
264+
```
265+
266+
Configure the tiering behavior before starting ClickHouse:
267+
268+
```bash
269+
# Move data to S3 when local disk free space falls below 20% (default)
270+
easy-db-lab clickhouse init --s3-tier-move-factor 0.2
271+
272+
# More aggressive tiering - move when free space < 50%
273+
easy-db-lab clickhouse init --s3-tier-move-factor 0.5
274+
```
275+
276+
Then create tables with S3 tiered storage:
277+
278+
```sql
279+
CREATE TABLE my_table (...)
280+
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/default/my_table', '{replica}')
281+
ORDER BY id
282+
SETTINGS storage_policy = 's3_tier';
283+
```
284+
285+
**When to use S3 tiered storage:**
286+
287+
- Workloads with mixed hot/cold data access patterns
288+
- Growing datasets that may outgrow local disk capacity
289+
- Want automatic cost optimization without manual intervention
290+
- Need local performance for recent data with S3 capacity for historical data
291+
292+
**How automatic tiering works:**
293+
294+
- New data is written to local disks first (fast writes)
295+
- When local disk free space falls below the configured threshold (default: 20%), ClickHouse automatically moves the oldest data to S3
296+
- Data on S3 is still queryable but with higher latency
297+
- The local cache (configured with `--s3-cache`) helps performance for frequently accessed S3 data
298+
- Manual moves are also possible: `ALTER TABLE my_table MOVE PARTITION tuple() TO DISK 's3'`
299+
254300
## Stopping ClickHouse
255301

256302
To remove the ClickHouse cluster:

src/main/kotlin/com/rustyrazorblade/easydblab/commands/clickhouse/ClickHouseInit.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,16 @@ class ClickHouseInit : PicoBaseCommand() {
4242

4343
@Option(
4444
names = ["--s3-tier-move-factor"],
45-
description = ["Fraction of local disk free space that triggers data move to S3 tier (default: \${DEFAULT-VALUE})"],
45+
description = ["Move data to S3 tier when local disk free space falls below this fraction (0.0-1.0) (default: \${DEFAULT-VALUE})"],
4646
)
4747
var s3TierMoveFactor: Double = Constants.ClickHouse.DEFAULT_S3_TIER_MOVE_FACTOR
4848

4949
override fun execute() {
50+
// Validate s3TierMoveFactor range [0.0, 1.0] per ClickHouse requirements
51+
require(s3TierMoveFactor in 0.0..1.0) {
52+
"s3TierMoveFactor must be in range [0.0, 1.0], got: $s3TierMoveFactor"
53+
}
54+
5055
val state = clusterStateManager.load()
5156
val config =
5257
ClickHouseConfig(

src/test/kotlin/com/rustyrazorblade/easydblab/commands/clickhouse/ClickHouseInitTest.kt

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.rustyrazorblade.easydblab.configuration.ClickHouseConfig
66
import com.rustyrazorblade.easydblab.configuration.ClusterState
77
import com.rustyrazorblade.easydblab.configuration.ClusterStateManager
88
import org.assertj.core.api.Assertions.assertThat
9+
import org.assertj.core.api.Assertions.assertThatThrownBy
910
import org.junit.jupiter.api.BeforeEach
1011
import org.junit.jupiter.api.Test
1112
import org.koin.core.module.Module
@@ -107,4 +108,79 @@ class ClickHouseInitTest : BaseKoinTest() {
107108
assertThat(savedState.clickHouseConfig!!.replicasPerShard)
108109
.isEqualTo(Constants.ClickHouse.DEFAULT_REPLICAS_PER_SHARD)
109110
}
111+
112+
@Test
113+
fun `init rejects s3TierMoveFactor below 0`() {
114+
val state = createTestState()
115+
whenever(mockClusterStateManager.load()).thenReturn(state)
116+
117+
val command = ClickHouseInit()
118+
command.s3TierMoveFactor = -0.1
119+
120+
assertThatThrownBy { command.execute() }
121+
.isInstanceOf(IllegalArgumentException::class.java)
122+
.hasMessageContaining("s3TierMoveFactor must be in range [0.0, 1.0]")
123+
}
124+
125+
@Test
126+
fun `init rejects s3TierMoveFactor above 1`() {
127+
val state = createTestState()
128+
whenever(mockClusterStateManager.load()).thenReturn(state)
129+
130+
val command = ClickHouseInit()
131+
command.s3TierMoveFactor = 1.1
132+
133+
assertThatThrownBy { command.execute() }
134+
.isInstanceOf(IllegalArgumentException::class.java)
135+
.hasMessageContaining("s3TierMoveFactor must be in range [0.0, 1.0]")
136+
}
137+
138+
@Test
139+
fun `init accepts s3TierMoveFactor at lower boundary`() {
140+
val state = createTestState()
141+
whenever(mockClusterStateManager.load()).thenReturn(state)
142+
143+
val command = ClickHouseInit()
144+
command.s3TierMoveFactor = 0.0
145+
command.execute()
146+
147+
val captor = argumentCaptor<ClusterState>()
148+
verify(mockClusterStateManager).save(captor.capture())
149+
150+
val savedState = captor.firstValue
151+
assertThat(savedState.clickHouseConfig!!.s3TierMoveFactor).isEqualTo(0.0)
152+
}
153+
154+
@Test
155+
fun `init accepts s3TierMoveFactor at upper boundary`() {
156+
val state = createTestState()
157+
whenever(mockClusterStateManager.load()).thenReturn(state)
158+
159+
val command = ClickHouseInit()
160+
command.s3TierMoveFactor = 1.0
161+
command.execute()
162+
163+
val captor = argumentCaptor<ClusterState>()
164+
verify(mockClusterStateManager).save(captor.capture())
165+
166+
val savedState = captor.firstValue
167+
assertThat(savedState.clickHouseConfig!!.s3TierMoveFactor).isEqualTo(1.0)
168+
}
169+
170+
@Test
171+
fun `init uses default s3TierMoveFactor when not specified`() {
172+
val state = createTestState()
173+
whenever(mockClusterStateManager.load()).thenReturn(state)
174+
175+
val command = ClickHouseInit()
176+
command.execute()
177+
178+
val captor = argumentCaptor<ClusterState>()
179+
verify(mockClusterStateManager).save(captor.capture())
180+
181+
val savedState = captor.firstValue
182+
assertThat(savedState.clickHouseConfig).isNotNull
183+
assertThat(savedState.clickHouseConfig!!.s3TierMoveFactor)
184+
.isEqualTo(Constants.ClickHouse.DEFAULT_S3_TIER_MOVE_FACTOR)
185+
}
110186
}

0 commit comments

Comments
 (0)