Performance¶
Performance Summary¶
MaxIOFS demonstrates excellent performance on Linux production environments with sub-10ms latencies for all S3 operations under heavy concurrent load.
| Operation | p50 | p95 | p99 | Throughput |
|---|---|---|---|---|
| Upload | 4 ms | 9 ms | 13 ms | 1.7–2.4 MB/s |
| Download | 3 ms | 7–13 ms | 10–23 ms | 172 MB/s |
| List | 13 ms | 28 ms | 34 ms | — |
| Delete | 4 ms | 7 ms | 9 ms | — |
Baseline: Debian Linux, 80 CPU cores, 125GB RAM, SSD/NVMe. Tested with K6 load testing (100,000+ operations, 100 concurrent VUs, 100% success rate).
Test Environments¶
Linux (Production Reference)¶
Windows (Development Only)¶
Critical finding: Linux is 10–300x faster than Windows across all metrics due to NTFS overhead, disk I/O subsystem, and OS scheduler differences. Windows test results are not representative of production performance and should be used for functional testing only.
Comparison (Mixed Workload p95)¶
| Operation | Windows p95 | Linux p95 | Improvement |
|---|---|---|---|
| Upload | 2,105 ms | 9 ms | 234x |
| Download | 221 ms | 7 ms | 32x |
| List | 1,008 ms | 28 ms | 36x |
| Delete | 86 ms | 7 ms | 12x |
Service Level Objectives (SLOs)¶
1. Availability¶
| Target | Measurement | Error Budget |
|---|---|---|
| 99.9% | Rolling 30-day window | 43 minutes/month |
sum(increase(maxiofs_http_requests_total{status!~"5.."}[30d]))
/ sum(increase(maxiofs_http_requests_total[30d])) * 100
2. Latency (P95)¶
| Target | Measurement | Safety Margin |
|---|---|---|
| < 50 ms for core S3 ops | Rolling 1-hour window | 5x above baseline |
| Threshold | Classification |
|---|---|
| < 20 ms | Excellent |
| 20–50 ms | Within SLO |
| 50–100 ms | Warning |
| > 100 ms | SLO violation |
3. Latency (P99)¶
| Target | Measurement | Safety Margin |
|---|---|---|
| < 100 ms for core S3 ops | Rolling 1-hour window | 10x above baseline |
4. Throughput¶
| Target | Measurement | Baseline |
|---|---|---|
| > 1,000 req/s sustained | Rolling 5-minute window | 1,500–2,000 req/s achieved |
5. Error Rate¶
| Target | Measurement | Counts Against SLO |
|---|---|---|
| < 1% server errors | Rolling 1-hour window | 5xx only (4xx excluded) |
Performance Targets by Operation¶
| Operation | P50 | P95 | P99 |
|---|---|---|---|
| PutObject | < 10 ms | < 50 ms | < 100 ms |
| GetObject | < 10 ms | < 50 ms | < 100 ms |
| DeleteObject | < 10 ms | < 50 ms | < 100 ms |
| ListObjects | < 20 ms | < 75 ms | < 150 ms |
| HeadObject | < 5 ms | < 25 ms | < 50 ms |
| MultipartUpload | < 50 ms | < 200 ms | < 500 ms |
Monitoring & Alerting¶
Prometheus Metrics¶
Exposed at /metrics on the S3 port. The full list is in API.md. For performance:
maxiofs_s3_operation_duration_seconds{operation}andmaxiofs_http_request_duration_seconds{method, operation}: histograms, forhistogram_quantileover any windowmaxiofs_storage_operation_duration_seconds{operation}: the storage backend's share of a requestmaxiofs_operation_latency_{p50,p95,p99,mean}_milliseconds{operation},maxiofs_operation_success_rate_percent{operation}: each node's last hourmaxiofs_throughput_requests_per_second,maxiofs_throughput_bytes_per_second
Alert Rules¶
Defined in docker/prometheus/alerts.yml:
| Alert | Condition | Severity |
|---|---|---|
| HighP95Latency | p95 > 100ms for 5m | warning |
| CriticalP95Latency | p95 > 500ms for 2m | critical |
| LowSuccessRate | success < 95% for 3m | critical |
| SLOViolationAvailability | hourly avg < 99.9% | critical |
| SLOViolationLatencyP95 | hourly avg p95 > 50ms | warning |
| S3ServerErrors | more than 5% of S3 requests answered with a server error for 5m; SlowDown does not count |
warning |
| StorageFailures | a storage operation failing for 5m | critical |
Grafana Dashboard¶
docker/grafana/dashboards/maxiofs.json, filtered by node and bucket: S3 requests, errors and latency by operation, storage and object operations, sign-ins, bucket and Object Lock changes, background tasks, events, system resources and the per-node latency percentiles and throughput. See docker/README.md.
Error Budget Policy¶
Monthly error budget = (1 − SLO target) × total time
For 99.9% availability: 43.2 minutes/month
| Budget Consumed | Action |
|---|---|
| 0–25% | Normal operations |
| 25–50% | Increase monitoring, review recent changes |
| 50–75% | Freeze new features, focus on reliability |
| 75–100% | Code freeze, all hands on reliability |
| > 100% | Mandatory post-incident review |
Metadata Store Performance (Pebble)¶
MaxIOFS stores all object and bucket metadata in an embedded Pebble LSM-tree database. Understanding its behaviour is essential for diagnosing latency spikes in large deployments.
Cold vs. hot reads¶
The most common performance complaint is "the first folder listing after a restart is slow." This is expected and inherent to LSM-tree engines:
- Cold read (first access after restart): Pebble reads SSTable blocks from disk and populates the block cache. Duration depends on disk speed and folder size.
- Hot read (subsequent access): Data is served from the block cache in RAM — typically sub-millisecond.
There is no way to avoid the cold-read penalty entirely; you minimise its frequency by making the cache large enough to hold your working set.
Tuning the block cache¶
The single most impactful setting is storage.metadata_cache_size_mb in config.yaml:
| Scenario | Recommended cache |
|---|---|
| Dev / small deployment | 256 MB (default) |
| Medium workload | 512 MB |
| Veeam B&R / 20k–100k objects per bucket | 1 024 MB |
| Very large deployments | 2 048 MB+ |
Each object metadata record occupies ~500–800 bytes in the cache. A Veeam bucket with 40 000 objects fits in ~20–32 MB of cache — well within even the default 256 MB.
Write-heavy workloads (Veeam Backup & Replication)¶
Veeam writes thousands of objects in bursts. MaxIOFS ships with Pebble pre-tuned for this pattern:
| Tuning | Value | Why it matters for Veeam |
|---|---|---|
| MemTable size | 64 MB | Absorbs write bursts in RAM; fewer L0 flushes during active backup jobs. |
| MemTableStopWritesThreshold | 12 | Prevents write stalls while compaction catches up after a large restore job. |
| Compaction concurrency | 2–4 goroutines | Keeps the LSM tree compact in the background, maintaining fast list performance between backup sessions. |
| Bloom filters (L1–L6) | 10 bits/key | Point lookups (HEAD requests, existence checks by Veeam) skip unnecessary disk reads on non-matching keys. |
| Block size | 32 KB | Efficient for sequential range scans (folder listings, GETs of consecutive objects). |
These values are fixed in the binary and do not require configuration. Only the cache size is user-tunable.
Write durability cost¶
An overwrite retains a rollback copy of the object it replaces until the new index entry is durable, so the metadata commit for an object write, a version write and a multipart-part write waits for a WAL sync before returning — see Metadata durability in ARCHITECTURE.md. The bucket mutation lock is released before that wait, so concurrent writers to the same bucket share one fsync instead of serializing on it (Pebble's group commit).
Measured with scripts/s3-battery.sh --bench (8 KiB objects, one node, local SSD):
| 1 stream | 32 streams | |
|---|---|---|
| Create | 11.7 obj/s | 31.0 obj/s |
| Overwrite | 9.2 obj/s | 28.7 obj/s |
At one stream, releasing the lock before the fsync roughly doubles the rate over waiting for it inside the lock (7.6 → 11.7 obj/s). At 32 streams the gain is within noise — the ceiling there is the filesystem backend's own write path (three fsyncs and three renames per object between the data file, its sidecar and the staged-commit protocol), not the metadata commit. A raw filesystem probe with the same two-file, two-fsync pattern reaches roughly 90 obj/s on the same disk, so there is headroom in that path that this release does not yet use.
Run the benchmark against your own storage before sizing a Veeam job:
scripts/s3-battery.sh --bench-only --console http://localhost:8081 --admin-password '...' \
--bench-objects 1000 --bench-streams 32 --bench-label baseline
Estimating required cache for your workload¶
objects_per_bucket × avg_metadata_size_bytes ÷ 1_048_576 = MB needed per bucket
Example (Veeam, 40 000 objects, 700 bytes avg):
40 000 × 700 ÷ 1 048 576 ≈ 27 MB per bucket
For 10 such buckets:
27 × 10 = 270 MB → 512 MB cache recommended (headroom for indexing overhead)
Veeam-Shaped Validation, November 6, 2026¶
One standalone node: Debian 13, 16 vCPU Xeon E5-2699 v4, 62 GB RAM, ext4 on LVM over a Unity SSD pool by FC. One Object Lock bucket, COMPLIANCE retention on every object, encryption at rest, 64 connections, one access key at the default limit of 1,000 requests/s. The disk alone: 890 MB/s write, 1,576 MB/s read, 559 fsync/s.
| Load | Objects | Rate | PUT p50 | PUT p99 | Errors |
|---|---|---|---|---|---|
| 1 MB | 1,501,697 | 345/s | — | — | 0 |
| 4 MB | 250,000 | 594 MB/s | 362 ms | 724 ms | 0 |
| 64 KB | 3,000,000 | 667/s | 54 ms | 365 ms | 0 |
At 4.8 million objects and 2.7 TB:
| Check | Result |
|---|---|
| Server memory | 630 MB RSS, 877 MB peak |
| Metadata | 1.9 GB, about 440 bytes per object |
ListObjectVersions, whole bucket |
4,778 pages, p50 64 ms, p99 126 ms, no duplicate, in order |
ListObjectsV2, whole bucket |
p50 31 ms |
PutObjectRetention, 30,000 extended |
no error, every date read back |
DeleteObjects, 200,000 versions with 120,000 writes alongside |
1,400 versions/s, files gone from the disk |
kill -9 under load, twice |
nothing acknowledged lost: 170,085 and 30,245 objects read back |
| Start | 13 s to serving; the reconciliation after a crash runs behind, about 1,000 objects/s |
| Disk at 99.5% | 2,679 writes answered 507, none 500; reads, deletes and retention go on, kill -9 included |
A real Veeam Backup & Replication 13 over HTTPS, immutability on, to a new Object Lock bucket on the same node:
| Job | Result |
|---|---|
| Full, 2.43 TB read | 717 GB in 1,042,307 objects in an hour; 1,043,799 PUT, p50 178 ms, p99 643 ms, no 5xx, no SlowDown |
| Incremental | 7,367 PUT in 44 minutes, no error |
| Immutability | every data block under COMPLIANCE, the date Veeam asked |
| Bucket counters | bytes and objects equal to the listing and the disk |
| Integrity scan, whole bucket | 1,047,851 versions read back, none corrupted, 2 h 34 min |
Optimization Guidelines¶
When to optimize: SLO violations > 5% of the time over 7 days.
Priority order:
1. Metadata cache — increase metadata_cache_size_mb if folder listings are slow after restart
2. Disk I/O — use SSD/NVMe, enable OS caching
3. Memory — reduce GC pressure, reuse buffers
4. Concurrency — optimize lock contention
5. Network — connection pooling, HTTP/2
Do not extrapolate Windows latency or throughput to Linux production capacity. Software-induced copying and repeated operations can still be identified on Windows; their production impact requires measurements on representative storage and workloads.
Load Testing¶
K6 test scripts are in tests/performance/:
| Script | Description |
|---|---|
upload_test.js |
Upload operations (ramp 1→50 VUs) |
download_test.js |
Download operations (ramp 1→100 VUs) |
mixed_workload.js |
40% upload, 50% download, 7% list, 3% delete |
run_linux_tests.sh |
Automated Linux test runner |
Write-Path Cost Accounting¶
internal/object/write_cost_test.go exercises the real object manager, encryption,
bucket metrics, Pebble and filesystem. It does not include HTTP, IAM, tenant quotas,
network, replication or multipart completion. No running server is needed or contacted.
Only test code is instrumented; production durability settings are unchanged.
The workload covers new objects, unversioned overwrites, versioned writes, new parts and replacement parts, with 4 KiB and 1 MiB payloads and 1, 8 and 32 concurrent workers. Replacement workers each own a key/part; this is not a single-hot-key contention test. Versioned writes retain history. Existing replacement data has the same size as the new payload. Setup, content verification and cleanup are excluded from the measured interval. Every final object/part is read back and checked; successful writes must leave no backups.
The small accounting test runs in CI. The disk workload is opt-in:
$env:MAXIOFS_WRITE_COST = '1'
$env:MAXIOFS_COST_OPS = '64'
go test ./internal/object -run '^TestWritePathCost$' -count=1 -v
MAXIOFS_WRITE_COST=1 MAXIOFS_COST_OPS=64 \
go test ./internal/object -run '^TestWritePathCost$' -count=1 -v
MAXIOFS_COST_OPS is the operation count per scenario (32 to 10000, default 64).
MAXIOFS_COST_DIR optionally selects an existing parent on the disk under test.
The test creates and removes its own unique subdirectory, never the parent.
Use an isolated test volume, not the live data directory. Large operation counts
increase both disk traffic and retained version history. The default matrix writes
about 964 MiB of payload, plus staging, backups, setup data and metadata.
Each WRITE_COST line contains JSON with backend calls, selected metadata calls,
bytes consumed from backend reads/writes, local throughput and local p50/p95 latency.
Counters preserve the reader's WriteTo fast path. They count application interfaces,
not system calls, physical IOPS, SSD wear or cache misses. Backend write bytes exclude
plaintext staging, backup destination writes, sidecars and the WAL. Backend reads during
these successful write scenarios are backup reads. Metadata lookups may hit memory.
There is no total fsync counter in this test. OS tracing is needed for that measurement.
Local Validation, September 21, 2026¶
All 30 scenarios passed on Windows/amd64, Go 1.26.6, with 32 operations per scenario. These are short bursts, not sustained-load or capacity results. At 32 workers there is only one measured operation per worker. Do not use the local latency as a production SLO.
Measured per-operation counters for 1 MiB payloads were stable across concurrency levels:
| Write | Bytes read for backup | Bytes passed to backend write | Bucket / object metadata lookups |
|---|---|---|---|
| New object | 0 | 1,048,908 | 3 / 2 |
| Unversioned overwrite | 1,048,908 | 1,048,908 | 3 / 3 |
| Versioned write | 0 | 1,048,908 | 3 / 2 |
| New part | 0 | 1,048,576 | 0 / 0 |
| Replacement part | 1,048,576 | 1,048,576 | 0 / 0 |
All scenarios made one metadata object/version/part commit and one explicit backend metadata read per operation. Part writes additionally looked up the upload and prior part. The encryption format accounts for the extra object bytes.
Source inspection, separately from the counters, shows that retainBackup writes the
entire previous content to a temporary file and syncs it; writeManifest syncs the
manifest and calls SyncDirectory. The new data and sidecar have their own syncs.
Directory sync is a no-op on Windows and an actual directory fsync on Unix, another
reason Windows timings cannot predict Linux durability costs. PutObject also stages
the plaintext before writing encrypted bytes. The test has not attributed elapsed time
between those operations or compared against a weaker durability mode.
The hardware-independent finding is the extra full-content copy on replacement. Removing it or reducing repeated lookups is a candidate for investigation, not evidence that it is safe to remove recovery protection or that all filesystem checks are redundant.
PUT Staging and Multipart Completion¶
internal/object/staging_cost_test.go extends the investigation to completion.
TestStagingPassAccounting uses small fixtures in CI. Set MAXIOFS_WRITE_COST=1
and run go test ./internal/object -run '^TestMultipartPassCost$' -count=1 -v
for 2 and 8 parts of 5 MiB each, both new objects and overwrites. It uses its own
temporary directories and does not connect to a server. Unlike TestWritePathCost,
this probe does not accept MAXIOFS_COST_DIR and does not measure elapsed time:
its directory snapshots would distort timing. Setup uploads are outside the counters.
The probe counts bytes read from parts, assembled plaintext, previous ciphertext and the encryption input; it counts each backend publication and snapshots staging file sizes immediately before it. Final plaintext, ETag, user metadata, encryption markers, upload cleanup and closure of all part readers are checked.
Baseline before streaming completion, September 21, 2026; all eight small/large scenarios passed:
| Path | Logical content writes, excluding metadata | Content reads after upload |
|---|---|---|
| New PUT | Plaintext staging N + encrypted output C | Staging N |
| Complete new multipart | Plaintext assembly N + staging N + encrypted output C | Parts N + assembly N + staging N |
| Complete over an existing object | Same as above, plus previous ciphertext backup B | Same as above, plus previous ciphertext B |
N is the payload size; C and B include the encryption format overhead. The staging write is evidenced by the file size snapshot and the copy path, not a write-syscall counter. These totals are logical file traffic, not physical device traffic or IOPS. Uploading the parts adds another N of writes before completion.
For the 40 MiB new multipart case, completion read 40 MiB from each of the three sources and wrote a 40 MiB assembly, a 40 MiB staging file and 41,955,852 encrypted bytes: approximately 120 MiB read and 120 MiB written, excluding uploaded parts, sidecars and WAL. The overwrite added another 41,955,852-byte backup.
The two-part completion held two part readers open at once; the eight-part completion
held eight. combineMultipartParts opens all requested parts before consuming them.
This scales descriptor usage with part count, rather than keeping one part open;
descriptor exhaustion was not tested. Completion also fetched each part's metadata
twice, once for validation and once for the multipart ETag (4 and 16 lookups).
Streaming Completion, September 22, 2026¶
Completion now encrypts sequential part readers directly into one backend publication. The same four large scenarios passed with one encrypted write, zero assembly reads, zero staging bytes and at most one open part reader. The 40 MiB case read 41,943,040 bytes from parts and wrote 41,955,852 encrypted bytes. Encryption consumes that same part stream; its input counter is not an additional disk read.
Completion therefore requires N bytes read and C bytes written for a new object. Including the initial part uploads gives approximately 3N of logical content traffic, excluding metadata, encryption overhead and replication. This is byte accounting, not a throughput forecast or a test with a 1 TB object.
Overwriting still reads and writes the previous ciphertext for rollback. Ordinary PUT staging is unchanged, as are the two metadata lookups per completed part. Tests verify plaintext MD5, multipart ETag, metadata, cleanup, failure rollback and retry, including cancellation and part open/read/close failures. The producer is joined before completion returns, including when storage rejects the write early.
Encrypted PUT Staging, November 8, 2026¶
A PUT is encrypted as it is received, with the key it is stored with, so its staging file holds ciphertext; the content's MD5 and checksum are taken on the way. The staging file is then published as it is, without a second encryption pass: a new PUT writes C to staging and C to the object and reads C, where it wrote N, read N and wrote C before. Memory stays that of one 64 KiB chunk and a 256 KiB write buffer, whatever the size.
BenchmarkPutObject in internal/object/put_bench_test.go measures a PUT
through the object manager, its rate and the most memory its heap objects held
while it ran, beyond what they held before; the content comes from a 1 MiB
block given over and over, never held whole:
On a Windows 11 laptop (8 threads, NVMe), three runs of eight PUTs each:
| Size | Before (MB/s) | After (MB/s) | Peak heap before → after |
|---|---|---|---|
| 1 MiB | 52–57 | 58–60 | 2.8–3.4 → 3.7–4.0 MiB |
| 64 MiB | 160–177 | 196–221 | 3.4–4.2 → 4.1–4.8 MiB |
| 256 MiB | 156–158 | 202–210 | 3.4–4.5 → 5.1–5.3 MiB |
The rate counts the server's work only: the content comes from memory, not from a network.
See also: ARCHITECTURE.md · OPERATIONS.md · TESTING.md