Multi-Backend Storage Performance

active intermediate

Multi-Backend Storage Performance

Hypothesis

We hypothesize that a workload-aware storage routing strategy that directs operations to the optimal backend based on access pattern can achieve 5x read throughput improvement and 3x write throughput improvement over single-backend deployment, while maintaining consistency guarantees through the platform's adapter contract system.

Background

The Prismatic Platform operates four distinct storage backends, each optimized for different access patterns: ETS (in-memory key-value with sub-microsecond reads), PostgreSQL (relational with ACID transactions and complex queries), Meilisearch (full-text search with typo tolerance), and KuzuDB (graph database for relationship traversal).

The storage architecture uses a trait-based adapter system (prismatic_storage_core) that defines common behaviors, with each backend implementing the PrismaticStorage.Adapter behaviour. The Sparkline contract system ensures interface compatibility across adapters. However, performance characteristics vary by orders of magnitude between backends depending on the operation type.

Currently, the platform uses a static mapping: each application configures its preferred backend at compile time. This means an application that primarily reads (benefiting from ETS) but occasionally needs full-text search (requiring Meilisearch) must either use a suboptimal single backend or manually manage multi-backend routing.

This experiment measures per-backend performance across standardized workloads and evaluates dynamic routing strategies that select the optimal backend per operation.

Methodology

We defined five workload profiles representing common platform access patterns:

  1. Read-Heavy (90% reads, 10% writes): Typical for agent state lookup and configuration retrieval
  2. Write-Heavy (10% reads, 90% writes): Typical for telemetry ingestion and event logging
  3. Mixed (50% reads, 50% writes): Typical for CRUD operations on entities
  4. Search (95% full-text queries, 5% writes): Typical for OSINT result exploration
  5. Graph Traversal (80% traversal queries, 20% writes): Typical for relationship analysis

Each workload was executed for 5 minutes at sustained load against each backend, measuring throughput (operations/second), latency (p50, p95, p99), and resource consumption (memory, CPU, disk I/O).

The dataset contained 1 million records with realistic field distributions matching the platform's entity schema (256-byte average record size with 3 indexed fields and 1 full-text field).

Setup

The storage router that directs operations to optimal backends:

defmodule PrismaticStorage.Router do
  @backend_capabilities %{
    ets: %{
      read_latency: :microseconds,
      write_latency: :microseconds,
      search: false,
      graph: false,
      persistence: :memory_only,
      max_dataset: :node_memory
    },
    postgresql: %{
      read_latency: :milliseconds,
      write_latency: :milliseconds,
      search: :basic,
      graph: false,
      persistence: :durable,
      max_dataset: :disk
    },
    meilisearch: %{
      read_latency: :milliseconds,
      write_latency: :tens_of_milliseconds,
      search: :advanced,
      graph: false,
      persistence: :durable,
      max_dataset: :memory_indexed
    },
    kuzudb: %{
      read_latency: :milliseconds,
      write_latency: :milliseconds,
      search: false,
      graph: true,
      persistence: :durable,
      max_dataset: :disk
    }
  }

  @spec route(atom(), map()) :: atom()
  def route(operation, context) do
    case operation do
      :read ->
        if context.requires_persistence do
          :postgresql
        else
          :ets
        end

      :write ->
        if context.requires_durability do
          :postgresql
        else
          :ets
        end

      :search ->
        if context.typo_tolerant or context.full_text do
          :meilisearch
        else
          :postgresql
        end

      :traverse ->
        :kuzudb

      :mixed ->
        select_by_workload_profile(context)
    end
  end

  defp select_by_workload_profile(context) do
    read_ratio = context.read_ratio || 0.5
    needs_search = context.needs_search || false
    needs_graph = context.needs_graph || false

    cond do
      needs_graph -> :kuzudb
      needs_search -> :meilisearch
      read_ratio > 0.8 -> :ets
      true -> :postgresql
    end
  end
end

The benchmark harness:

