File Compaction
How Arc Enterprise merges small Parquet files into larger ones, and how a dedicated compactor node runs the schedule, min_files, and concurrency settings off the write path.
Arc's automatic compaction system merges small Parquet files into larger, optimized files for dramatically faster queries.
Overview
Compaction is Arc's file optimization system that merges small files into larger ones, improving query performance by 10-50x.
Key Features:
- Automatic - Runs on schedule (default: hourly at :05)
- Safe - Locked partitions prevent concurrent compaction
- Efficient - Parallel, sorted merging by the query engine
- Non-blocking - Queries work during compaction
- Enabled by default - Essential for production
Why compaction matters
The small file problem
Arc's high-performance ingestion creates many small files:
At a sustained high ingest rate with a 5-second flush:
→ one file per flush interval per measurement
→ 12 files per minute per measurement
→ 720 files per hour per measurement
→ 17,280 files per day per measurementImpact on Queries:
- Slow queries - The query engine must open/scan hundreds of files
- High costs - More S3/MinIO API calls
- Poor compression - Small files compress less efficiently
- Reduced pruning - Less effective partition elimination
After compaction
Real Production Test Results:
Before: 2,704 small files (Snappy) = 3.7 GB
After: 3 compacted files (ZSTD) = 724 MB
Compression: 80.4% space savings
File reduction: 901x fewer files (2,704 → 3)
Compaction time: 5 secondsPer-Measurement Breakdown:
- mem: 888 files → 1 file, 1,213 MB → 239 MB (80.3% compression)
- disk: 906 files → 1 file, 1,237 MB → 242 MB (80.4% compression)
- cpu: 910 files → 1 file, 1,246 MB → 243 MB (80.5% compression)
Query Performance:
- 10-50x faster - Single file scan vs hundreds
- 99% fewer API calls - Massive cost reduction (2,704 → 3 LIST operations)
- 80.4% compression - ZSTD compaction vs Snappy writes
- Effective pruning - The query engine can skip entire files
How it works
Compaction flow
1. Scheduler wakes up (cron: "5 * * * *")
↓
2. Scan storage for eligible partitions
↓
3. For each partition:
- Check age (>1 hour old?)
- Check file count (≥10 files?)
- Check if already compacted?
↓
4. Acquire partition lock (SQLite)
↓
5. Download small files to temp directory
↓
6. Compact via the query engine (parallel, sorted)
↓
7. Upload compacted file to storage
↓
8. Delete old small files
↓
9. Release lock & cleanup temp files
↓
10. Repeat for next partitionPartition structure
Data is organized by hour:
arc/ # Bucket
├── default/ # Database
│ └── cpu/ # Measurement
│ └── 2025/10/08/ # Date
│ ├── 14/ # Hour (2 PM) - Eligible for compaction
│ │ ├── file1.parquet (50 MB)
│ │ ├── file2.parquet (48 MB)
│ │ └── ...
│ ├── 15/ # Hour (3 PM) - Eligible for compaction
│ └── 16/ # Hour (4 PM) - CURRENT, skip!Compaction merges all files in a partition (e.g., 2025/10/08/14/) into one optimized file.
Configuration
Default configuration
Compaction is enabled by default in arc.toml:
[compaction]
enabled = true
# Hourly tier
hourly_enabled = true
hourly_schedule = "5 * * * *" # Cron schedule: every hour at :05
hourly_min_age_hours = 1 # Wait 1 hour before compacting (let the hour complete)
hourly_min_files = 10 # Only compact if >=10 files exist
# Daily tier
daily_enabled = true
daily_schedule = "0 3 * * *" # Cron schedule: 3 AM daily
daily_min_age_hours = 24 # Wait 24 hours
daily_min_files = 12 # Only compact if >=12 files exist
max_concurrent = 2 # Run 2 compactions in parallelConfiguration options
Schedule
[compaction]
hourly_schedule = "5 * * * *" # Every hour at :05 (default)
daily_schedule = "0 3 * * *" # 3 AM daily (default)
# hourly_schedule = "0 */2 * * *" # Every 2 hours at :00Cron format: minute hour day month weekday
Minimum age
[compaction]
hourly_min_age_hours = 1 # Don't compact the current hour (default)
daily_min_age_hours = 24 # Daily tier waits a full day (default)
# hourly_min_age_hours = 2 # Wait 2 hours (more conservative)
# hourly_min_age_hours = 0 # Compact immediately (aggressive)Minimum files
[compaction]
hourly_min_files = 10 # Only compact if >=10 files (default)
daily_min_files = 12 # Daily tier threshold (default)
# hourly_min_files = 50 # Only compact with many files
# hourly_min_files = 5 # Compact more aggressivelyConcurrent jobs
[compaction]
max_concurrent = 2 # Run 2 compactions in parallel (default)
# max_concurrent = 4 # More parallelism (uses more CPU/memory)
# max_concurrent = 1 # Sequential (lower resource usage)Memory limit and threads (per subprocess)
Each compaction job runs in an isolated subprocess with its own query engine instance. These keys bound that instance's resources:
[compaction]
memory_limit = "" # Per-subprocess engine memory limit; "" (default) = auto
threads = 0 # Per-subprocess engine threads; 0 (default) = auto
# memory_limit = "2GB" # Explicit cap
# threads = 4 # Explicit thread countEnv vars: ARC_COMPACTION_MEMORY_LIMIT, ARC_COMPACTION_THREADS.
Auto behavior:
memory_limitderives asdatabase.memory_limit / max_concurrent, so all concurrent compaction jobs together stay within roughly onedatabase.memory_limit. Withdatabase.memory_limit = "8GB"and the default concurrency of 2, each subprocess gets4GB.threadsdefaults to half the CPU cores (minimum 1), so the default two concurrent jobs together use about one machine's worth of cores, leaving headroom for ingest and queries.
Accepted memory_limit forms are absolute sizes with a unit: "8GB", "512MB", "0.5GB". Percent and unit-less forms are rejected at startup (DuckDB's SET memory_limit does not support them), as are other invalid values. The effective values appear in the startup log (subprocess_memory_limit, subprocess_threads).
When a job exceeds its memory limit, DuckDB spills to a duckdb-spill/ directory inside the job's temp directory (under compaction.temp_directory) — size that volume for your largest partitions. Spill files are removed by normal job cleanup and by the crash sweeps on startup.
On a dedicated compactor node these can be raised well above the defaults, since compaction is not competing with ingest or queries for RAM and cores on that host.
Files per batch
A partition with more files than this is split into several batches, each compacted as an independent job producing its own output file.
[compaction]
max_files_per_batch = 30 # Files per compaction job (default)
# max_files_per_batch = 5 # Smaller outputs, more jobs per partition
# max_files_per_batch = 60 # Fewer, larger outputsValid range is 2–500. Values outside it fall back to the default with a startup warning; 1 is rejected because compaction's adaptive retry cannot process a single-file batch.
This bounds the file count per job, not the output size in bytes — compacted file size tracks input file size, which follows your ingest buffer settings. The upper bound exists because a single read_parquet() call spanning too many files can abort.
Compression
Compaction always writes its output with ZSTD, which is why compacted files are substantially smaller than the freshly-ingested files they replace. This is not configurable per tier.
The compression used for incoming writes is separate, and is set by
ingest.compression (default snappy) — see the
configuration overview.
Disable compaction
[compaction]
enabled = falseWhen to disable:
- Testing ingestion performance
- Very low write volume (<10 files/hour)
- Debugging compaction issues
Monitoring
Check compaction status
curl http://localhost:8000/api/compaction/status \
-H "Authorization: Bearer $ARC_TOKEN"Response:
{
"enabled": true,
"running": false,
"last_run": "2025-10-08T14:05:00Z",
"next_run": "2025-10-08T15:05:00Z",
"stats": {
"total_jobs": 42,
"successful_jobs": 40,
"failed_jobs": 2,
"total_files_compacted": 12580,
"total_bytes_saved": 8589934592
}
}Get detailed statistics
curl http://localhost:8000/api/compaction/stats \
-H "Authorization: Bearer $ARC_TOKEN"List eligible partitions
curl http://localhost:8000/api/compaction/candidates \
-H "Authorization: Bearer $ARC_TOKEN"Response:
{
"candidates": [
{
"partition": "default/cpu/2025/10/08/14",
"file_count": 150,
"total_size_mb": 7500,
"age_hours": 2.5,
"eligible": true
},
{
"partition": "default/mem/2025/10/08/14",
"file_count": 120,
"total_size_mb": 6000,
"age_hours": 2.5,
"eligible": true
}
],
"total_candidates": 2
}Manually trigger compaction
curl -X POST http://localhost:8000/api/compaction/trigger \
-H "Authorization: Bearer $ARC_TOKEN"View active jobs
curl http://localhost:8000/api/compaction/jobs \
-H "Authorization: Bearer $ARC_TOKEN"View job history
curl http://localhost:8000/api/compaction/history \
-H "Authorization: Bearer $ARC_TOKEN"Performance impact
Compaction performance
Test Environment: Apple M3 Max (14 cores, 36GB RAM)
| Files | Size | Compaction Time | Final Size | Compression |
|---|---|---|---|---|
| 888 | 1.2 GB | 2.1s | 239 MB | 80.3% |
| 906 | 1.2 GB | 2.2s | 242 MB | 80.4% |
| 910 | 1.2 GB | 2.3s | 243 MB | 80.5% |
Total: 2,704 files (3.7 GB) → 3 files (724 MB) in 6.6 seconds
Query performance
Before Compaction:
SELECT * FROM default.cpu WHERE time > NOW() - INTERVAL 1 HOUR;
-- 5.2 seconds (scan 720 files)After Compaction:
SELECT * FROM default.cpu WHERE time > NOW() - INTERVAL 1 HOUR;
-- 0.05 seconds (scan 1 file) - 104x faster!Storage savings
Original files (Snappy): 3.7 GB
Compacted files (ZSTD): 724 MB
Space saved: 80.4%Best practices
1. Let compaction run automatically
The default schedule (hourly) works well for most use cases:
[compaction]
enabled = true
hourly_schedule = "5 * * * *"2. Monitor compaction jobs
Set up alerts for:
- Failed compaction jobs
- Partitions with >1000 files
- Compaction taking >10 minutes
3. Adjust based on write volume
High write volume:
[compaction]
hourly_min_files = 100 # Wait for more files
max_concurrent = 4 # More parallelismLow write volume:
[compaction]
hourly_min_files = 5 # Compact with fewer files
hourly_schedule = "0 */6 * * *" # Every 6 hours4. Tune files per batch
[compaction]
max_files_per_batch = 30 # Files per compaction job (default)
# max_files_per_batch = 60 # Fewer, larger outputs
# max_files_per_batch = 5 # Smaller outputs, more jobs per partition5. Reduce file generation at source
Best practice: Increase buffer sizes to generate fewer files:
[ingest]
max_buffer_size = 200000 # Up from 50,000 (4x fewer files)
max_buffer_age_ms = 10000 # Up from 5000 (2x fewer files)Impact:
- Files generated: 2,000/hour → 250/hour (8x reduction)
- Compaction time: substantially reduced
- Memory usage: +300MB per worker
This is the most effective optimization - fewer files means faster compaction AND faster queries.
Troubleshooting
Compaction not running
Check status:
curl http://localhost:8000/api/compaction/statusVerify configuration:
# Check if enabled
grep "enabled" arc.toml
# Check schedule
grep "schedule" arc.tomlCheck logs:
# Docker
docker logs arc | grep compaction
# Native
sudo journalctl -u arc | grep compactionCompaction taking too long
Symptoms: Compaction jobs running for >30 minutes
Solutions:
-
Reduce files per batch:
[compaction] max_files_per_batch = 10 # Smaller compaction jobs -
Increase parallelism:
[compaction] max_concurrent = 4 -
Reduce files at source:
[ingest] max_buffer_size = 200000
Out of disk space during compaction
Symptoms: Compaction fails with disk space errors
Solutions:
-
Use temp directory on larger disk:
export TMPDIR=/mnt/large-disk/tmp -
Reduce concurrent jobs:
[compaction] max_concurrent = 1 -
Clean up old compacted files manually:
# Remove small files that were already compacted find ./data -name "*.parquet" -size -10M -delete
Compaction locks not releasing
Symptoms: Partitions stuck in "locked" state
Check locks:
# View active locks
sqlite3 ./data/arc.db "SELECT * FROM compaction_locks;"Clear stale locks:
# Locks expire automatically after 2 hours
# Or manually clear:
sqlite3 ./data/arc.db "DELETE FROM compaction_locks WHERE expires_at < datetime('now');"API reference
GET /api/v1/compaction/status
Get current compaction status.
Response:
{
"enabled": true,
"running": false,
"last_run": "2025-10-08T14:05:00Z",
"next_run": "2025-10-08T15:05:00Z"
}GET /api/v1/compaction/stats
Get detailed compaction statistics.
GET /api/v1/compaction/candidates
List partitions eligible for compaction.
POST /api/v1/compaction/trigger
Manually trigger compaction.
Response:
{
"message": "Compaction triggered",
"job_id": "comp_1696775400"
}GET /api/v1/compaction/jobs
View active compaction jobs.
GET /api/v1/compaction/history
View compaction job history.
Summary
Compaction is essential for production deployments:
Benefits:
- 10-50x faster queries
- 80% storage savings
- 99% fewer API calls
- Automatic and safe
Default configuration works for most cases:
[compaction]
enabled = true
hourly_schedule = "5 * * * *"
hourly_min_age_hours = 1
hourly_min_files = 10Monitor regularly:
- Check
/api/v1/compaction/status - Alert on failed jobs
- Watch for partitions with >1000 files
Next steps
- Monitor Compaction - Set up health checks
- Configure WAL - Add durability guarantees
- Tune Performance - Optimize for your workload
Write-Ahead Log (WAL)
How the Arc Enterprise WAL bounds data loss to the sync interval on a writer node, the durability and throughput tradeoff per sync mode, and recovery behaviour after failover.
Query Caching
The three caches an Arc Enterprise reader node uses to serve repeated dashboard queries — result, metadata, and S3 file cache — and how to size and invalidate each one.