Skip to content

A periodic snapshot cache that's seriously fast and scalable

License

Notifications You must be signed in to change notification settings

xsawyerx/melian

Repository files navigation

Version

GitHub Actions Workflow Status Issues License

C Python Javscript PHP Perl Raku

MySQL MariaDB PostgreSQL SQLite

Melian

Melian is an in-memory table cache engineered for ultra-low latency reads and predictable performance under load, written in optimized C.

Instead of caching arbitrary keys and pushing invalidation logic into your application, Melian materializes full or partial database tables into coherent in-memory snapshots and refreshes them on a schedule. Snapshots are swapped atomically: readers never observe half-updated data.

Performance (vs. Redis)

These were run against a MacBook Pro M3 (36GB mem).

  • Melian plateaus at ~580k RPS with p99 ≤ 256 µs
  • Redis reaches ~300k RPS with p99 up to 1024 µs
  • Melian has ~2× higher peak throughput and 2–4× lower p99 latency at all concurrency levels
  • Stable throughput plateau from 16–256 connections
Concurrency | Melian RPS | Redis RPS | Melian p99 | Redis p99
------------+------------+-----------+------------+-----------
1           | 157k       | 97k       | 8 µs       | 16 µs
16          | 564k       | 265k      | 32 µs      | 64 µs
32          | 586k       | 285k      | 32 µs      | 128 µs
64          | 578k       | 297k      | 128 µs     | 256 µs
256         | 578k       | 309k      | 256 µs     | 1024 µs

Analyzed:

Concurrency | Throughput (Melian > Redis) | Tail latency (p99)
------------+------------------------------+--------------------
1           | 1.6×                         | 2× lower
16          | 2.1×                         | 2× lower
32          | 2.1×                         | 4× lower
64          | 1.9×                         | 2× lower
256         | 1.9×                         | 4× lower

Features

  • Blazing fast lookups: Sub-millisecond query latency, zero-copy networking, low CPU use, stable memory.
  • Concurrency: Lock-free reads, atomic data swap on reload.
  • Periodic refresh: automatic data reload via a background thread, not event-driven.
  • Data model: Full- or partial-table snapshots, but not individual keys.
  • Consistency: Always serves complete, coherent snapshots - no half-updated data.
  • Dual-key indexing: look up entries by numeric or string key.
  • Clients in Node.js, Python, C, Perl, PHP, and Raku.
  • Runtime performance statistics: query table size, min/max ID, and memory usage.
  • Binary row payloads: length-prefixed field name/type/value encoding for fast decode and byte-accurate values.

Why

Most applications just need specific tables to always be in memory for fast reads.

Traditional caches (Redis, Memcached) require your app to manage keys manually (and often individually) and synchronize them with the database. That adds complexity and risks serving stale or inconsistent data. Being flexible to these changes also forces them to perform slower than they could.

Should I or Shouldn't I?

Melian is a good fit when:

  • You have one or more tables in PostgreSQL/MySQL/MariaDB/SQLite
  • Those tables change periodically (minute/hour/day/...)
  • You want them always in memory for very fast reads
  • You care about snapshot consistency (no partial refresh states)

Typical examples:

  • Reference tables (countries, currencies, plans, services, permissions).
  • Host or customer routing maps.
  • User or organizational metadata used across many services.
  • Materialized data sets refreshed periodically.
  • Read-mostly microservices.

Melian is not a good fit when:

  • You need instant propagation of updates (write-through caching)
  • You need arbitrary key/value caching not expressible as a table query
  • You want clustering/replication built in
  • You need to mutate cache state frequently

Authors

  • Gonzalo "Gonzo" Diethelm (@gonzus)
  • Sawyer X (@xsawyerx)

Building

# add flags when deps are not in the default paths
$ ./configure --with-mysql=/path/to/mysql \        # enable MySQL/MariaDB support
               --with-postgresql=/path/to/pgsql \  # enable PostgreSQL support
               --with-sqlite3=/path/to/sqlite \    # enable SQLite support
               --with-libevent=/path/to/libevent \
               --with-jansson=/path/to/jansson
$ make
$ make install

The configure step fails explicitly if the required headers/libraries cannot be located. At least one database backend (PostgreSQL, MariaDB/MySQL, or SQLite) must be available, configure stops early otherwise. Pass whichever --with-* flags match the drivers you intend to compile in.

Running

  1. Start the server
# Run listening on a UNIX socket (default)
$ ./melian-server

