Parallel Bulk Import Tuning
Adding workers to a bulk load is the fastest way to either double your throughput or collapse the coordinator — and which one you get depends entirely on how you partition the input and cap concurrency. This guide is the procedure for parallelizing a JanusGraph import safely: splitting the source by key range so workers do not collide on the same partitions, sizing worker and pool concurrency to the coordinator’s real ceiling, tuning ids.block-size so parallel writers stop contending on id allocation, and throttling to the index bulk queue while you measure actual throughput. It sits under the Bulk Data Loading reference and extends the single-writer loader into a fleet. The failure it prevents is coordinator saturation — the state where more workers produce less committed throughput because every extra connection deepens the native-transport queue and lengthens the tail on every commit.
Prerequisites
Confirm each item before scaling out. Parallelism amplifies every misconfiguration in the single-writer path, so a load that is not clean at one writer will not become clean at eight.
- A working single-writer load. Validate the idempotent batched loader from Bulk Loading Graphs with gremlin-python end-to-end first; parallelism is an optimization on top of a correct loader, not a substitute for one.
- A partitionable key. The source must carry a key you can split into disjoint ranges — a hash prefix, a numeric id range, or a natural shard key — so each worker owns a non-overlapping slice.
- Known coordinator capacity.
nodetool tpstatsheadroom onMutationStageand the per-nodenative_transport_max_threadsceiling, so you can size total sockets below what the coordinators absorb. - A resolved connection pool baseline from Connection Pooling, because the parallel workers share that socket ceiling and it is the real concurrency limit.
- Replica topology fixed. Reconcile Replication Strategies before scaling writers — replica count and consistency level set the coordinator fan-out each parallel commit multiplies.
- A throughput baseline from the single-writer run (committed vertices per second) to measure the parallel speedup against.
Step 1 — Partition the input by key range
Split the source into disjoint ranges so no two workers write the same storage partition. Range partitioning on a hash prefix keeps each worker’s writes local to a slice of the token ring and eliminates cross-worker uniqueness collisions.
def partition_ranges(num_workers: int):
# Disjoint hex-prefix ranges over a byte of key space.
step = 256 // num_workers
for w in range(num_workers):
lo = w * step
hi = 256 if w == num_workers - 1 else (w + 1) * step
yield (f"{lo:02x}", f"{hi:02x}")
def rows_for_range(source, lo, hi):
for r in source:
if lo <= r["key"][:2] < hi: # key's hex prefix selects the owner
yield r
Verify: confirm the ranges are disjoint and cover the whole key space with no gaps or overlaps.
ranges = list(partition_ranges(4))
assert ranges[0][0] == "00" and ranges[-1][1] == "100" # full coverage
assert all(ranges[i][1] == ranges[i+1][0] for i in range(len(ranges)-1)) # no overlap
Step 2 — Size worker and pool concurrency
The number of workers is not the throughput knob — the shared pool is. Size the pool below the coordinator’s native-transport capacity, then set worker count to keep the pool busy without deepening the acquire queue. A good starting point is workers equal to pool size, so each worker holds roughly one socket’s worth of in-flight commits.
# Shared across all parallel workers — this is the real ceiling.
storage.cql.core-connections-per-host=4
storage.cql.max-connections-per-host=16
storage.cql.max-requests-per-connection=1024
storage.cql.connection-timeout=5000
Total sockets must stay under coordinator capacity: with storage nodes and per-host max , the fleet opens up to sockets, and that product must sit below aggregate native_transport_max_threads. Set worker count to the pool size and no higher — extra workers only queue on acquisition.
Verify: under a short canary run, in-flight sockets should approach the pool max while the coordinator keeps headroom.
# Coordinator not saturated: Active < max, Pending near zero
nodetool tpstats | grep -E "Pool Name|MutationStage|Native-Transport"
If MutationStage Pending climbs and All time blocked is non-zero, you have too many sockets for the coordinator — lower max-connections-per-host before adding workers.
Step 3 — Tune id-block-size against cross-worker contention
Parallel writers all draw ids from the same allocation mechanism, and if their blocks are small they contend on the id-allocation partition — the single most common reason parallel loads scale sublinearly. Give each worker a block large enough that renewals are rare, and let each worker’s disjoint key range keep its allocations independent.
# Each worker holds a large block; renewals become rare under parallelism.
ids.block-size=20000000
ids.renew-timeout=600000
ids.renew-percentage=0.3
Verify: allocation contention should not appear in the logs during the parallel run.
# No cross-worker renewal contention under full parallelism
grep -Ec "renew|IDBlock|Temporary.*id" /var/log/janusgraph/janusgraph.log
A near-zero count means blocks are large enough; a rising count means the writers are contending and ids.block-size must go higher.
Step 4 — Throttle to the index queue and measure throughput
If you dispatch to a live index, the index bulk queue is the binding constraint, not the storage coordinator. Throttle the aggregate commit rate to the queue’s drain rate, then measure committed throughput to confirm the parallel speedup is real. Aggregate throughput follows the pool ceiling, not the worker count:
where is worker count, per-worker throughput, the pool socket count, and the mean commit latency. Once exceeds , more workers add nothing — the pool is saturated.
import time
def measure(load_fn, workers, source_ranges):
start = time.monotonic()
total = sum(load_fn(r) for r in source_ranges) # committed vertices
elapsed = time.monotonic() - start
print(f"workers={workers} committed={total} rate={total/elapsed:.0f}/s")
Verify: the measured rate must rise with workers up to the pool ceiling, then plateau. If it plateaus early or regresses, the pool or coordinator is the bottleneck, not worker count.
# Index queue must not reject during the throttled run
curl -s 'localhost:9200/_cat/thread_pool/write?v&h=name,queue,rejected'
rejected climbing means the throttle is too loose — lower the aggregate rate or defer the index and reindex after the load, per the Bulk Data Loading reference.
Fallback and rollback procedures
Change one variable at a time; parallel loads make compound changes impossible to attribute.
If Step 1 leaves gaps or overlaps. Overlapping ranges make two workers write the same key and race the uniqueness check; gaps silently drop rows. Re-derive the ranges from the assertion in Step 1 and re-run the coverage check before loading anything.
If Step 2 saturates the coordinator. MutationStage Pending climbing means total sockets exceed coordinator capacity. Lower max-connections-per-host and reduce worker count together — do not raise the pool to “push through,” which only deepens the queue. If you must absorb the load, add storage nodes rather than sockets.
If Step 3 still shows renewal contention. Raise ids.block-size further and confirm each worker’s key range is genuinely disjoint; overlapping ranges reintroduce contention no block size can fix.
If Step 4 rejects at the index. Stop dispatching during the load. Hold the index in REGISTERED state, complete the storage load, then reindex — a paced rebuild always beats fighting live backpressure. To roll back a corrupted parallel load, drop and recreate the keyspace rather than deleting per worker, then restart from clean state.
Related
- Up a level: Bulk Data Loading — load modes, id-block sizing, and deferred-index reindexing this scale-out builds on.
- Bulk Loading Graphs with gremlin-python — the single-writer idempotent loader parallelized here.
- Connection Pooling — the shared socket ceiling that bounds parallel throughput.
- Replication Strategies — replica count and consistency that set the coordinator fan-out each parallel commit multiplies.