Skip to content

Repository files navigation

SQLiteTenancy

Multi-Tenant E-Commerce Platform — Rust + Actix-Web + SQLite (WAL) + Per-Tenant Micro-Batching

An Empirical Read/Write Benchmark for Per-Tenant SQLite Multi-Tenancy · 50 Concurrent Tenants · In-Memory Cache · Measured Resource Footprint

Rust Actix SQLite Cache Stores Reads Writes

Version: 2.0.0 · Measured: 2026-08-29 · Mode: ubuntu-docker-wal-per-tenant


Table of Contents

  1. Introduction
  2. Architecture
  3. Two Benchmark Suites
    • 3.1 Suite A — Original Mixed-Scenario Benchmark
    • 3.2 Suite B — Read/Write Split Benchmark
  4. Results — Suite A
  5. Results — Suite B
  6. Micro-Batching Deep Dive
  7. Cache Performance
  8. Resource Footprint
  9. Correctness & Isolation
  10. How to Re-produce
  11. Findings & Next Steps

1. Introduction

SQLiteTenancy is a multi-tenant e-commerce backend designed around a core principle:

One SQLite database per tenant, with a single serialized writer actor per store.

This guarantees strict tenant isolation (no noisy neighbors) while keeping the hot correctness path (checkout) fully serialized. A memory cache and per-tenant micro-batching amortize the read and write costs respectively.

This document measures the platform with two complementary benchmark suites and reports both performance and resource consumption.


2. Architecture

flowchart TB
    subgraph Client
      U[User / k6]
    end
    subgraph Docker["Ubuntu 24.04 Docker — sqlite-tenancy:ubuntu"]
      A[Actix-Web 4.x · 12 workers]
      C[(In-Memory LRU Cache<br/>99.4% hit, TTL 2s)]
      R[TenantRouter<br/>HashMap store_id → WriterHandle<br/>LRU 600s / max 100]
      subgraph Writer["Per-Tenant Writer Actor"]
        Q[mpsc 1024 · warm queue]
        T[Interval 8ms]
        F[flush_warm()<br/>single TX per batch]
        H[do_checkout()<br/>5-stmt TX, serialized]
      end
      DB[(SQLite per tenant<br/>WAL · NORMAL · FK=ON · 64MB cache)]
    end
    U --> A
    A --> C
    A --> R
    C -.->|read path| DB
    R --> Writer --> DB
    A -->|checkout| H
Loading
Component File Responsibility
TenantRouter src/tenant/router.rs Store → writer lookup, LRU eviction, WAL checkpoint
WriterHandle src/tenant/writer.rs mpsc queue, cart/checkout/restock operations
flush_warm src/tenant/writer.rs batches carts into a single TX (micro-batching)
do_checkout src/tenant/writer.rs hot path, 5-statement TX, stock check
MemoryCache src/cache.rs LRU + TTL cache for catalog reads

3. Two Benchmark Suites

The platform was measured with two suites that focus on complementary questions.

3.1 Suite A — Original Mixed-Scenario Benchmark

The first suite models a realistic e-commerce funnel (browse → cart → checkout → restock) across many concurrent tenants. Scenarios A–F probe reads, checkout, cart, flash sale, mixed load, and 100-tenant Zipfian traffic. Full report in BENCHMARK_REPORT_MEASURED.md.

3.2 Suite B — Read/Write Split Benchmark

The second suite isolates reads from writes to answer two independent questions:

  • How fast can we serve reads? (cache-heavy path)
  • How fast can we absorb writes? (per-tenant serialized path)

It runs 4 scenarios (benches/k6/rw/) under a 50-tenant footprint:

Scenario Script VUs Target Operation
R-S read_single.js 100 store_1 GET /products
R-A read_all.js 1000 50 stores (80/20) GET /products
W-S write_single.js 100 store_1 POST /cart
W-A write_all.js 1000 50 stores (80/20) POST /cart

Both suites run inside the app's Docker network using grafana/k6.


4. Results — Suite A

