Sizing Cassandra Clusters for JanusGraph
This procedure computes exactly how many Cassandra nodes, of what instance shape and heap, a JanusGraph deployment needs so the storage cluster holds the graph with room to compact instead of wedging at 90 percent disk mid-merge. It sits under the Storage Backend Selection and Sizing reference and narrows that decision to a single backend: turning measured element counts into a node count you can put in a capacity request and defend with nodetool output. The failure it prevents is the silent under-provision — a storage cluster sized from raw dataset bytes that runs fine in a demo, then hits a compaction storm at scale and cannot free space to complete it, taking every replica down together. Work the steps in order; each one has a verification command, and the byte estimates are corrected against a real sample load rather than trusted blind.
Prerequisites
Confirm each item before you provision anything — a node count derived from guessed byte sizes is worse than no estimate, because it looks authoritative.
- JanusGraph 0.6.x or 1.0.x targeting a CQL backend with Apache Cassandra 4.x (3.11 works, but 4.x streaming and compaction throughput change the per-node ceiling). If storage is not yet stood up, follow Cassandra Backend Setup first.
- Element counts for the target graph — vertex, edge, and property totals at the horizon you are sizing for (12–18 months out), not today’s numbers.
- A representative 1% sample of real data you can load to measure actual bytes per element; synthetic data with uniform property sizes will underestimate wide rows.
gremlinpythonmatching the server’s TinkerPop line, plusnodetoolaccess on every storage node.- A decided replication factor. Fix
RFand datacenter layout via Replication Strategies before sizing, because it is the multiplier on every byte.
Step 1 — Estimate the dataset size
Estimate the raw size one replica holds. Do not start from disk price or instance catalogs; start from the graph’s element counts and the bytes each element serializes to as a Cassandra row.
# raw single-replica bytes from element counts and measured per-element sizes
def raw_bytes(vertices, edges, properties, b_v=200, b_e=400, b_p=60):
# b_v/b_e/b_p default to conservative starting estimates in bytes;
# b_e counts both edge directions JanusGraph writes.
return vertices * b_v + edges * b_e + properties * b_p
# Worked example: 500M vertices, 2B edges, 1.5B properties
raw = raw_bytes(500_000_000, 2_000_000_000, 1_500_000_000)
print(f"raw single-replica: {raw / 2**40:.2f} TiB") # ~0.90 TiB
The worked example gives 100 GB + 800 GB + 90 GB = 990 GB ≈ 0.90 TiB for one replica. Treat the default b_v/b_e/b_p as placeholders until Step 4 replaces them with measured values.
Verify: the estimate is dominated by the term you expect (edges here); if a single term is more than ~80% of the total, re-check that count before trusting the size.
python3 -c "print('edges dominate' if 2_000_000_000*400 > 0.5*990_000_000_000 else 'check inputs')"
Step 2 — Compute per-node storage with RF and compaction headroom
Expand the single-replica size into the on-disk cluster requirement by applying the replication multiplier and compaction headroom, then divide by the usable disk one node offers.
def cluster_and_node_floor(raw, rf=3, h_c=0.5, disk_per_node=2 * 2**40, rho=0.65):
# rf copies, then compaction headroom (0.5 size-tiered / 0.2 leveled),
# then divide by usable disk (target utilization rho x raw disk per node).
cluster = raw * rf * (1 + h_c)
usable = rho * disk_per_node
node_floor = -(-int(cluster) // int(usable)) # ceil division
return cluster, node_floor
cluster, floor = cluster_and_node_floor(990_000_000_000)
print(f"cluster on-disk: {cluster / 2**40:.2f} TiB, node floor: {floor}")
# cluster on-disk: ~4.05 TiB, node floor: 4
With RF=3 and 50% compaction headroom, the 0.90 TiB replica becomes 0.90 × 3 × 1.5 ≈ 4.05 TiB on disk. Against 2 TB SSD nodes at 65% target utilization (≈1.3 TB usable), that is a floor of 4 nodes — but a floor is not the answer until Step 3 checks the per-node data ceiling.
Verify: confirm the projected per-node data sits under the ceiling and the target utilization on a running cluster.
nodetool status | awk '/^UN/ {print $2, $3}' # per-node Load; must stay < ~1.3 TB here
nodetool info | grep -E "Load|Heap Memory"
Step 3 — Pick node count, instance type, and heap
Turn the floor into a provisioning spec by reconciling three ceilings: disk (Step 2), per-node data volume, and memory. The largest constraint sets the real node count.
- Per-node data ceiling. Keep each node under ~1–2 TB of data so repair and streaming stay fast; the 4-node floor already respects this at ~1 TB/node, so disk is the binding constraint here.
- Instance type. Choose local NVMe SSD over network storage for compaction I/O, at least 8 vCPU, and RAM sized so the hot working set fits in page cache. For the ~1 TB/node example, 64 GB RAM leaves ~48 GB for page cache after a 16 GB heap.
- Heap. Pin Cassandra to an 8–16 GB heap with G1GC. Bigger is not better — a larger heap lengthens GC pauses and steals RAM from the page cache that actually serves reads.
# cassandra-env.sh / jvm-server.options — pin, do not autoscale the heap
-Xms16G
-Xmx16G
-XX:+UseG1GC
-XX:MaxGCPauseMillis=300
Round the node count up to keep replica placement balanced across racks — with RF=3, a node count that is a multiple of 3 (or evenly spread across 3 racks) keeps each rack holding one full replica set. Here 4 nodes works with NetworkTopologyStrategy, but many operators pick 6 for clean rack symmetry and headroom.
Verify: confirm the heap took and the page cache is doing the work (low read latency, high cache hit rate).
nodetool info | grep -E "Heap Memory|Off Heap"
nodetool tablestats janusgraph_prod | grep -E "read latency|bloom filter"
Step 4 — Validate with a load test and correct the estimates
Guessed byte sizes are the biggest error source, so load the 1% sample, measure real bytes per element, and re-run Steps 1–2 with the corrected numbers before committing to the full cluster.
import time
from gremlin_python.driver.driver_remote_connection import DriverRemoteConnection
from gremlin_python.process.anonymous_traversal import traversal
def load_sample(ws_endpoint, vertices=5_000_000, batch=1000):
conn = DriverRemoteConnection(ws_endpoint, "g")
g = traversal().withRemote(conn)
written = 0
for i in range(vertices // batch):
gtx = g.tx().begin()
for n in range(batch):
gtx.addV("entity").property("k", i * batch + n).property(
"payload", "x" * 48
).iterate()
gtx.tx().commit()
written += batch
if i % 100 == 0:
time.sleep(0.05) # let compaction keep pace during the sample load
conn.close()
return written
# load_sample("ws://cassandra-canary:8182/gremlin")
After the sample lands and a nodetool flush completes, read the actual on-disk size and back out the true bytes-per-element, then feed them into Step 1.
Verify: compute measured bytes per vertex from live table stats and compare against the b_v=200 assumption.
nodetool flush janusgraph_prod
nodetool tablestats janusgraph_prod \
| grep -E "Space used \(total\)|Number of partitions"
# measured_b_v = space_used_total / partitions ; if it exceeds 200, re-size upward
df -h /var/lib/cassandra/data # confirm disk headroom before scaling the load
If measured bytes exceed the estimate, raise b_v/b_e/b_p and re-derive node count — it is cheaper to add a node on paper now than to reshard a full cluster later.
Fallback and rollback procedures
Validate between actions instead of stacking changes, and keep the sample-load cluster disposable so a wrong estimate costs nothing.
If Step 1 or 2 produces an implausible node count. A count of 1–2 for a large graph usually means an input is off by an order of magnitude — recheck element counts and units (GB vs TiB) before provisioning. Never provision fewer nodes than RF.
If Step 3’s instance runs out of page cache under the sample load. Read latency climbs and disk IOPS spike. Move to a higher-RAM instance or add nodes to shrink per-node data — do not enlarge the heap to “use the RAM”, which lengthens GC pauses and starves the cache further.
If Step 4 shows measured bytes far above the estimate. Stop before scaling the load. Re-run Steps 1–2 with the measured b_* values, re-derive node count, and only then continue; loading the full graph onto an undersized cluster is the failure this procedure exists to prevent.
If a compaction storm fills disk during validation. Throttle ingestion, raise compaction_throughput_mb_per_sec, and if disk keeps climbing, drop the sample keyspace and re-provision with more headroom:
nodetool disableautocompaction janusgraph_prod # pause to let manual compaction catch up
nodetool compact janusgraph_prod
# if unrecoverable, tear down the disposable sample cluster and re-size before retrying
If the full rollout must be reverted. Because sizing changes are provisioning, not config, rollback means decommissioning added nodes cleanly with nodetool decommission one at a time so their data streams to peers before removal — never removenode a live node, which skips the stream and under-replicates.
Related
- Up a level: Storage Backend Selection and Sizing — the backend-selection and capacity reference this Cassandra-specific procedure implements.
- Choosing Cassandra, ScyllaDB, or Bigtable — the scored decision procedure for the backend you are sizing here.
- How to Configure Cassandra for JanusGraph Storage — the keyspace, consistency, and compaction settings this sizing sits on top of.
- Configuring Multi-Datacenter Replication for Graph Data — the replication factor and layout that multiply the storage estimate.