# Run listening on a TCP socket only
$ MELIAN_SOCKET_PATH= MELIAN_SOCKET_PORT=42123 ./melian-server

# Run listening on both UNIX and TCP simultaneously
$ MELIAN_SOCKET_PORT=42123 ./melian-server

# Display server options
$ ./melian-server --help

# Display server version
$ ./melian-server --version

Set the database driver explicitly and adjust shared settings via config file or environment variables.

Configuration file

{
    "database": {
        "driver": "sqlite",
        "name": "melian",
        "host": "localhost",
        "port": 42123,
        "username": "melian",
        "password": "melian",
        "sqlite": {
            "filename": "/var/lib/melian.db"
        }
    },
    "socket": {
        "path": "/tmp/melian.sock",
        "host": "127.0.0.1",
        "port": 42123
    },
    "table": {
        "period": 60,
        "selects": {
            "table1": "SELECT id, email FROM table1",
            "table2": "SELECT id, hostname FROM table2 WHERE active = 1"
        }
    },
    "tables": [
        {
            "name": "table1",
            "id": 0,
            "period": 60,
            "indexes": [
                {
                    "id": 0,
                    "column": "id",
                    "type": "int"
                }
            ]
        },
        {
            "name": "table2",
            "id": 1,
            "period": 60,
            "indexes": [
                {
                    "id": 0,
                    "column": "id",
                    "type": "int"
                },
                {
                    "id": 1,
                    "column": "hostname",
                    "type": "string"
                }
            ]
        }
    ]
}

You can store the entire configuration in a JSON file and tell the server to load it at startup:

$ ./melian-server -c /path/to/melian-config.json
# or
$ ./melian-server --configfile /path/to/melian-config.json

Configuration sources are consulted in this order:

  1. Command-line -c/--configfile.
  2. Environment variable MELIAN_CONFIG_FILE.
  3. Default /etc/melian.json.

Any values from the configuration file are still overridable by environment variables so you can keep secrets out of the file (e.g., inject MELIAN_DB_PASSWORD at runtime while other settings live in JSON).

Environment variables

These will override any values in the config file.

  • MELIAN_DB_DRIVER (config: database.driver): mysql, postgresql, or sqlite (required)
  • MELIAN_DB_HOST (config: database.host): database host (default 127.0.0.1)
  • MELIAN_DB_PORT (config: database.port): database port (default 3306)
  • MELIAN_DB_NAME (config: database.name): database/schema name (default melian)
  • MELIAN_DB_USER (config: database.username): username (default melian)
  • MELIAN_DB_PASSWORD (config: database.password): password (default meliansecret)
  • MELIAN_SQLITE_FILENAME (config: database.sqlite.filename): SQLite database filename (default /etc/melian.db)
  • MELIAN_SOCKET_HOST (config: socket.host): TCP bind address (default 127.0.0.1)
  • MELIAN_SOCKET_PORT (config: socket.port): TCP port -- 0 to disable (default 0)
  • MELIAN_SOCKET_PATH (config: socket.path): UNIX socket path -- empty to disable (default /tmp/melian.sock)

Both UNIX and TCP listeners can be active simultaneously. By default only the UNIX socket is enabled. Set MELIAN_SOCKET_PORT to a non-zero value to also enable TCP.

  • MELIAN_SERVER_TOKENS (config: server.tokens): whether to advertise the server version in status JSON (default true)
  • MELIAN_TABLE_PERIOD (config: table.period): 60 seconds (reload interval)
  • MELIAN_TABLE_SELECTS (config: table.selects): semicolon-separated overrides (table=SELECT ...;table2=SELECT ...) to customize per-table SELECT statements
  • MELIAN_TABLE_TABLES (config: tables): table1,table2

When using MELIAN_TABLE_SELECTS, ensure each entry follows table_name=SELECT ... and separate multiple entries with ;. The SQL is used verbatim, so double-check statements for the intended tables.

In JSON, use table.selects with a mapping of table names to their statements:

"table": {
  "selects": {
    "table1": "SELECT ...",
    "table2": "SELECT ..."
  }
}

Versioning

The server version is compiled in protocol.h as MELIAN_SERVER_VERSION. Use --version to print it. If you want to hide the version from the status JSON, set MELIAN_SERVER_TOKENS=false or server.tokens: false in the config file.

  1. Use the test client

Ths describes the test client we have in C.

# Connect to a UNIX socket
$ ./melian-client -u /tmp/melian.sock

# Connect to a TCP socket (start server with TCP enabled)
$ MELIAN_SOCKET_PORT=42123 ./melian-server
$ ./melian-client -p 42123

# Display client options
$ ./melian-client -?
$ ./melian-client -h

Subcommands

  • fetch: Fetch a single row (see Ad-hoc querying)
  • schema: Show the server schema as JSON
  • stats: Show server statistics as JSON

Benchmark options

When no subcommand is given, the client runs in benchmark mode:

  • -U: Benchmark table1 by ID
  • -C: Benchmark table2 by ID
  • -H: Benchmark table2 by hostname
  • -s: Print server statistics
  • -q: Quit the server
  • -v: Verbose logging

Ad-hoc querying

Melian uses a binary protocol, so curl won't work. The C client supports subcommands for quick, ad-hoc queries against a running server.

All examples below assume a UNIX socket at /tmp/melian.sock. For TCP, replace -u /tmp/melian.sock with -p PORT (and optionally -h HOST).

Describe schema (discover tables and indexes):

./melian-client -u /tmp/melian.sock schema

Fetch a row by integer key (table table1, index id, key 42):

./melian-client -u /tmp/melian.sock fetch --table table1 --index id --key 42

Fetch a row by string key (table table2, index hostname, key host-00002):

./melian-client -u /tmp/melian.sock fetch --table table2 --index hostname --key host-00002

Mix names and IDs freely (table by ID, index by name):

./melian-client -u /tmp/melian.sock fetch --table-id 1 --index hostname --key host-00002

Server statistics:

./melian-client -u /tmp/melian.sock stats

The key type (integer or string) is detected automatically from the schema - no need to specify it. Output is JSON, suitable for piping through jq.

Docker images

The provided Dockerfile builds a self-contained image (SQLite + bundled clients). Build it locally:

docker build -t melian:latest .

With UNIX socket (default)

This keeps the UNIX domain socket enabled and bind-mounts it to the host so native clients can connect:

mkdir -p $(pwd)/socket
docker run --rm \
  -p 42123:42123 \
  -v $(pwd)/socket:/run/melian \
  melian:latest

The server listens on /run/melian/melian.sock inside the container. On the host you’ll find the mirrored socket at ./socket/melian.sock.

Both UNIX and TCP

Enable both listeners by setting a port while keeping the default socket path:

docker run --rm \
  -v $(pwd)/socket:/run/melian \
  -e MELIAN_SOCKET_HOST=0.0.0.0 \
  -e MELIAN_SOCKET_PORT=42123 \
  -p 42123:42123 \
  melian:latest

TCP only (disable UNIX socket)

To accept only TCP connections, unset MELIAN_SOCKET_PATH and map the port:

docker run --rm \
  -e MELIAN_SOCKET_PATH= \
  -e MELIAN_SOCKET_HOST=0.0.0.0 \
  -e MELIAN_SOCKET_PORT=42123 \
  -p 42123:42123 \
  melian:latest

Clients can then connect to tcp://localhost:42123.

Security

Unix socket (default)

The default Unix socket is created with mode 0660 (owner + group read/write). Only processes running as the same user or group can connect. No additional configuration is needed.

Network restrictions (TCP)

When exposing Melian over TCP, bind to a specific interface rather than 0.0.0.0 and use firewall rules to restrict access to trusted hosts:

# Bind Melian to an internal interface
MELIAN_SOCKET_HOST=10.0.1.5 MELIAN_SOCKET_PORT=42123 ./melian-server
# Allow only your application subnet, drop everything else
iptables -A INPUT -p tcp --dport 42123 -s 10.0.1.0/24 -j ACCEPT
iptables -A INPUT -p tcp --dport 42123 -j DROP

This is handled at the kernel level with zero performance overhead.

Encryption (TLS via proxy)

Melian does not include built-in TLS. Its performance comes from zero-copy direct I/O on raw sockets. In-process TLS would add a 30-50% throughput penalty (the same cost Redis 6+ pays for native TLS).

For encrypted connections, run a TLS termination proxy in front of Melian. stunnel, HAProxy, and nginx all work. Example stunnel configuration:

[melian]
accept = 0.0.0.0:42123
connect = /tmp/melian.sock
cert = /etc/melian/server.pem
key = /etc/melian/server.key

Clients connect to tls://host:42123, stunnel decrypts and forwards to Melian's Unix socket. For containerized deployments, run the TLS proxy as a sidecar container alongside Melian.

About

A periodic snapshot cache that's seriously fast and scalable

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •