It was definitively a weird backend setup I had made but it just worked once set up so I didnt have to touch any of the frontend code.
Marmot started as a sidecar project using triggers and polling to replicate changes over NATS. It worked, but I hit a wall pretty fast. Most people really want full ACID compliance and DDL replication across the cluster. I realized the only clean way to do that was to expose SQLite over a standard protocol.
While projects like rqlite use REST and others go the page-capture route, I decided to implement the MySQL protocol instead. It just makes the most sense for compatibility.
Iβve reached a point where it works with WordPress, which theoretically covers a huge chunk of the web. There are scripts in the repo to deploy a WP cluster running on top of Marmot. Any DB change replicates across the whole cluster, so you can finally scale WordPress out properly.
On the performance side, Iβm seeing about 6K-7K inserts per second on my local machine with a 3-node quorum. It supports unix-sockets, and you can even have your processes read the SQLite DB file directly while routing writes through the MySQL interface. This gives you a lot of flexibility for read-heavy apps.
I know the "AI slop" label gets thrown around a lot lately, but Iβve been running this in production consistently. Itβs taken a massive amount of manual hours to get the behavior exactly where it needs to be.
Now I'm curious how sharding/routing is handled- which seems like the final piece of the puzzle for scaling writes.
https://github.com/synopse/mORMot2
FreePascal ORM, so in an adjacent space (ORM, can work with both SQLite and MySQL).
I guess DB devs really love marmots? :-))
The fact that youβve been running this with WP is also a really huge use case/demonstration of trust in your different software β IMO this should be on the README prominently.
These days I personally just ignore projects that insist on MySQL β Postgres has won in my mind and is the better choice. The only way Iβd run something like a WP hosting service is with a tool like Marmot.
One thing you might find interesting is trying marmot with something like Litestream v2 β marmot of course has its own replication system but I like the idea of having a backup system writing to s3. It seems trivial (as youβve noted that you can still work directly on the s3 file) but would be a nice blog post/experiment to see βworked outβ so to speak.(and probably wouldn't sink to the bottom of hn!)
- Ability to launch a replica on selected databases from main cluster.
- Ability for replica to only download and replicate changes of select databases (right now all or nothing).
- Ability for replica to proxy DML & DDL into write cluster transparently.
- Custom set of commands for replicas to download and attach/detach databases on the fly.
This will instantly solve 80% of the problems for most of consumption today. I will probably go after on demand page stream and other stuff once core features are done.
Not to mention this solves majority use-cases of lambdas. One can have a persistent main cluster, and then lambda's coming up or going down on demand transparently.
So umm, does that mean it sacrifices consistency?
Marmot v2 is a leaderless, distributed SQLite replication system built on a gossip-based protocol with distributed transactions and eventual consistency.
Key Features:
MySQL active-active requires careful setup of replication, conflict avoidance, and monitoring. Failover needs manual intervention. Split-brain scenarios demand operational expertise. This complexity doesn't scale to edge deployments.
Marmot excels at read-heavy edge scenarios:
| Use Case | How Marmot Helps |
|---|---|
| Distributed WordPress | Multi-region WordPress with replicated database |
| Lambda/Edge sidecars | Lightweight regional SQLite replicas, local reads |
| Edge vector databases | Distributed embeddings with local query |
| Regional config servers | Fast local config reads, replicated writes |
| Product catalogs | Geo-distributed catalog data, eventual sync |
# Start a single-node cluster
./marmot-v2
# Connect with MySQL client
mysql -h localhost -P 3306 -u root
# Or use DBeaver, MySQL Workbench, etc.
# Test DDL and DML replication across a 2-node cluster
./scripts/test-ddl-replication.sh
# This script will:
# 1. Start a 2-node cluster
# 2. Create a table on node 1 and verify it replicates to node 2
# 3. Insert data on node 1 and verify it replicates to node 2
# 4. Update data on node 2 and verify it replicates to node 1
# 5. Delete data on node 1 and verify it replicates to node 2
# Manual cluster testing
./examples/start-seed.sh # Start seed node (port 8081, mysql 3307)
./examples/join-cluster.sh 2 localhost:8081 # Join node 2 (port 8082, mysql 3308)
./examples/join-cluster.sh 3 localhost:8081 # Join node 3 (port 8083, mysql 3309)
# Connect to any node and run queries
mysql --protocol=TCP -h localhost -P 3307 -u root
mysql --protocol=TCP -h localhost -P 3308 -u root
# Cleanup
pkill -f marmot-v2
Marmot can run distributed WordPress with full database replication across nodes. Each WordPress instance connects to its local Marmot node, and all database changes replicate automatically.
Marmot implements MySQL functions required by WordPress:
| Category | Functions |
|---|---|
| Date/Time | NOW, CURDATE, DATE_FORMAT, UNIX_TIMESTAMP, DATEDIFF, YEAR, MONTH, DAY, etc. |
| String | CONCAT_WS, SUBSTRING_INDEX, FIND_IN_SET, LPAD, RPAD, etc. |
| Math/Hash | RAND, MD5, SHA1, SHA2, POW, etc. |
| DML | ON DUPLICATE KEY UPDATE (transformed to SQLite ON CONFLICT) |
cd examples/wordpress-cluster
./run.sh up
This starts:
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β WordPress-1 β β WordPress-2 β β WordPress-3 β
β :9101 β β :9102 β β :9103 β
ββββββββ¬βββββββ ββββββββ¬βββββββ ββββββββ¬βββββββ
βΌ βΌ βΌ
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Marmot-1 ββββ€ Marmot-2 ββββ€ Marmot-3 β
β MySQL: 9191 β β MySQL: 9192 β β MySQL: 9193 β
βββββββββββββββ βββββββββββββββ βββββββββββββββ
ββββββββββββββββ΄βββββββββββββββ
QUORUM Replication
Test it:
Commands:
./run.sh status # Check cluster health
./run.sh logs-m # Marmot logs only
./run.sh logs-wp # WordPress logs only
./run.sh down # Stop cluster
Marmot v2 uses a fundamentally different architecture from other SQLite replication solutions:
vs. rqlite/dqlite/LiteFS:
How It Works:
| Aspect | Marmot | MySQL Active-Active | rqlite/dqlite | TiDB |
|---|---|---|---|---|
| Leader | None | None (but complex) | Yes (Raft) | Yes (Raft) |
| Failover | Automatic | Manual intervention | Automatic | Automatic |
| Split-brain recovery | Automatic (anti-entropy) | Manual | N/A (leader-based) | N/A |
| Consistency | Tunable (ONE/QUORUM/ALL) | Serializable | Strong | Strong |
| Direct file read | β SQLite file | β | β | β |
| JS-safe AUTO_INCREMENT | β Compact mode (53-bit) | N/A | N/A | β 64-bit breaks JS |
| Edge-friendly | β Lightweight | β Heavy | β οΈ Moderate | β Heavy |
| Operational complexity | Low | High | Low | High |
Marmot v2 supports distributed DDL (Data Definition Language) replication without requiring master election:
Cluster-Wide Locking: Each DDL operation acquires a distributed lock per database (default: 30-second lease)
Automatic Idempotency: DDL statements are automatically rewritten for safe replay
CREATE TABLE users (id INT)
β CREATE TABLE IF NOT EXISTS users (id INT)
DROP TABLE users
β DROP TABLE IF EXISTS users
Schema Version Tracking: Each database maintains a schema version counter
Quorum-Based Replication: DDL replicates like DML through the same 2PC mechanism
[ddl]
# DDL lock lease duration (seconds)
lock_lease_seconds = 30
# Automatically rewrite DDL for idempotency
enable_idempotent = true
mydb.users instead of users)Marmot v2 uses Change Data Capture (CDC) for replication instead of SQL statement replay:
For UPDATE and DELETE operations, Marmot automatically extracts row keys:
Marmot can publish CDC events to external messaging systems, enabling real-time data pipelines, analytics, and event-driven architectures. Events follow the Debezium specification for maximum compatibility with existing CDC tooling.
[publisher]
enabled = true
[[publisher.sinks]]
name = "kafka-main"
type = "kafka" # "kafka" or "nats"
format = "debezium" # Debezium-compatible JSON format
brokers = ["localhost:9092"] # Kafka broker addresses
topic_prefix = "marmot.cdc" # Topics: {prefix}.{database}.{table}
filter_tables = ["*"] # Glob patterns (e.g., "users", "order_*")
filter_databases = ["*"] # Glob patterns (e.g., "prod_*")
batch_size = 100 # Events per poll cycle
poll_interval_ms = 10 # Polling interval
# NATS sink example
[[publisher.sinks]]
name = "nats-events"
type = "nats"
format = "debezium"
nats_url = "nats://localhost:4222"
topic_prefix = "marmot.cdc"
filter_tables = ["*"]
filter_databases = ["*"]
Events follow the Debezium envelope structure:
{
"schema": { ... },
"payload": {
"before": null,
"after": {"id": 1, "name": "alice", "email": "alice@example.com"},
"source": {
"version": "2.0.0",
"connector": "marmot",
"name": "marmot",
"ts_ms": 1702500000000,
"db": "myapp",
"table": "users"
},
"op": "c",
"ts_ms": 1702500000000
}
}
Operation Types (per Debezium spec):
| Operation | op |
before |
after |
|---|---|---|---|
| INSERT | c (create) |
null |
row data |
| UPDATE | u (update) |
old row | new row |
| DELETE | d (delete) |
old row | null |
Topics follow the pattern: {topic_prefix}.{database}.{table}
Examples:
marmot.cdc.myapp.usersmarmot.cdc.myapp.ordersmarmot.cdc.analytics.eventsFor more details, see the Integrations documentation.
Deploy Marmot as a lightweight regional replica alongside Lambda functions:
Scale reads globally with replica mode:
[replica]
enabled = true
master_address = "central-cluster:8080"
reconnect_interval_seconds = 5
Marmot supports a wide range of MySQL/SQLite statements through its MySQL protocol server. The following table shows compatibility for different statement types:
| Statement Type | Support | Replication | Notes |
|---|---|---|---|
| DML - Data Manipulation | |||
INSERT / REPLACE |
β Full | β Yes | Includes qualified table names (db.table) |
UPDATE |
β Full | β Yes | Includes qualified table names |
DELETE |
β Full | β Yes | Includes qualified table names |
SELECT |
β Full | N/A | Read operations |
LOAD DATA |
β Full | β Yes | Bulk data loading |
| DDL - Data Definition | |||
CREATE TABLE |
β Full | β Yes | Replicated with cluster-wide locking |
ALTER TABLE |
β Full | β Yes | Replicated with cluster-wide locking |
DROP TABLE |
β Full | β Yes | Replicated with cluster-wide locking |
TRUNCATE TABLE |
β Full | β Yes | |
RENAME TABLE |
β Full | β Yes | Replicated with cluster-wide locking |
CREATE/DROP INDEX |
β Full | β Yes | Replicated with cluster-wide locking |
CREATE/DROP VIEW |
β Full | β Yes | Replicated with cluster-wide locking |
CREATE/DROP TRIGGER |
β Full | β Yes | Replicated with cluster-wide locking |
| Database Management | |||
CREATE DATABASE |
β Full | β Yes | Replicated with cluster-wide locking |
DROP DATABASE |
β Full | β Yes | Replicated with cluster-wide locking |
ALTER DATABASE |
β Full | β Yes | Replicated with cluster-wide locking |
SHOW DATABASES |
β Full | N/A | Metadata query |
SHOW TABLES |
β Full | N/A | Metadata query |
USE database |
β Full | N/A | Session state |
| Transaction Control | |||
BEGIN / START TRANSACTION |
β Full | N/A | Transaction boundary |
COMMIT |
β Full | β Yes | Commits distributed transaction |
ROLLBACK |
β Full | β Yes | Aborts distributed transaction |
SAVEPOINT |
β Full | β Yes | Nested transaction support |
| Locking | |||
LOCK TABLES |
β Parsed | β No | Requires distributed locking coordination |
UNLOCK TABLES |
β Parsed | β No | Requires distributed locking coordination |
| Session Configuration | |||
SET statements |
β Parsed | β No | Session-local, not replicated |
| XA Transactions | |||
XA START/END/PREPARE |
β Parsed | β No | Marmot uses its own 2PC protocol |
XA COMMIT/ROLLBACK |
β Parsed | β No | Not compatible with Marmot's model |
| DCL - Data Control | |||
GRANT / REVOKE |
β Parsed | β No | User management not replicated |
CREATE/DROP USER |
β Parsed | β No | User management not replicated |
ALTER USER |
β Parsed | β No | User management not replicated |
| Administrative | |||
OPTIMIZE TABLE |
β Parsed | β No | Node-local administrative command |
REPAIR TABLE |
β Parsed | β No | Node-local administrative command |
Schema Changes (DDL): DDL statements are fully replicated with cluster-wide locking and automatic idempotency. See the DDL Replication section for details.
XA Transactions: Marmot has its own distributed transaction protocol based on 2PC. MySQL XA transactions are not compatible with Marmot's replication model.
User Management (DCL): User and privilege management statements are local to each node. For production deployments, consider handling authentication at the application or proxy level.
Table Locking: LOCK TABLES statements are recognized but not enforced across the cluster. Use application-level coordination for distributed locking needs.
Qualified Names: Marmot fully supports qualified table names (e.g., db.table) in DML and DDL operations.
Marmot includes a MySQL-compatible protocol server, allowing you to connect using any MySQL client (DBeaver, MySQL Workbench, mysql CLI, etc.). The server supports:
Marmot provides full support for MySQL metadata queries, enabling GUI tools like DBeaver to browse databases, tables, and columns:
SHOW DATABASES, SHOW TABLES, SHOW COLUMNS FROM table, SHOW CREATE TABLE, SHOW INDEXESINFORMATION_SCHEMA.TABLES, INFORMATION_SCHEMA.COLUMNS, INFORMATION_SCHEMA.SCHEMATA, and INFORMATION_SCHEMA.STATISTICSThese metadata queries are powered by the rqlite/sql AST parser, providing production-grade MySQL query compatibility.
# Using mysql CLI
mysql -h localhost -P 3306 -u root
# Connection string for applications
mysql://root@localhost:3306/marmot
Marmot handles various failure and recovery scenarios automatically:
| Scenario | Behavior |
|---|---|
| Minority partition | Writes fail - cannot achieve quorum |
| Majority partition | Writes succeed - quorum achieved |
| Partition heals | Delta sync + LWW merges divergent data |
How it works:
StreamChanges RPC| Scenario | Recovery Method |
|---|---|
| Brief outage | Delta sync - replay missed transactions |
| Extended outage | Snapshot transfer + delta sync |
| New node joining | Full snapshot from existing node |
Anti-Entropy Background Process:
Marmot v2 includes an automatic anti-entropy system that continuously monitors and repairs replication lag across the cluster:
Delta Sync Process:
last_applied_txn_id for each peer/databaseStreamChanges RPCGC Coordination with Anti-Entropy:
gc_min >= delta_threshold and gc_max >= 2x delta_threshold| Write Consistency | Behavior |
|---|---|
ONE |
Returns after 1 node ACK (fast, less durable) |
QUORUM |
Returns after majority ACK (default, balanced) |
ALL |
Returns after all nodes ACK (slow, most durable) |
Conflict Resolution:
SERIALIZABLE transaction assumptions may not hold across nodes.Distributed databases need globally unique IDs, but traditional solutions cause problems:
| Solution | Issue |
|---|---|
| UUID | 128-bit, poor index performance, not sortable |
| Snowflake/HLC 64-bit | Exceeds JavaScript's Number.MAX_SAFE_INTEGER (2^53-1) |
| TiDB AUTO_INCREMENT | Returns 64-bit IDs that break JavaScript clients silently |
The JavaScript Problem:
// 64-bit ID from TiDB or other distributed DBs
const id = 7318624812345678901;
console.log(id); // 7318624812345679000 - WRONG! Precision lost!
// JSON parsing also breaks
JSON.parse('{"id": 7318624812345678901}'); // {id: 7318624812345679000}
TiDB's answer? "Use strings." But that breaks ORMs, existing application code, and type safety.
Marmot offers two ID generation modes to solve this:
| Mode | Bits | Range | Use Case |
|---|---|---|---|
extended |
64-bit | Full HLC timestamp | New systems, non-JS clients |
compact |
53-bit | JS-safe integers | Legacy systems, JavaScript, REST APIs |
[mysql]
auto_id_mode = "compact" # Safe for JavaScript (default)
# auto_id_mode = "extended" # Full 64-bit for new systems
Compact Mode Guarantees:
Number.MAX_SAFE_INTEGER (9,007,199,254,740,991)With Marmot compact mode:
const id = 4503599627370496;
console.log(id); // 4503599627370496 - Correct!
JSON.parse('{"id": 4503599627370496}'); // {id: 4503599627370496} - Correct!
Note: Marmot automatically converts
INT AUTO_INCREMENTtoBIGINTto support distributed ID generation.
DDL Transformation: When you create a table with AUTO_INCREMENT:
CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100))
-- Becomes internally:
CREATE TABLE users (id BIGINT PRIMARY KEY, name TEXT)
DML ID Injection: When inserting with 0 or NULL for an auto-increment column:
INSERT INTO users (id, name) VALUES (0, 'alice')
-- Becomes internally (compact mode):
INSERT INTO users (id, name) VALUES (4503599627370496, 'alice')
Explicit IDs Preserved: If you provide an explicit non-zero ID, it is used as-is.
Schema-Based Detection:
Marmot automatically detects auto-increment columns by querying SQLite schema directly:
INTEGER PRIMARY KEY (SQLite rowid alias)BIGINT PRIMARY KEY (Marmot's transformed columns)No registration required - columns are detected from schema at runtime, works across restarts, and works with existing databases.
Marmot v2 uses a TOML configuration file (default: config.toml). All settings have sensible defaults.
node_id = 0 # 0 = auto-generate
data_dir = "./marmot-data"
[transaction]
heartbeat_timeout_seconds = 10 # Transaction timeout without heartbeat
conflict_window_seconds = 10 # Conflict resolution window
lock_wait_timeout_seconds = 50 # Lock wait timeout (MySQL: innodb_lock_wait_timeout)
Note: Transaction log garbage collection is managed by the replication configuration to coordinate with anti-entropy. See replication.gc_min_retention_hours and replication.gc_max_retention_hours.
[connection_pool]
pool_size = 4 # Number of SQLite connections
max_idle_time_seconds = 10 # Max idle time before closing
max_lifetime_seconds = 300 # Max connection lifetime (0 = unlimited)
[grpc_client]
keepalive_time_seconds = 10 # Keepalive ping interval
keepalive_timeout_seconds = 3 # Keepalive ping timeout
max_retries = 3 # Max retry attempts
retry_backoff_ms = 100 # Retry backoff duration
[coordinator]
prepare_timeout_ms = 2000 # Prepare phase timeout
commit_timeout_ms = 2000 # Commit phase timeout
abort_timeout_ms = 2000 # Abort phase timeout
[cluster]
grpc_bind_address = "0.0.0.0"
grpc_port = 8080
seed_nodes = [] # List of seed node addresses
cluster_secret = "" # PSK for cluster authentication (see Security section)
gossip_interval_ms = 1000 # Gossip interval
gossip_fanout = 3 # Number of peers to gossip to
suspect_timeout_ms = 5000 # Suspect timeout
dead_timeout_ms = 10000 # Dead timeout
Marmot supports Pre-Shared Key (PSK) authentication for cluster communication. This is strongly recommended for production deployments.
[cluster]
# All nodes in the cluster must use the same secret
cluster_secret = "your-secret-key-here"
Environment Variable (Recommended):
For production, use the environment variable to avoid storing secrets in config files:
export MARMOT_CLUSTER_SECRET="your-secret-key-here"
./marmot
The environment variable takes precedence over the config file.
Generating a Secret:
# Generate a secure random secret
openssl rand -base64 32
Behavior:
cluster_secret is empty and MARMOT_CLUSTER_SECRET is not set, authentication is disabledMarmot provides admin HTTP endpoints for managing cluster membership (requires cluster_secret to be configured):
Node Lifecycle:
# View cluster members and quorum info
curl -H "X-Marmot-Secret: your-secret" http://localhost:8080/admin/cluster/members
# Remove a node from the cluster (excludes from quorum, blocks auto-rejoin)
curl -X POST -H "X-Marmot-Secret: your-secret" http://localhost:8080/admin/cluster/remove/2
# Allow a removed node to rejoin (node must then restart to join)
curl -X POST -H "X-Marmot-Secret: your-secret" http://localhost:8080/admin/cluster/allow/2
See the Operations documentation for detailed usage and examples.
For read-only replicas that follow a master node without participating in the cluster:
[replica]
enabled = true # Enable read-only replica mode
master_address = "master:8080" # Master node gRPC address
reconnect_interval_seconds = 5 # Reconnect delay on disconnect
Note: Replica mode is mutually exclusive with cluster mode. A replica receives all data via streaming replication but cannot accept writes.
[replication]
default_write_consistency = "QUORUM" # Write consistency level: ONE, QUORUM, ALL
default_read_consistency = "LOCAL_ONE" # Read consistency level
write_timeout_ms = 5000 # Write operation timeout
read_timeout_ms = 2000 # Read operation timeout
# Anti-Entropy: Background healing for eventual consistency
# - Detects and repairs divergence between replicas
# - Uses delta sync for small lags, snapshot for large lags
# - Includes gap detection to prevent incomplete data after GC
enable_anti_entropy = true # Enable automatic catch-up for lagging nodes
anti_entropy_interval_seconds = 60 # How often to check for lag (default: 60s)
delta_sync_threshold_transactions = 10000 # Delta sync if lag < 10K txns
delta_sync_threshold_seconds = 3600 # Snapshot if lag > 1 hour
# Garbage Collection: Reclaim disk space by deleting old transaction records
# - gc_min must be >= delta_sync_threshold (validated at startup)
# - gc_max should be >= 2x delta_sync_threshold (recommended)
# - Set gc_max = 0 for unlimited retention
gc_min_retention_hours = 2 # Keep at least 2 hours (>= 1 hour delta threshold)
gc_max_retention_hours = 24 # Force delete after 24 hours
Anti-Entropy Tuning:
delta_sync_threshold_transactions to 50000+gc_max_retention_hours at 24+ to handle extended outagesGC Configuration Rules (Validated at Startup):
gc_min_retention_hours must be >= delta_sync_threshold_seconds (in hours)gc_max_retention_hours should be >= 2x delta_sync_threshold_seconds[query_pipeline]
transpiler_cache_size = 10000 # LRU cache for MySQLβSQLite transpilation
validator_pool_size = 8 # SQLite connection pool for validation
[mysql]
enabled = true
bind_address = "0.0.0.0"
port = 3306
max_connections = 1000
unix_socket = "" # Unix socket path (empty = disabled)
unix_socket_perm = 0660 # Socket file permissions
auto_id_mode = "compact" # "compact" (53-bit, JS-safe) or "extended" (64-bit)
Unix Socket Connection (lower latency than TCP):
mysql --socket=/tmp/marmot/mysql.sock -u root
[publisher]
enabled = false # Enable CDC publishing to external systems
[[publisher.sinks]]
name = "kafka-main" # Unique sink name
type = "kafka" # "kafka" or "nats"
format = "debezium" # Debezium-compatible JSON (only option)
brokers = ["localhost:9092"] # Kafka broker addresses
topic_prefix = "marmot.cdc" # Topic pattern: {prefix}.{db}.{table}
filter_tables = ["*"] # Glob patterns for table filtering
filter_databases = ["*"] # Glob patterns for database filtering
batch_size = 100 # Events to read per poll cycle
poll_interval_ms = 10 # Polling interval (default: 10ms)
retry_initial_ms = 100 # Initial retry delay on failure
retry_max_ms = 30000 # Max retry delay (30 seconds)
retry_multiplier = 2.0 # Exponential backoff multiplier
See the Integrations documentation for details on event format, Kafka/NATS configuration, and use cases.
[logging]
verbose = false # Enable verbose logging
format = "console" # Log format: console or json
[prometheus]
enabled = true # Metrics served on gRPC port at /metrics endpoint
Accessing Metrics:
# Metrics are multiplexed with gRPC on the same port
curl http://localhost:8080/metrics
# Prometheus scrape config
scrape_configs:
- job_name: 'marmot'
static_configs:
- targets: ['node1:8080', 'node2:8080', 'node3:8080']
See config.toml for complete configuration reference with detailed comments.
Performance benchmarks on a local development machine (Apple M-series, 3-node cluster, single machine):
| Parameter | Value |
|---|---|
| Nodes | 3 (ports 3307, 3308, 3309) |
| Threads | 16 |
| Batch Size | 10 ops/transaction |
| Consistency | QUORUM |
| Metric | Value |
|---|---|
| Throughput | 4,175 ops/sec |
| TX Throughput | 417 tx/sec |
| Records Loaded | 200,000 |
| Errors | 0 |
| Metric | Value |
|---|---|
| Throughput | 3,370 ops/sec |
| TX Throughput | 337 tx/sec |
| Duration | 120 seconds |
| Total Operations | 404,930 |
| Errors | 0 |
| Retries | 37 (0.09%) |
Operation Distribution:
| Percentile | Latency |
|---|---|
| P50 | 4.3ms |
| P90 | 14.0ms |
| P95 | 36.8ms |
| P99 | 85.1ms |
All 3 nodes maintained identical row counts (346,684 rows) throughout the test, confirming consistent replication.
Note: These benchmarks are from a local development machine with all nodes on the same host. Production deployments across multiple machines will have different characteristics based on network latency. Expect P99 latencies of 50-200ms for cross-region QUORUM writes.
Marmot's SQLite files are standard WAL-mode databases, compatible with Litestream:
litestream replicate /path/to/marmot-data/*.db s3://bucket/backup
Enable CDC publisher to stream changes to Kafka/NATS, then archive to your preferred storage.
Since Marmot uses SQLite with WAL mode, you can safely snapshot the data directory during operation.
- DDL gets really tricky in these cases, that's why you see Corrosion has this weird file based system. - cr-sqlite ain't maintained anymore but I did some benchmarks and if I remember correctly it was as slow as 4x-8x depending upon type of your data & load. Storage bloats by 2x-3x, tombstones accumulate pretty fast as well.
I mean each mutation on every column looks something like:
table, pk, cid, val, col_version, db_version, site_id, cl, seq
Overall I dropped the idea after spending month or two on it.
- Marmot can give you better easy DDL and better replication guarantees.
- You can control the guarantees around transactions. So if you're doing a quorum based transaction, you are guaranteed that quorum has written those set of rows before returning success. This takes care of those conflicting ID based rows getting overwritten that people would usually ignore. And you should be able to do transactions with proper begin and commit statements.
- Disk write amplification is way lower than what you would see in CRDT. This should usually mean that on a commodity hardware you should see better write throughput. As I mentioned on my local benchmarks I'm getting close to 6K insert ops. This was with a cluster of three nodes. So you can effectively multiply it by three and that is like 18k operations per second. I did not set up a full cluster to actually benchmark these. That requires investing more money and time. And I would be honestly frugal over here since I am spending all my $$$ on my AI bill.
- Reads as you can see, you can read directly from the SQLite database. So you are only bottlenecked by your disk speed. There are no fancy mergers that happen on CRDT level in the middle. It's written once and you're ready to read.
- The hardest part in my opinion that I faced was the auto increment IDs. It is a sad reality but turns out 99% of small to mid-size companies, are using the auto increment for IDs. In all CRDTs, in case of conflict, the LWW (based on one ID or another) happens, and I can guarantee you at some point in time without coordination, if nodes are just emitting those regular incrementing IDs, THEY WILL OVERWRITE each other. That was the exact problem in the first version of Marmot.
- SQLite is single writer database. cr-sqlite writes these delta CRDT rows in a table as well, under high write load you are putting too much pressure on WAL, how do I know? I did this in Marmot v0.x and even v2 started with that and eventually I decided to write logs in a SQLite database as well. Turns out at a high throughput even writing or dumping those logs that change logs that I'm gonna discard away is a bad idea. I eventually move to PebbleDB, with mimalloc based unmanaged memory allocator for serialization/deserialization (yes even that caused slowdowns due to GC). It doesn't stop here each row in CRDT entry is for one every column of table (changed column) + it has index for faster lookup. So there that will bog it down further on many many rows. For context I have tested Marmot on gigs of data not megs.
I do have couple of ideas on how I can really exploit the CRDT stuff, but I don't think I need it right now. I think most of stuff can be taken care of if I can build and MVCC layer on top.