defmodule PrismaticStorage.Benchmark.Harness do
  @duration_ms 300_000
  @concurrency 50

  def run(backend, workload_profile) do
    dataset = generate_dataset(1_000_000)
    seed_backend(backend, dataset)

    operations = generate_operations(workload_profile, @duration_ms)

    start = System.monotonic_time(:millisecond)

    results =
      operations
      |> Task.async_stream(
        fn op -> execute_and_measure(backend, op) end,
        max_concurrency: @concurrency,
        timeout: 10_000
      )
      |> Enum.to_list()

    elapsed = System.monotonic_time(:millisecond) - start

    %{
      backend: backend,
      workload: workload_profile,
      throughput: length(results) / (elapsed / 1_000),
      latencies: extract_latencies(results),
      errors: count_errors(results),
      resource_usage: measure_resources(backend)
    }
  end
end

Results

Throughput (operations/second at 50 concurrent connections):

WorkloadETSPostgreSQLMeilisearchKuzuDBRouter
Read-Heavy847,00042,30012,1008,700762,000
Write-Heavy524,00018,7003,2006,100471,000
Mixed612,00028,4007,8007,200548,000
SearchN/A4,10018,400N/A17,200
GraphN/A1,200N/A14,30013,800

Latency at p99 (microseconds):

WorkloadETSPostgreSQLMeilisearchKuzuDBRouter
Read-Heavy124,20014,80021,40048
Write-Heavy288,70042,10018,20094
Mixed196,10028,40019,80067
SearchN/A48,2008,700N/A9,400
GraphN/A124,000N/A12,30013,100

Memory consumption (MB at 1M records):

BackendBaseAt 1M RecordsPer Record
ETS0312312 bytes
PostgreSQL128384256 bytes
Meilisearch2561,024768 bytes
KuzuDB64448384 bytes

Router improvement over best single backend:

WorkloadBest SingleRouterImprovement
Read-Heavy847K (ETS)762K-10.0%
Write-Heavy524K (ETS)471K-10.1%
Mixed612K (ETS)548K-10.5%
Search18.4K (Meili)17.2K-6.5%
Graph14.3K (Kuzu)13.8K-3.5%

Analysis

The results challenge our hypothesis in an instructive way. The Router does not achieve 5x improvement over single-backend deployment because ETS dominates most workloads by such a wide margin that routing overhead (the cost of the routing decision itself) reduces net throughput by 10%.

However, the Router provides massive improvement for workloads that no single backend handles well. For an application that needs both fast reads AND full-text search, no single backend is optimal. ETS cannot search, and Meilisearch reads are 1,200x slower than ETS. The Router achieves near-ETS read performance (762K ops/s) while also providing near-Meilisearch search performance (17.2K ops/s) by directing each operation to the correct backend.

ETS's dominance in throughput comes with a critical trade-off: no persistence. Applications requiring durability must use PostgreSQL, accepting a 20x throughput reduction for reads and a 28x reduction for writes. The Router intelligently applies this trade-off per operation rather than per application.

KuzuDB's graph traversal performance (14.3K ops/s at 12.3ms p99) is 11.9x better than PostgreSQL's recursive CTE approach (1.2K ops/s at 124ms p99) for the same graph queries. This validates KuzuDB's role as the dedicated graph backend.

Meilisearch's memory overhead (768 bytes per record vs ETS's 312 bytes) is the cost of maintaining full-text indexes with typo tolerance. For search-heavy workloads, this 2.5x memory premium delivers 4.5x better search throughput than PostgreSQL.

Conclusions

  1. ETS is the performance baseline -- nothing competes for in-memory key-value operations.
  2. The Router's value is multi-workload optimization, not single-workload improvement.
  3. Backend selection should be per-operation, not per-application.
  4. KuzuDB justifies its role with 12x graph traversal improvement over PostgreSQL.
  5. Meilisearch's memory cost is warranted for search-intensive applications.

Next Steps

  • Implement write-through caching (ETS front, PostgreSQL durable) for the common read-heavy + durable pattern
  • Benchmark at 10M and 100M records to find scaling inflection points
  • Test concurrent multi-backend writes with consistency reconciliation
  • Evaluate Redis as an intermediate caching layer between ETS and PostgreSQL
  • Measure cross-node ETS replication latency for distributed deployments

Connect & Contribute

Created by Tomáš Korcak (korczis) | Open Source under GHL