The original mixed benchmark (100 tenants, Ubuntu Docker). Summary of key scenarios:

Scenario Load Throughput Avg / p95 Result
A Catalog reads 50 VU / 15s 2,233 req/s 22.2ms / 28.7ms WAL non-blocking
C Cart (batched) 30 VU / 15s 3,612 req/s 8.2ms / 9.4ms Batching enabled
E Mixed realistic 27 VU / 30s 431 req/s 2.25ms / 8.77ms p99 ✅ <25ms
F 100-store Zipfian 82 VU / 120s ~770 req/s ~3–5ms Isolation holds
B Checkout 20 sequential ~300/s 13.7ms avg 5-stmt TX
D Flash sale 100 VU / 30s 22,945 req/s 2ms / 4.1ms 0 oversell

Suite A validated correctness and isolation (0 oversell across 229k attempts) but measured lower raw throughput because a fresh SQLite connection was opened per read.


5. Results — Suite B

After adding the in-memory cache (src/cache.rs), spawn_blocking for DB reads, WAL checkpointing, and raising container nofile, Suite B delivers the platform's true headroom.

5.1 Throughput

Scenario VUs Requests Throughput (req/s)
R-S Read single store 100 279,280 4,653
R-A Read all stores 1000 1,592,202 26,517
W-S Write single store 100 120,669 1,995
W-A Write all stores 1000 652,484 10,632

5.2 Latency

Scenario avg p90 p95 max
R-S 0.95ms 1.19ms 1.52ms 72ms
R-A 11.87ms 21.15ms 33.34ms 2.02s
W-S 29.25ms 12.04ms 96.66ms 1.75s
W-A 69.60ms 54.36ms 109.68ms 5.99s

5.3 Scaling

Reads (cache-backed):
R-S (100 VU)  ████████ 4,653 req/s
R-A (1000 VU) ██████████████████████████████████████████████████ 26,517 req/s   ← near-linear 5.7x

Writes (serialized per tenant):
W-S (100 VU)  ████████ 1,995 req/s
W-A (1000 VU) ██████████████████████████████████████████████████ 10,632 req/s   ← 5.3x

Reads scale near-linearly (cache absorbs load). Writes scale but pay per-store single-writer serialization at higher latency.

5.4 Write Failures

W-A reported 0.07% failed (468/652,484) — all InsufficientStock (cart quantity accumulated past the seeded prod_1 stock of 5000). Not an oversell; these are legitimate rejection responses.


6. Micro-Batching Deep Dive

The write path buffers cart upserts in an 8ms window and flushes them as a single SQLite transaction. Under load this amortizes one fsync across many rows.

Metric Suite A (Zipfian low load) Suite B (high per-store load)
total_flushes 16,727 103,512
avg_batch_size 1.02 7.46
batch_size_max 3 100
pending_peak 3 100
by_max (cap 100) 0 813
by_timer 99.7% 99.2%

Per-tenant breakdown (Suite B)

Store group Stores Flushes Warm enqueued avg_batch
Hot (1–10) 10 30,726 642,547 20.91
Cold (11–50) 40 72,786 130,138 1.79
Total 50 103,512 772,685 7.46

The single-store write test (store_1) reached avg_batch 34.30 with pending_peak 100 — empirical proof that micro-batching engages when a tenant is actually busy. The original avg_batch 1.02 was a load artifact (per-store rate too low under Zipfian), not a design flaw.


7. Cache Performance

The MemoryCache (LRU, TTL 2s, 5000 entries) serves catalog reads entirely from RAM.

Metric Value
entries 50
hits 1,860,728
misses 10,756
hit_rate_pct 99.43%

At 99.4% hit rate, the SQLite read path is effectively removed from the hot loop — the DB is nearly free under read load.


8. Resource Footprint

Measured on the host: AMD Ryzen 5 4600G (12 CPUs), 13 GB RAM, app in a Docker container with nofile=1048576, 12 Actix workers.

State CPU (avg / max) Memory (avg / max)
Idle 8.1% 216 MB
Read 1000 VU (26.8k req/s) 141.8% / 557.9% 233 MB / 249 MB
Write 1000 VU (17k req/s) 132.7% / 367.7% 223 MB / 228 MB

Interpretation

  • Memory is remarkably flat (~216–249 MB) across idle, read, and write states — the per-tenant SQLite files live on disk (WAL), and the cache holds only hot product listings. This makes the footprint predictably bounded regardless of load.
  • CPU scales with throughput: reads peak at ~5.6 cores (558%) during the 1000-VU burst; writes stay lower (~368%) because the single-writer serialization dominates wall-clock time.
  • Reads are cheaper per request than writes: 26.8k req/s at ~142% average CPU vs 17k req/s writes at ~133% — the cache keeps the read CPU mostly in serialization/network rather than disk I/O.

Network (cumulative across the read/write suite)

Direction Volume
Received ~797 MB
Sent ~2.03 GB

9. Correctness & Isolation

Property Suite A Suite B
Flash sale oversell 0 (50/50, 229k attempts) 0
Negative stock (any tenant) 0 0
Write failure (Suite B) 0.07% (legit InsufficientStock, not oversell)
Hot-store noise on cold-store none (store_1 686 vs store_38 51) none (99.4% cache hit)

Per-tenant single-writer serialization held at 1000 VU across 50 tenants.


10. How to Re-produce

# 1. Build & run the app (Dockerfile adds nofile via docker-compose ulimits)
docker compose build app
docker compose up -d app

# 2. Seed 50 stores (prod_1 stock 5000)
docker exec sqlite-tenancy sh -c 'for i in $(seq 1 50); do
  f=/app/data/store_$i.db
  [ -f "$f" ] && sqlite3 "$f" "DELETE FROM carts; UPDATE inventory SET stock=5000 WHERE product_id=\"prod_1\";"
done'

# 3. Benchmark Suite B (4 scenarios)
./bench.sh                    # all 4 (default 60s)
DURATION=20 SCOPE=read ./bench.sh     # shorter, read-only

# 4. Suite A (original mixed scenarios)
docker run --rm --network sqlite-tenancy_egyfy-net \
  -v $PWD/benches/k6:/scripts grafana/k6 run /scripts/stores_100_mixed.js

# 5. Stats
curl -s http://localhost:8080/stats | jq .

Artifacts: benches/k6/rw/*.js (Suite B), benches/k6/*.js (Suite A), src/cache.rs, src/tenant/writer.rs, docker-compose.yml (ulimits).


11. Findings & Next Steps

What works

  • Tenant isolation — per-file SQLite + single-writer actors; no noisy neighbors.
  • Correctness — 0 oversell, serialized checkout.
  • Read scaling — cache makes reads near-free (99.4% hit, 26.8k req/s).
  • Micro-batching — verifiably amortizes fsync under real per-store load (avg_batch 34.30).
  • Bounded memory — flat ~230 MB regardless of load.

To improve

  • Write latency (p95 97–110ms) — batch-window + queue wait at 1000 VU. Reduce BATCH_WINDOW_MS or scale write throughput with more per-tenant headroom.
  • Connection reuse — reads still open a connection on cache miss; a small pool (e.g. r2d2_sqlite) would harden the miss path.
  • WAL growth — checkpoint runs every 60s; a soak test (1h+) is needed to validate steady-state disk behavior.
  • Larger VU headroom — the container nofile was the original 1000-VU bottleneck; with it raised, the next ceiling is CPU (5.6 cores at read peak).

SQLiteTenancy — Multi-Tenant E-Commerce Platform · Rust + Actix-Web + SQLite (WAL)

Measured 2026-08-29 · 50 Tenants · Read/Write Split · In-Memory Cache · Resource Instrumented

About

Per-tenant SQLite (WAL) multi-tenant e-commerce platform — isolation, micro-batching, in-memory cache & resource footprint. Rust + Actix-Web + k6.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages