Branch 4369d: two-host load test of the owner-side lock model (2026-10-22)¶
Test report and setup guide for the 50-node runs of branch 4369d (#4369) on two hosts, cust004 and cust007, done on 2026-10-22. Part 1 is for reviewers of the branch: what was tested, the numbers, the findings and what is still open. Part 2 is for whoever repeats the runs: hosts, ports, firewall, scripts, profiles, how to read the results and what went wrong on the way.
Companion documents in locktest/: README.md (how to use the harness), docs/2026-10-22-two-host-test-design.md (the approved design), docs/2026-10-22-two-host-test-plan.md (the implementation plan) and .sdd/2026-10-22-two-host-test-plan/progress.md (the ledger of every task, decision and run of the day).
Part 1: results and findings¶
1. Summary¶
The code under test is the branch tree at r16798 (no uncommitted source changes): OwnerLockManager, OwnerLockFunction, LockQueue, TableLeases and OwnerUniqueTracker / OwnerUniqueIndex / OwnerUniqueFunction on partitioned Geode 2.0.1 regions. On 2026-10-22 it was exercised by the locktest harness on a real Geode cluster of 50 member JVMs, 25 on each host, with two locators, no FWD server, no 4GL and no database. Every run passed every check:
| profile | load | result | lock tx/s | unique tx/s | acquire p99 | reserve p99 | transaction p99 |
|---|---|---|---|---|---|---|---|
smoke2 |
1 + 1 nodes, 30 s | PASS | 686 | 176 | 0.66 ms | 0.98 ms | 5.7 ms |
prod50w |
production-like, wide hot set, 180 s | PASS | 4,922 | n/a | 17.7 ms | n/a | 93 ms |
prod50m |
production-like, narrow hot set, 180 s | PASS | 4,927 | n/a | 16.0 ms | n/a | 89 ms |
prod50w-u |
prod50w + 20 % unique-key transactions, 180 s |
PASS | 3,942 | 981 | 22.3 ms | 31.5 ms | 91 ms |
hot50-u |
high contention + 30 % unique on 50 hot keys, 120 s | PASS | 19,155 | 8,149 | 307 ms | 211 ms | 664 ms |
prod50w-fast |
prod50w with late buckets and ZGC, 120 s |
PASS | 4,933 | n/a | 1.17 ms | n/a | 31 ms |
prod50w-u-fast |
prod50w-u with late buckets and ZGC, 120 s |
PASS | 3,951 | 985 | 1.23 ms | 1.46 ms | 31 ms |
"Every check" means: 0 exclusivity violations, 0 errors, 0 stuck sessions, 0 lost or idle nodes, and for the unique tracker 0 duplicate keys, 0 self-TAKEN, 0 stale reservations and 0 reservations left at the end, in every run.
What the day showed:
- The lock protocol is cheap and never the bottleneck of the production-like profiles. An acquire or a release is one owner round trip of 0.8 ms at p50; of the 4.5 million calls of
prod50w, 20 waited for a lock. The 10,000 sessions are think-time bound (about 99 tx/s per node), so the throughput is set by the profile, not by the code. - The unique tracker holds under load. 981 unique-key transactions per second on top of the lock load, 176,591 in all, with the expected 43,759 error 132 on committed keys and no duplicate, lost or stale reservation; a reservation costs 0.92 ms at p50.
- Under high contention (
hot50-u: 50 hot records and 50 hot unique keys shared by 10,000 sessions), 50 nodes sustained 19,155 lock and 8,149 unique transactions per second with 0 violations; the latency tail there is queueing on the hot keys, as intended. - The latency tail of the SerialGC runs is the collector, not the code. The acquire/release p99 of 12-22 ms is young-generation pauses; with ZGC the p99 is 1.0-1.5 ms and 59 calls of 3 million exceed 5 ms (section 6.1).
- Buckets created before the cluster is complete make the start slow. The rebalance after a 50-node join took 183-669 s; creating the buckets once every node has joined (
--assignBuckets=late) leaves 1-2 bucket moves in under a second and a more even primary spread (section 6.2). Both remedies are in the-fastprofiles.
The same option sets were run on one host on 2026-10-14 (r16797 tree): the throughput is the same within 0.3 % and the p50 latencies within a few percent; section 5.5 has the comparison. Not done: any FWD-level test (ChUI, ETF, cluster application); see section 7.
2. What was tested, and what the tests actually do¶
Code. r16795 added OwnerLockManager (every lock change runs on the owner of the key), r16796 OwnerUniqueTracker (every unique key is reserved on its owner), r16797 table SHARE locks as member leases (TableLeases), owner call timeouts and fixes for ghost locks, failed releases on interrupt, lost unique reservations and a LockQueue deadlock; r16798 the review fixes. The harness compiles the lock package and the distributed/orm tracker classes from the branch source (locktest.sh build) and takes everything else from the branch's p2j.jar (./gradlew jar), so what runs is the reviewed code. The sections below say what the harness does with it; section 11 adds the regions and the mechanics a maintainer needs.
2.1 The cluster and a node¶
Every node is one JVM and one Geode member, as an FWD server is; the 50-node profiles run 25 on each host, with one locator per host. A node hosts every tenant (50): for each tenant it creates one OwnerLockManager over the partitioned region <tenant>_owner_locks (113 buckets, one redundant copy, as the FWD settings geode/ownerLocks/buckets and redundancy would) and, with the unique workload, one OwnerUniqueTracker over <tenant>_unique_keys. Geode spreads the buckets over the members, so every node owns about a fiftieth of every tenant's keys and serves the lock requests for them (the "owner calls" of the report), exactly as in a cluster of FWD servers. The lock functions and serializers are registered as FWD's GeodeConfig registers them.
Sessions are threads: --sessions per tenant per node, 4 × 50 = 200 per node, 10,000 in the cluster. Each session plays one FWD user on one tenant database and runs transactions back to back with a think time between them. What is emulated: the database (the committed unique keys and records live in a harness region), and there is no FWD session, RecordBuffer, directory or persistence. What is real: the lock managers, the lock queue, the table leases and their revocation, the membership handling and the whole owner-side unique reservation path, driven through the same entry points FWD uses (OwnerLockManager.lock/lockTable, UniqueTracker.Context).
2.2 A session's loop¶
Until the end of the run a session repeats: with probability --tableExclRate a table EXCLUSIVE (section 2.4); otherwise with probability --uniqueRatio a unique-key transaction (section 2.5); otherwise a lock transaction (section 2.3). Then it sleeps --thinkMs (2,000 ms in the production-like profiles, 300 ms in hot50-u). Nothing is counted before --warmupSec has passed. Any exception out of a transaction is counted as an error and logged with the session's name; the session goes on after 100 ms. Every session has its own random generator seeded from the run seed and its identity, so two runs of a profile draw the same records.
2.3 A lock transaction, step by step¶
- Choose the records. 1 to
--recordsPerTxof them (1-4 in the production-like profiles, 1-2 inhot50-u), each in a random table of 20 and, with probability--hotRatio, from the table's hot set (prod50w: 1,000 of 100,000 records at 5 %;prod50m: 200 at 20 %;hot50-u: 50 at 80 %), otherwise anywhere in the 100,000. The keys are sorted, so every session locks in the same order and record locks never deadlock each other. - Lock each record in that order, SHARE with probability
--shareRatio(50 %; 30 % inhot50-u), otherwise EXCLUSIVE:OwnerLockManager.lock(type, record, true, 10 s). The call goes to the owner of the key, which grants it or parks the caller in itsLockQueueuntil the holder releases (the "parked calls" of the report). The first record lock of a member on a table also takes the member's table lease, which from then on serves the implicit table SHARE lock of every record lock locally ("local, no call"). The time of the whole call is theacq_share/acq_excllatency. A 10 s timeout aborts the transaction (counted as aborted, every lock released). - Check it. After every acquire the session records its EXCLUSIVE lock in the checker region (
putIfAbsentof the record key with its own name) and looks at what is there: another session recorded as EXCLUSIVE holder of the same record, or a session recorded as holding the table EXCLUSIVE, is a violation. A SHARE lock only looks. This is the exclusivity proof; it costs one or two extra Geode calls per lock and is on in every profile (--verify=true). - Maybe upgrade. A SHARE lock is upgraded to EXCLUSIVE with probability
--upgradeRatio(5 %):lock(EXCLUSIVE, ...)on a record the session holds SHARE, theupgradelatency. Two sessions upgrading the same record deadlock by nature; the 10 s timeout breaks it, and the loser's transaction is aborted (the 13 timeouts ofhot50-u). - Hold the locks for
--holdMs(20 ms; 2 ms inhot50-u): the transaction's work. - Release in reverse order: remove the checker entry, then
lock(NONE, record, true), one owner call each, thereleaselatency. - The whole thing, from the first acquire to the last release, is the
txlatency; the transaction counts as committed or aborted.
2.4 A table EXCLUSIVE¶
A bulk delete in FWD takes the table EXCLUSIVE; the harness does it with probability --tableExclRate per transaction (0.00001 in the production-like profiles: 3-7 per run; 0.0002 in hot50-u: 649 per run). lockTable(EXCLUSIVE, table, true) makes the owner revoke the table leases of every member holding one ("revokes sent / received" in the report) and wait for the record locks on the table to go; the call's time is the tbl_excl latency. The session then records the table in the checker region, holds the lock --tableExclHoldMs (50 ms) and restores the previous level. A record lock that another session manages to take on that table while the entry is there is a violation (step 3 above).
2.5 A unique-key transaction, step by step¶
The unique workload (--uniqueRatio of a session's transactions: 20 % in prod50w-u, 30 % in hot50-u) plays what FWD does for a record with unique indexes, in FWD's order (RecordBuffer, Validation), on a synthetic table with two unique indexes: code (one field) and (cust, seq) (two fields, seq sometimes unknown, in which case that key is not reserved). The DMO of that table is assembled at runtime with DmoMetadataManager.registerDmo under FWD's AsmClassLoader, as converted DMOs are.
- Choose the operation: create, key change or delete with
--uniqueOpMix(50/40/10). A change or a delete needs a committed record: the session draws up to 4 codes (from the hot set of 200 with probability 20 %, inhot50-u50 at 80 %, otherwise from the tenant's 100,000) and looks each up in the emulated database; if none is committed the transaction becomes a create. New record ids encode node, session and sequence, so they are unique cluster-wide. - Lock the record EXCLUSIVE through the tenant's
OwnerLockManager(10 s timeout), as FWD locks a record before changing it. - Build the change: a create gets a random
codeand(cust, seq); a change loads the committed values and changes eithercodeor(cust, seq)(50/50); a delete loads them. - Open the transaction scope of the session's
UniqueTracker.Context(beginTxScope). With probability--uniqueSubTxRatio(5 %) a second, nested scope is opened: the inner block of a 4GL transaction. - Reserve the keys on their owners:
lockAndChange(tracker, record, table, true)for a create or a change,lockAndDelete(tracker, record)for a delete. This is the branch'sOwnerUniqueTracker: one owner call per index whose key changed, each key reserved on the member that owns it; theu_reservelatency. AValidationExceptionis error 132: the key is reserved by another open transaction. The session reads the key's holder afterwards to classify it (held by an open transaction, holder gone, or the record's own id held by another member, which is the self-TAKEN failure), rolls the transaction back (rollback(true)+finished()) and counts it. - Record the open transaction in the
_uopenchecker region (so the stale scanner can tell an open transaction from a lost one). - Validate against the committed keys, as the database's unique index would at the INSERT or UPDATE: every new key is looked up in the emulated database; one committed to another record means
rollbackChange(tracker, token)(theu_rollbacklatency), counted as error 132 (committed), and the transaction is rolled back. Otherwiseunlock(tracker, token)ends the validation step (u_unlock); the reservations stay until the transaction ends. - Hold
--holdMs, the transaction's work. - Undo the sub-transaction (the 5 % of step 4): the inner scope is ended with
rollback(false)+finished(), its change is not written, and the outer transaction commits with nothing to write. Or roll the whole transaction back with probability--uniqueRollbackRatio(5 %):rollback(true)+finished(), nothing written, keys released. - Commit: first the emulated database (
putIfAbsentof every new key with the record id: a key already committed to another live record is the duplicate failure; the old keys of a change and all keys of a delete are removed; the record is written), thencommit(true)+finished()of the tracker scope, which gives the session's reservations back (theu_txendlatency). The database before the tracker is FWD's order: a key is reserved from the reservation until after its commit, so no other transaction can commit it in between. - Clean up: remove the record from
_uopen, release the record lock. The whole transaction is theu_txlatency.
Two faults can be injected to prove the checks (--injectFault, used only by test/fault-injection.sh): skipRelease makes 5 % of committed creates and changes skip finished(), so their keys stay reserved (stale and never-released must fire); bypassTracker makes 5 % of creates reserve nothing and commit after the database check alone, so two of them can commit the same key (duplicate must fire).
2.6 The checks running alongside¶
The exclusivity checker of steps 3 and 2.4 runs inside the sessions. Independently of them, every node scans every 5 s the reservations of the buckets it owns in every _unique_keys region: one older than --staleSec (30 s) whose transaction is not in _uopen is stale (a lost release). At the end of the run, after every session has stopped and every node has reached the end barrier, node 0 counts the reservations left in the _unique_keys regions: never released. Node 0 also counts the nodes that reported (lost nodes) and the nodes with sessions but no transaction (idle nodes). Section 4 lists all nine checks and how each was proven to fire.
2.7 What each profile stresses¶
| profile | sessions | what it is | what it exercises |
|---|---|---|---|
smoke2 |
8 (2 nodes × 2 tenants × 2) | 30 s with the defaults: 1-2 records, 30 % SHARE, hold 2 ms, think 5 ms, 20 % unique | every code path across the two hosts, before an hour of 50-node runs |
prod50w |
10,000 | production-like: 1-4 records, 50 % SHARE, 5 % upgrades, hold 20 ms, think 2 s, hot set 1,000 records at 5 % | the cost of the protocol with almost no contention: 25,000 owner calls/s, leases, the checker; 20 parked calls in 4.5 million |
prod50m |
10,000 | the same with 200 hot records at 20 % | moderate contention on the hot records: 8 × the parked calls at the same p50 |
prod50w-u |
10,000 | prod50w with 20 % unique-key transactions, hot keys 200 at 20 % |
the tracker under the same load: reservations, collisions on committed keys (25 % of unique transactions end in a 132), rollbacks, sub-transactions, deletes |
hot50-u |
10,000 | 50 hot records at 80 %, 1-2 records, 30 % SHARE, hold 2 ms, think 300 ms, 30 % unique on 50 hot keys at 80 %, 20 × the table EXCLUSIVE rate | saturation: the lock queue (154,424 parked calls, re-asks, owner busy), lease revocation (649 table EXCLUSIVE), upgrade deadlocks (13 timeouts), the tracker on 50 contended keys |
*-fast |
as the original | the same loads with buckets created after all nodes joined and ZGC | the same, with the startup and GC remedies of section 6 |
2.8 A run from start to end¶
The nodes of a host start 0.2 s apart. Each creates its cache, registers the functions, creates its regions, lock managers and trackers, publishes ready-N in the control region and waits until all N are there (--startTimeoutSec); with --assignBuckets=late it then creates the buckets of its share of the regions and waits until every region's buckets exist. Node 0 rebalances the lock and unique-key regions (--rebalance=true) and writes a start signal 3 s ahead, relative to its own clock, so the hosts' clock offset does not matter. All sessions start together, warm up for --warmupSec (not counted), then run the measured --durationSec. They stop; every node waits at an end barrier (a member leaving early would purge locks and move buckets, which would distort the others' numbers); node 0 counts the reservations left; every node publishes its counters and latency histograms to the control region; node 0 aggregates them into the report and the RESULT line; then all leave.
2.9 Not tested¶
Node, locator or host failures (members leaving under load); cluster sizes other than 50 (the 2-node smoke run aside); a single-host baseline on the r16798 tree; and anything above the lock manager API: ChUI/ETF/cluster-application tests of the branch are still owed (the 2026-10-21 wiki page Owner-Side Cluster Locking records the same status).
3. Test matrix¶
All 50-node profiles run with 1 GB node heaps and the option sets of cluster.conf (COMMON50: SerialGC and the same options as the earlier single-host runs, so the days stay comparable), plus --startTimeoutSec as a wait bound. Node 0 (cust004) rebalances once every node has joined (--rebalance=true) and writes the report.
| profile | nodes | main options | warm-up / measured | run | result |
|---|---|---|---|---|---|
smoke2 |
1 + 1 | --tenants=2 --sessions=2 --uniqueRatio=0.2 |
5 / 30 s | cluster-20261022_154111-smoke2 |
PASS |
prod50w |
25 + 25 | 50 tenants, 4 sessions per tenant per node (10,000 sessions), 4 records per tx, 50 % SHARE, hold 20 ms, think 2,000 ms, hot set 1,000 records at 5 %, 113 buckets | 180 / 180 s | cluster-20261022_132309-prod50w |
PASS |
prod50m |
25 + 25 | as prod50w, hot set 200 records at 20 % |
180 / 180 s | cluster-20261022_133846-prod50m |
PASS |
prod50w-u |
25 + 25 | prod50w + --uniqueRatio=0.2 (create/change/delete 50/40/10, 5 % rollbacks, 5 % sub-transaction undo) |
180 / 180 s | cluster-20261022_144731-prod50w-u |
PASS |
hot50-u |
25 + 25 | 50 hot records at 80 %, 2 records per tx, hold 2 ms, think 300 ms, 4 sessions, + --uniqueRatio=0.3 --uniqueHotKeys=50 --uniqueHotRatio=0.8 |
10 / 120 s | cluster-20261022_151009-hot50-u |
PASS |
Diagnostic and experiment runs (60 / 120 s, prod50w options unless noted):
| run | what differs | purpose |
|---|---|---|
cluster-20261022_135855-prod50w |
-Xlog:gc |
GC diagnosis (SerialGC) |
cluster-20261022_141101-prod50w |
GC_OPTS=-XX:+UseZGC |
GC confirmation |
cluster-20261022_155429-prod50w |
--assignBuckets=late (node 0 assigns) |
rebalance experiment 1 |
cluster-20261022_160210-prod50w-fast |
late + ZGC (node 0 assigns) | rebalance experiment 2 |
cluster-20261022_161118-prod50w-u-fast |
prod50w-u, late (all nodes assign) + ZGC |
rebalance experiment 3 |
The -fast profiles (prod50w-fast, prod50w-u-fast, hot50-u-fast) are the originals plus --assignBuckets=late and ZGC; hot50-u-fast has not been run yet.
4. Correctness checks¶
A run FAILs (exit code 2, last report line RESULT: FAIL name=count ..., up to 5 samples per failed check above it) when any of these is not zero:
| check | what it catches | how |
|---|---|---|
| violations | two EXCLUSIVE holders of one record, or a record lock during a table EXCLUSIVE | the checker (--verify=true) records holders in a separate region and counts every overlap |
| errors | any exception in a session | counted per session |
| stuck sessions | a session that did not finish its transaction | end barrier |
| lost nodes | a member that left (lock purges, bucket moves) | node 0 counts the results published |
| idle nodes | a node whose sessions did nothing (found 2026-10-22: a clock-offset bug hid it) | per node transaction count |
| duplicate keys | a unique key committed to two live records | putIfAbsent on the emulated database at commit |
| self-TAKEN | a record's error 132 caused by its own reservation held by another member (a lost or late release) | holder read from <tenant>_unique_keys after a tracker 132 |
| stale reservations | a reservation older than --staleSec (30 s) whose transaction is no longer open |
every node scans the reservations it owns every 5 s |
| never released | reservations left once every session has stopped | node 0 counts the _unique_keys entries at the end |
Reported, not judged: error 132 counts by cause (held by an open transaction / committed in the database / holder gone, which is a timing race between the 132 and the holder read).
The checks were proven before the runs, by breaking the workload on purpose on 2 local nodes (test/fault-injection.sh): --injectFault=skipRelease (5 % of committed creates and changes skip finished()) ends with RESULT: FAIL stale=8 never-released=8; --injectFault=bypassTracker (5 % of creates skip the tracker) ends with RESULT: FAIL duplicate=44; the clean run passes. The idle-node check has a fixture of the real bad run (test/idle-node.sh: node-1 did 0 transactions and the report said PASS; now RESULT: FAIL idle-nodes=1).
5. Numbers¶
All numbers come from the runs' report.txt (run ids in section 3 and the appendix); latencies are in ms, "p99" the 99th percentile.
5.1 Lock workload: prod50w, prod50m and the -fast variant¶
| prod50w | prod50m | prod50w-fast | |
|---|---|---|---|
| collector, buckets | SerialGC, created at startup | SerialGC, created at startup | ZGC, created once all nodes joined |
| measured | 180 s | 180 s | 120 s |
| transactions / s (per node) | 4,922 (98-99) | 4,927 (98-99) | 4,933 (98-99) |
| acquire p50 / p99 / p99.9 | 0.82 / 17.7 / 137 | 0.82 / 16.0 / 137 | 0.81 / 1.17 / 1.71 |
| release p50 / p99 | 0.71 / 13.2 | 0.71 / 12.2 | 0.70 / 1.02 |
| upgrade p99 | 1.68 | 1.55 | 1.00 |
| table EXCLUSIVE p99 (count) | 61 (7) | 43 (7) | 99 (3) |
| transaction p50 / p99 / max | 26.4 / 93 / 636 | 26.4 / 89 / 517 | 25.9 / 31 / 109 |
| owner calls / s (per transaction) | 24,912 (5.06) | 24,934 (5.06) | 24,972 (5.06) |
| owner fast call avg | 1.23 | 1.20 | 0.75 |
| parked calls (waited for a lock) | 20 | 164 | 11 |
| calls slower than 5 ms | 71,403 (1.6 %) | 68,985 (1.5 %) | 59 (0.002 %) |
| table leases taken / revokes sent | 50,550 / 11 | 50,550 / 11 | 50,200 / 4 |
| CPU, all node JVMs | 30.6 cores | 30.8 cores | 33.6 cores |
| threads per node at the end | 545-799 | 552-792 | 523-790 |
| heap after GC at the end (MB) | 105-128 | 100-121 | 160-186 |
| lock managers created, slowest node | 137.8 s | 124.0 s | 1.4 s |
| rebalance after the last join | 182.7 s | 211.5 s | 0.51 s (2 bucket moves) |
| primary buckets per node, min / max (max/avg) | 79 / 140 (1.24) | 82 / 135 (1.19) | 104 / 121 (1.07) |
| checks | all 0 | all 0 | all 0 |
Each node runs 200 sessions that think 2 s between transactions, so about 99 tx/s per node is the ceiling of the profile and the throughput cannot tell the variants apart; the latencies can. prod50m (200 hot records at 20 %) parks eight times more calls than prod50w (164 against 20) at the same p50, which is the contention it is meant to produce. The p99 of the SerialGC runs is the collector (section 6.1); prod50w-fast shows the protocol's own tail: 1.17 ms at p99 and 1.71 ms at p99.9 for an acquire, 31 ms for a whole transaction that holds its locks for 20 ms.
5.2 Unique-key workload: prod50w-u and the -fast variant¶
| prod50w-u | prod50w-u-fast | |
|---|---|---|
| collector, buckets | SerialGC, created at startup | ZGC, created once all nodes joined |
| measured | 180 s | 120 s |
| lock transactions / s | 3,942 | 3,951 |
| unique transactions / s (total; per node) | 981 (176,591; 19-20) | 985 (118,219; 19-20) |
| all transactions / s | 4,923 | 4,936 |
| unique operations created / changed / deleted | 143,424 / 26,464 / 6,703 | 98,144 / 15,920 / 4,155 |
| rollbacks: full / sub-transaction undone | 50,256 / 6,739 | 28,460 / 4,807 |
| error 132: open transaction / committed / holder gone | 13 / 43,759 / 4 | 8 / 24,013 / 2 |
reserve (lockAndChange) p50 / p99 / max |
0.92 / 31.5 / 1,356 | 0.94 / 1.46 / 6.3 |
| transaction-end release p99 | 20.7 | 1.23 |
| unique transaction p50 / p99 / p99.9 | 24.8 / 190 / 1,294 | 24.8 / 27.4 / 28.4 |
| acquire p99 / release p99 (lock workload) | 22.3 / 12.4 | 1.23 / 1.07 |
| parked calls | 73 | 23 |
| calls slower than 5 ms | 54,514 (1.4 %) | 41 |
| table leases taken | 53,151 | 52,796 |
| CPU, all node JVMs | 30.7 cores | 36.7 cores |
| rebalance after the last join (100 regions) | 551 s | 0.75 s (1 bucket move) |
| primary buckets per node, max/avg | 1.19 | 1.07 |
| duplicate / self-TAKEN / stale / never released | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
20 % of every session's transactions are unique-key transactions, so the lock rate is lower by construction and the sum is what compares with prod50w. The requested mix is 50/40/10, but a change or a delete needs an existing record of the session's own, so most transactions early in a run are creates. The 43,759 rollbacks on a committed key are the workload colliding on its hot keys on purpose (error 132 from the check against the emulated database), the 13 on an open transaction are collisions with a reservation another session still holds; both are the correct outcome. The reservation round trip is about 1 ms; the 31.5 ms p99 and the 1.3 s maxima of the SerialGC run are GC pauses (section 6.1).
5.3 High contention: hot50-u¶
50 hot records per table at 80 %, 50 hot unique keys at 80 %, 10,000 sessions: most transactions queue on the same few owners, which is the point of the profile.
| hot50-u (SerialGC, buckets at startup, 120 s measured) | |
|---|---|
| lock transactions / s (per node) | 19,155 (371-393) |
| unique transactions / s (total) | 8,149 (977,874) |
| all transactions / s | 27,304 |
| aborted (lock timeout 10 s, breaks upgrade deadlocks) | 13 |
| table EXCLUSIVE | 649 |
| acquire p50 / p99 / max | 1.87 / 307 / 10,494 |
| release p99 | 49.7 |
| transaction p50 / p99 | 12.2 / 664 |
| table EXCLUSIVE p99 | 4,653 |
| reserve p50 / p99 | 2.21 / 211 |
| unique transaction p50 / p99 | 19.2 / 909 |
| error 132: open transaction / committed / holder gone | 29,751 / 587,026 / 27,222 |
| owner calls / s | 75,102 |
| parked calls (total parked time) | 154,424 (52,945 s) |
| re-asks / owner busy | 49,587 / 5,502 |
| owner fast call avg | 7.2 |
| leases taken / revokes sent | 84,626 / 895 |
| CPU, all node JVMs | 116.7 of 192 cores (JIT 21.5 of them: 10 s warm-up is short) |
| rebalance after the last join | 669 s |
| checks | all 0 (the 13 timeouts are aborted transactions, not errors) |
The protocol itself is not the limit: p50 of an acquire is the owner round trip (1.9 ms), the p99 is queueing on the 50 hot keys, and the 154,424 parked calls with their re-asks are the lock queue doing its work. The 27,222 "holder gone" 132s are the race between a tracker 132 and the harness's holder read at 8,000 unique tx/s on 50 keys (the holder commits or rolls back in between); a real leak would show as stale or never-released, both 0.
5.4 Smoke run smoke2 (1 + 1 nodes)¶
686 lock tx/s and 176 unique tx/s (340-347 and 86-89 per node, i.e. both hosts work equally), acquire p99 0.66 ms, reserve p99 0.98 ms, 956 error 132 on committed keys, all checks 0. This is the run the acceptance script starts with: it proves the two hosts can talk before an hour of 50-node runs.
5.5 Comparison with the single-host runs of 2026-10-14¶
The same option sets were run on 2026-10-14 on one 64-core host (cust004, 50 nodes, r16797 tree, SerialGC); every run's compare.txt has the full side by side. The essentials, then / today:
| prod50w | prod50m | hot50 (one saturated host) / hot50-u | |
|---|---|---|---|
| transactions / s | 4,919 / 4,922 | 4,915 / 4,927 | 13,817 / 19,155 (+ 8,149 unique) |
| acquire p50 | 0.87 / 0.82 | 0.87 / 0.82 | 18.7 / 1.87 |
| acquire p99 | 14.2 / 17.7 | 13.95 / 16.0 | 2,048 / 307 |
| release p99 | 10.6 / 13.2 | 10.6 / 12.2 | 430 / 49.7 |
| transaction p99 | 101 / 93 | 114 / 89 | 4,030 / 664 |
| owner fast call avg | 1.32 / 1.23 | 1.33 / 1.20 | 69 / 7.2 |
| parked calls | 25 / 20 | 183 / 164 | 283,137 / 154,424 |
| calls slower than 5 ms | 1.8 % / 1.6 % | 1.9 % / 1.5 % | 93 % / 15 % |
| CPU, node JVMs | 28.8 / 30.6 cores | 29.3 / 30.8 cores | 63.7 of 64 / 116.7 of 192 cores |
Reading: the throughput is identical (think-time bound), the p50s are 5 % lower, and the p99s of the production-like profiles are 15-25 % higher, which is that day's SerialGC pause distribution (the old runs had more calls over 5 ms but shorter ones), not the protocol (section 6.1). The differences mix the r16797 to r16798 code change with the cross-host network; no single-host run of r16798 was made. The hot50 line is indicative only: the old run saturated one host (63.7 of 64 cores) and had no unique workload.
6. Findings¶
6.1 The latency tail is SerialGC¶
Question: why is the acquire p99 17.7 ms when the p50 is 0.82 ms and only 20 calls of 4.5 million waited for a lock? Diagnosis on a short prod50w with -Xlog:gc on every node (SerialGC, 1 GB heap, -Xms64m):
- each node did about 105 young collections in the roughly 530 s of the run, one every 2-5 s, average pause 23 ms, maxima 148-326 ms (the longest ones are the harness's own
System.gc()heap measurements outside the measured window); of 5,461 pauses, 380 were 5-10 ms, 2,687 10-20 ms, 2,142 20-50 ms, 244 over 50 ms; - every node was paused 0.86-1.09 % of the last 150 s; a round trip touches two JVMs, so about 1.5 % of calls should be hit, and 1.47-1.63 % of the calls were slower than 5 ms, on both hosts alike;
- the heap stays at 60-230 MB because
-Xms64mlets SerialGC keep a 20-50 MB young generation while the nodes allocate 17-22 MB/s.
Confirmation: the same run with -XX:+UseZGC and nothing else changed:
| prod50w, 60 / 120 s | SerialGC | ZGC |
|---|---|---|
| transactions / s | 4,926 | 4,948 |
| acquire p50 / p99 / p99.9 (ms) | 0.82 / 12.7 / 25.3 | 0.79 / 1.78 / 3.36 |
| release p99 (ms) | 11.9 | 1.36 |
| transaction p99 (ms) | 55.8 | 33.3 |
| calls slower than 5 ms | 46,171 (1.5 %) | 349 (0.01 %) |
| parked calls | 13 | 18 |
| CPU, all node JVMs | 31.2 cores | 33.5 cores (+7 %) |
| heap after GC at the end (MB) | 89-102 | 162-190 |
So the owner round trip is about 0.8 ms at p50 and under 2 ms at p99; everything above is the collector. The acceptance profiles keep SerialGC for comparability with 2026-10-14. For FWD this is a server JVM question: collector choice and -Xms/young-generation sizing of the servers, not a lock manager change.
6.2 Rebalance after a cluster start, and bucket placement¶
Every lock region (50) and unique-key region (50) has 113 buckets with one redundant copy. With buckets created at startup (geode/ownerLocks/assignBuckets=true, the FWD default) the first nodes to join get almost all of them, and node 0's rebalance after the last join moves them: 183-213 s for the 50 lock regions, 551 s for the 100 regions of prod50w-u, 669 s for hot50-u, and 903 s when the harness's own 100 regions were still included (the first prod50w-u attempt timed out on it). FWD's default of 1,009 buckets per region means roughly nine times as many buckets to move. After the rebalance the primaries were still uneven: 47 % on cust004, 53 % on cust007, busiest node 1.24 × the average.
The harness got --assignBuckets=late: nothing is created at startup; once every node has joined, every node creates the buckets of the tenants with t % nodes == idx (its lock region and its unique-key region), so Geode places them over the complete cluster.
| prod50w, 60 / 120 s | buckets at startup (true) | late, node 0 creates all | late, every node its share |
|---|---|---|---|
| bucket creation | 122-138 s after the cache | 92-95 s (node 0, 50 regions, serial) | about 7 s per node (100 regions, in parallel) |
| rebalance | 183-213 s, thousands of bucket transfers (27,989 for the 200 regions of the first prod50w-u attempt) |
448 ms, 1 transfer | 748 ms, 1 transfer (100 regions, prod50w-u-fast) |
| primaries per node, max / avg | 1.21-1.24 | 1.08 (108-122) | 1.07 (104-121) |
| primaries per host | 47 % / 53 % | 2,831 / 2,819 | even (104-121 per node on both) |
| requests served, max / avg | 1.21-1.24 | 1.08 | 1.06 |
Throughput and latency are unchanged by the mode (4,928-4,933 tx/s; acquire p99 14.2 ms with SerialGC, 1.17-1.23 ms with ZGC). Runs: cluster-20261022_155429-prod50w, cluster-20261022_160210-prod50w-fast, cluster-20261022_161118-prod50w-u-fast.
For a production FWD cluster the equivalent is: do not create the buckets before the cluster is complete (assignBuckets=false with all servers started before users log in, or a new "assign once N members joined" setting, which is what the late mode does), use fewer buckets than the default 1,009 per region (113 was enough for 50 nodes), and run gfsh rebalance at a quiet time after a server joins or leaves, since Geode never rebalances on its own.
6.3 Clock offset between the hosts (harness bug, fixed)¶
cust007's clock is about 466 s ahead of cust004's. The first cross-host smoke run passed with node-1 having done 0 transactions: the start time was node 0's absolute clock, so cust007's node had "started" 466 s ago and never ran. Fixed by scheduling with a relative delay, and the new idle-nodes check fails such a run. The branch itself compares no timestamps across members (OwnerLockEntry.since, UniqueReservation.since are informational and set by the owner); only the harness's stale check mixes clocks when a bucket moves between hosts. NTP on the hosts is still advisable.
6.4 Fixed ports and the ephemeral range (setup finding)¶
cust004's firewall drops everything but ssh, so Geode's ports were pinned to a fixed set for the firewall rules. The first set (40000-40099, 41000-41099) lay inside Linux's ephemeral range (32768-60999): four nodes failed with BindException: Address already in use because outbound connections had taken their port first. The set was moved below 32768 (section 8); preflight now refuses a fixed range inside the hosts' ephemeral range.
6.5 Smaller harness findings¶
- Geode's rebalance includes every partitioned region; the harness excludes its own
_db/_uopenregions now (an FWD cluster has no such regions), which halved theprod50w-urebalance. - The stale scanner saw
EntryDestroyedExceptionfor entries released between iteration and read (theerrorssamples of theprod50w-uandhot50-ureports, count 0); fixed to skip them. The samples in those two reports are this nuisance, not tracker errors. - The 13 aborted transactions of
hot50-uare the 10 s lock timeout breaking upgrade deadlocks (two sessions holding SHARE and both upgrading), which is the intended resolution.
7. Not covered and open items¶
- FWD-level test of the branch (ChUI/ETF/cluster application): not done; the harness drives the lock manager API, nothing above it.
[4369-locks] TEMPORARYinstrumentation inOwnerLockManager/OwnerLockFunction/TableLeases: to be removed before merge; the harness report's owner-call statistics depend on it, so the harness needs an update then.hot50-u-fasthas not been run; the other two-fastprofiles were run for 120 s, not 180 s.- cust004 firewall rules are not persistent (iptables, added by hand on 2026-10-22; INPUT carries the TCP rule twice, harmless). They vanish on a reboot.
- Clocks 466 s apart; NTP.
- Harness minors deferred in the ledger: the idle check only flags a node with 0 transactions;
start-nodesdoes not notice a dead slice shell before its 120 s wait;all.shdecides the selftest by the absence of "FAIL"; the 132 catch does not check the error number;deployreplaces the bundle even while a run is live. test/late-buckets.shwas written together with the late mode and never watched failing (the hosts' experiments read the same log lines it asserts).
Part 2: setup and how to repeat¶
8. Topology¶
cust004 (192.168.1.83, 64 cores) cust007 (192.168.1.86, 128 cores)
+------------------------------------+ +------------------------------------+
| locator-1 :10334 (tcp 20091) |<------>| locator-2 :10335 (tcp 20092) |
| nodes 0..24 (node 0 reports) |<------>| nodes 25..49 |
| tcp-port 20000+N | LAN | tcp-port 20000+N |
| membership 21000-21099 tcp+udp | | membership 21000-21099 tcp+udp |
+------------------------------------+ +------------------------------------+
every JVM: locators=192.168.1.83[10334],192.168.1.86[10335], bind-address = its host's LAN address
development machine: ssh -p 2227 es@localhost -> cust004 (key), ssh -p 2228 mfg@localhost -> cust007 (password)
| cust004 | cust007 | |
|---|---|---|
| reached as | ssh -p 2227 es@localhost (key; or a password from SSHPASS_cust004 with H1_AUTH=password) |
ssh -p 2228 mfg@localhost (password, from SSHPASS_cust007 or SSHPASS) |
| LAN address | 192.168.1.83 | 192.168.1.86 |
| cores / RAM | 64 / 503 GiB | 128 / 503 GiB |
| Java | 17.0.20 | 17.0.20 |
| harness directory | ~/4369d/locktest/ |
~/4369d/locktest/ |
| firewall | iptables-nft, INPUT and OUTPUT policy DROP | none (policy ACCEPT) |
| other | older bundles ~/lt-20261014, ~/locktest (the 2026-10-14 runs) |
only ~/4369d may be read or changed; Docker bridge 172.17.0.1 |
The ssh tunnels are created through the jump host www.goldencode.com (ssh port 2222): ssh www.goldencode.com -p 2222 -l es -NC -f -L2227:${cust004}:22 and ssh www.goldencode.com -p 2222 -l mfg -NC -f -L2228:${cust007}:22, with cust004 and cust007 set to the hosts' addresses (step 2 of section 10.1 has the full sequence). Both hosts reach each other directly on the LAN. Two locators, one per host, each started with both in its locators list so they share one membership view, as FWD's geode/locatorHost1/2 configuration does; locator-1 starts first, then locator-2, then the node slices. Locators are not in the fwd-servers group, so owner calls skip them.
Fixed Geode ports (GEODE_PORTS and LOC_TCP_PORT_BASE in cluster.conf), the only ones the firewall must open, both directions between the two LAN addresses:
| ports | protocol | use |
|---|---|---|
| 10334-10335 | TCP | locators |
| 20000-20099 | TCP | direct channels (tcp-port): node N = 20000 + N, locator of host H = 20090 + H |
| 21000-21099 | TCP + UDP | membership (membership-port-range) |
They must stay below the ephemeral range (/proc/sys/net/ipv4/ip_local_port_range, 32768-60999 on both hosts); preflight checks that, test/cluster-ports.sh checks the configuration, test/pinned-ports.sh that every JVM listens inside the set.
cust004 firewall rules. The five iptables rules that open this port set on cust004 (applied with sudo on 2026-10-22; not persistent, re-add after a reboot) are in step 3 of section 10.1, together with the check. A blocked port shows up within seconds: cluster.sh run probes each locator's port and direct-channel port from the other host right after starting it ("cust007 cannot reach 192.168.1.83:10334 (firewall?)").
Constraints on cust007. Only ~/4369d is read or written (plus the read-only host queries of preflight: Java version, nproc, ulimit -u, kernel threads-max / pid_max, systemd TasksMax, the ephemeral range). Every JVM keeps its files inside it: -XX:-UsePerfData, java.io.tmpdir under ~/4369d/locktest/tmp, the run's results directory as working directory. No keys are installed; the password is only ever in the SSHPASS_<host> or SSHPASS environment variable (sshpass -e), never in a file, a log or the shell history. Processes are stopped by their recorded pids only, and only if they still are load test processes (never pkill).
Thread budget. Every node keeps reader threads for every other member: about 190 + 80 × (nodes − 1) per node, so a 25-node slice of a 50-node cluster needs about 103,000 threads on its host. Measured at the end of the runs: 545-836 threads per node (up to 1,751 in hot50-u). preflight checks ulimit -u, TasksMax, threads-max and pid_max (both hosts: about 2 million or more).
9. Build, bundle, deploy¶
On the development machine, in the branch checkout:
./gradlew jar copyDependencies # the branch's p2j.jar and its jars in build/lib
bash locktest/bin/locktest.sh build # compiles ../src/.../distributed/lock/*.java, the orm tracker
# classes, GeodeService, BlockSequence and everything under
# locktest/src into locktest/classes
bash locktest/bin/locktest.sh bundle # locktest/locktest-bundle.tar.gz: classes, bin/, test/,
# cluster.conf and the 30 jars of jars.txt
bash locktest/bin/cluster.sh deploy # scp + unpack into ~/4369d on both hosts (results/ kept)
jars.txt lists the jars the locator and the nodes load (Geode, FWD's p2j.jar, and for the unique workload commons-collections4, asm, reflections). A ClassNotFoundException in a node log on the hosts means a jar is missing from the list.
Harness files, for orientation:
| file | role |
|---|---|
bin/acceptance.sh |
the whole acceptance in one command (sections 10.1 and 10.2) |
bin/cluster.sh |
two-host orchestration over ssh: preflight, deploy, run, status, collect, stop |
cluster.conf |
the hosts, the fixed ports, the profiles (PROFILE_*, HEAP_*, GC_*, BASELINE_*) |
bin/locktest.sh |
the harness itself: build, run (one host), locator, nodes, report, selftest, compare, bundle, and the detached steps cluster.sh uses (start-locator, start-nodes, state, stop, probe, preflight) |
src/.../lock/LockLoadTest.java |
the test program: locator mode, node mode (regions, sessions, barriers, checks, report) |
src/.../lock/UniqueWorkload.java |
one unique-key transaction, fault injection, 132 classification |
src/.../lock/UniqueChecks.java |
the stale scanner and the never-released count |
src/.../lock/UniqueTable.java, UniqueRecords.java, LtTracker.java |
the synthetic DMO, its records, the harness's OwnerUniqueTracker subclass |
src/.../lock/SelfTest.java, Compare.java |
the one-JVM tracker self-test; the comparison with a baseline |
baseline/2026-10-14/{prod50w,prod50m,hot50}/ |
the 2026-10-14 reports and node results compared with (SOURCE names the run) |
test/*.sh, test/all.sh |
the harness's own tests (section 12) |
results/ |
every run: run-* (one host), cluster-* (two hosts), acceptance-* (summaries) |
10. Running¶
10.1 Step by step, from a fresh checkout to the results¶
Everything runs from the development machine, in the root of a checkout of branch 4369d (the paths below are relative to it); the hosts only need the bundle, which step 5 deploys. Times are those of 2026-10-22.
Step 1. What this machine needs. Java 17 (java -version), sshpass (command -v sshpass; install it with the package manager if missing), the branch checkout, and the two accesses: your ssh key accepted by es@cust004, and the password of mfg@cust007 (ask ES for it; it is never written down). Without a key on cust004 you need the password of es@cust004 as well: see "Passwords for both hosts" under step 5.
Step 2. Create the ssh tunnels and check where they land. Both go through the jump host www.goldencode.com (ssh port 2222): local port 2227 to cust004, logged in on the jump host as es, and local port 2228 to cust007, logged in as mfg. -N runs no command, -C compresses, -f puts the tunnel in the background, where it stays until killed:
cust004=192.168.1.83; cust007=192.168.1.86 # the hosts' addresses as seen from the jump host
ssh www.goldencode.com -p 2222 -l es -NC -f -L2227:${cust004}:22
ssh www.goldencode.com -p 2222 -l mfg -NC -f -L2228:${cust007}:22
ssh -o BatchMode=yes -p 2227 es@localhost hostname # must print cust004, without a prompt
ssh -p 2228 mfg@localhost hostname # asks the password, must print cust007
Another hostname means the tunnel forwards to another machine: fix the tunnel before going on (~/bin/gcd_remote.sh on ES's machine wraps the same two commands).
Step 3. Check the cust004 firewall rules, add them if missing. cust004's firewall drops everything but ssh (INPUT and OUTPUT policy DROP), so the fixed Geode ports of section 8 must be opened both ways between the two LAN addresses. The rules are not persistent: they are lost when cust004 reboots. cust007 has no firewall.
ssh -p 2227 es@localhost sudo iptables -S INPUT | head -5 # the policy line, then the rules at the top of the chain
With the rules in place it looks like this: the policy, the two rules for 192.168.1.86 (tcp or udp first, depending on the order they were added), then the chain's own entries such as -A INPUT -i lo -j ACCEPT:
-P INPUT DROP -A INPUT -s 192.168.1.86/32 -d 192.168.1.83/32 -i eth0 -p udp -m udp --dport 21000:21099 -j ACCEPT -A INPUT -s 192.168.1.86/32 -d 192.168.1.83/32 -i eth0 -p tcp -m multiport --dports 10334:10335,20000:20099,21000:21099 -j ACCEPT -A INPUT -i lo -j ACCEPT ...
Without them, no -A INPUT -s 192.168.1.86/32 ... line appears. In that case add the five rules (sudo on cust004), check again and log out:
sudo iptables -I INPUT -s 192.168.1.86 -d 192.168.1.83 -i eth0 -p tcp -m multiport --dports 10334:10335,20000:20099,21000:21099 -j ACCEPT sudo iptables -I INPUT -s 192.168.1.86 -d 192.168.1.83 -i eth0 -p udp --dport 21000:21099 -j ACCEPT sudo iptables -I OUTPUT -d 192.168.1.86 -o eth0 -p tcp -m multiport --dports 10334:10335,20000:20099,21000:21099 -j ACCEPT sudo iptables -I OUTPUT -d 192.168.1.86 -o eth0 -p udp --dport 21000:21099 -j ACCEPT sudo iptables -I OUTPUT -d 192.168.1.86 -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT sudo iptables -S INPUT | head -5 sudo iptables -S OUTPUT | head -6 exit
The INPUT check then shows the lines above; the OUTPUT check shows the policy and the three rules for 192.168.1.86 (RELATED,ESTABLISHED, udp, tcp) at the top of its chain:
-P OUTPUT DROP -A OUTPUT -d 192.168.1.86/32 -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT -A OUTPUT -d 192.168.1.86/32 -o eth0 -p udp -m udp --dport 21000:21099 -j ACCEPT -A OUTPUT -d 192.168.1.86/32 -o eth0 -p tcp -m multiport --dports 10334:10335,20000:20099,21000:21099 -j ACCEPT ...
These are the rules applied on 2026-10-22 (INPUT then carried the TCP rule twice, which is harmless). A port that is still blocked shows up within seconds of a run: cluster.sh run probes each locator's ports from the other host and stops with "cust007 cannot reach 192.168.1.83:10334 (firewall?)".
Step 4. Build the branch jars (once, and again after every source change of the branch; a few minutes):
./gradlew jar copyDependencies
Step 5. Run the acceptance. One command does the rest: builds the harness, runs its own tests (about 15 min), bundles, deploys to both hosts, checks them and runs the five profiles (about 1 h):
bash locktest/bin/acceptance.sh
It asks once for the password of every host logged into with a password: mfg@cust007 and, with H1_AUTH=password, es@cust004 (typed, not echoed, not stored). Leave the terminal alone until it prints acceptance: done; summary: and the summary table. Expected durations: smoke2 about 3 min, prod50w and prod50m about 15 min each, prod50w-u about 22 min, hot50-u about 17 min. Variants:
SKIP_SUITE=1 bash locktest/bin/acceptance.sh # without the harness's own tests bash locktest/bin/acceptance.sh smoke2 # a quick cross-host check only bash locktest/bin/acceptance.sh prod50w-fast prod50w-u-fast hot50-u-fast # the late-buckets + ZGC variants
Passwords for both hosts. cust004 is logged into with your ssh key (H1_AUTH=key in cluster.conf), cust007 with the password of mfg. Without a key on cust004, log into it with the password of es: set H1_AUTH=password in cluster.conf (once), and either let acceptance.sh ask for both passwords, or export them beforehand, one variable per host, in the shell that runs the commands:
read -rsp 'Password of es@cust004: ' SSHPASS_cust004; echo; export SSHPASS_cust004 read -rsp 'Password of mfg@cust007: ' SSHPASS_cust007; echo; export SSHPASS_cust007 bash locktest/bin/acceptance.sh # asks nothing more bash locktest/bin/cluster.sh run smoke2 # every cluster.sh command works the same way
SSHPASS_<host> is the password of that host (the host name as in cluster.conf); SSHPASS alone serves every password host that has no variable of its own, which only helps when the accounts share a password. The read -rs form keeps the passwords out of the shell history; they are never written anywhere. A missing one stops every command at once with cluster: set SSHPASS_cust004 or SSHPASS (password of es@cust004).
Step 6. Read the result. The exit code says it: 0 = every profile passed, 2 = a check failed, 1 = a profile could not run. The summary is locktest/results/acceptance-<timestamp>/summary.txt, one line per profile, for example:
profile result tx/s uniq/s acq p99 rel p99 tx p99 u_res p99 vs 2026-10-14 (tx/s, acq p99) prod50w PASS 4922 17.66 13.18 93.18 +0.1%, +24.3% prod50w-u PASS 3942 981 22.27 12.42 91.14 31.49 -19.9%, +56.8%
A FAIL line is followed by failed checks: name=count .... details.txt next to it has the correctness section of every run; the full report.txt and compare.txt of each run are in locktest/results/cluster-<timestamp>-<profile>/ (section 10.4 explains how to read them; the RESULT line is the last line of report.txt, the samples of a failed check are just above it).
Step 7. If it stops early. A line acceptance: FAILED: ... names the step: no tunnel (step 2), no sshpass (step 1), the build, the harness's own tests (suite.log in the results directory), the deploy (deploy.log) or the preflight (preflight.log: a live pid of an earlier run, a locator port in use, thread limits, a fixed port inside the ephemeral range). A profile reported as NO RUN has its orchestrator log in run-<profile>.log; the usual causes and fixes are in the table of section 12. A normal run leaves nothing behind on the hosts; after an interrupted one, with the password exported:
read -rs SSHPASS_cust007 && export SSHPASS_cust007 # and SSHPASS_cust004 with H1_AUTH=password bash locktest/bin/cluster.sh status # what is still running on the hosts bash locktest/bin/cluster.sh collect # fetch a finished run the orchestrator missed bash locktest/bin/cluster.sh stop # stop a run's processes (by their pid files only)
Step 8. Single runs and experiments (once step 5 has deployed the current bundle):
read -rs SSHPASS_cust007 && export SSHPASS_cust007 # and SSHPASS_cust004 with H1_AUTH=password bash locktest/bin/cluster.sh run prod50w # one profile, about 15 min bash locktest/bin/cluster.sh run prod50w --warmupSec=60 --durationSec=120 # a shorter one GC_OPTS=-XX:+UseZGC bash locktest/bin/cluster.sh run prod50w # another collector
Each run lands in locktest/results/cluster-<timestamp>-<profile>/ with its report.txt and compare.txt.
Only the harness's own tests, without the hosts (local JVMs, about 15 min): after step 4, bash locktest/bin/locktest.sh build and bash locktest/test/all.sh; the last line must be ALL PASS.
10.2 Everything in one command¶
bash locktest/bin/acceptance.sh # smoke2 prod50w prod50m prod50w-u hot50-u, about 1 h bash locktest/bin/acceptance.sh smoke2 prod50w # only these profiles bash locktest/bin/acceptance.sh prod50w-fast # a -fast profile (late buckets + ZGC) SKIP_SUITE=1 bash locktest/bin/acceptance.sh # without the harness's own tests (about 15 min less) SKIP_BUILD=1 bash locktest/bin/acceptance.sh # hosts already have the current bundle
Prerequisites: a checkout of the branch with ./gradlew jar copyDependencies done, Java 17, sshpass installed, the tunnels on 2227 and 2228 up, the firewall rules of section 8 in place. The script asks for the password of every password host (mfg@cust007; es@cust004 too with H1_AUTH=password; read -rs, not echoed, not stored) unless SSHPASS_<host> or SSHPASS is exported, then: checks the tunnels, builds, runs test/all.sh, bundles, deploys, runs preflight, runs the profiles one after another and writes results/acceptance-<timestamp>/summary.txt (one line per profile: result, tx/s, unique tx/s, p99s, change against 2026-10-14) and details.txt (the correctness section of every run). Exit code 0 = all passed, 2 = a check failed, 1 = a profile could not run. Verified end to end with smoke2 on 2026-10-22 (results/acceptance-20261022_153213; its summary.txt still shows the transaction count where later versions show the rate).
10.3 The pieces: cluster.sh commands¶
read -rs SSHPASS_cust007 && export SSHPASS_cust007 # password of mfg@cust007, typed (not in shell history)
read -rs SSHPASS_cust004 && export SSHPASS_cust004 # only with H1_AUTH=password (no key on cust004)
bash locktest/bin/cluster.sh preflight # Java 17, thread limits, disk, free ports, live pids, clocks,
# ephemeral range on both hosts
bash locktest/bin/cluster.sh run smoke2 # 1 + 1 nodes, 1 min: proves the hosts can talk
bash locktest/bin/cluster.sh run prod50w # a profile; extra --opt=value override it
bash locktest/bin/cluster.sh run prod50w --warmupSec=60 --durationSec=120
GC_OPTS=-XX:+UseZGC bash locktest/bin/cluster.sh run prod50w # another collector
NODE_JVM_OPTS=-Xlog:gc:file=gc-%p.log bash locktest/bin/cluster.sh run prod50w # extra JVM options
bash locktest/bin/cluster.sh status [RUN] # state of a run on both hosts (running / done N / none)
bash locktest/bin/cluster.sh collect [RUN] # copy a run's results here (also after an interrupted run)
bash locktest/bin/cluster.sh stop [RUN] # stop a run's processes on both hosts, by their pid files
run does, in order: preflight; results/.last = the run name; start locator-1 on cust004 and probe its ports from cust007; start locator-2 and probe from cust004; start the node slices (cust004 nodes 0-24, cust007 nodes 25-49, each slice detached with setsid nohup, with HEAP, NODE_JVM_OPTS, GC_OPTS and the fixed ports); poll both hosts every 15 s (POLL_SEC) until both slices are done, a slice failed or vanished (then both hosts are stopped at once), or the deadline passed (startTimeoutSec + warmupSec + durationSec + 17 min); stop the locators; collect to locktest/results/<RUN>/{cust004,cust007}/; write report.txt (node 0's report) and compare.txt (against baseline/2026-10-14/<BASELINE_profile>). Exit 0 = PASS, 2 = FAIL, 1 = did not run. If the orchestrator dies, the run goes on: status, collect and stop take over (tested by killing it mid-run).
GC_<profile> in cluster.conf sets a profile's collector (GC_OPTS in the environment wins); the default is -XX:+UseSerialGC as on 2026-10-14. HEAP_<profile> is the node heap (1g for the 50-node profiles).
10.4 Reading a run¶
results/<RUN>/report.txt, top to bottom:
- options line: every option the nodes ran with (the single source of truth for a run's configuration);
- startup: nodes reported, cache creation, lock manager creation, bucket creation, heap, rebalance time;
- throughput: transactions and tx/s, per node min/avg/max (uneven = a slow host or an idle node);
- latency: acquire (SHARE / EXCLUSIVE), upgrade, release, table EXCLUSIVE and whole transaction, with count, mean, p50, p90, p99, p99.9 and max in ms;
- unique tracker (with
--uniqueRatio > 0): unique tx/s, operations by kind, rollbacks, error 132 by cause, and the latency of reserve (u_reserve),unlock,rollbackChange, transaction end (u_txend) and the whole unique transaction (u_tx); - owner calls: round trips by kind;
fast calls avgis the cluster round trip,>5msthe count of slow ones (GC or network, see 6.1);parkedcalls waited for a lock;re-asksandowner busyare the queue protocol under contention;local, no callare the table SHARE locks served by the member lease; - table leases: taken, revoked (by table EXCLUSIVE requests), given back;
- owner load balance: requests served and primary buckets per node (max/avg far above 1 = the first nodes own most keys), busiest owners;
- correctness: the checks of section 4, samples of failed ones, and
RESULT: PASSorRESULT: FAIL ....
compare.txt puts the run's rows next to the 2026-10-14 baseline of the profile (tx/s, all tx/s, p50 and p99 of every operation, owner calls, leases, checks; unique rows n/a on the old side) with the caveats that apply. Per host, <host>/node-N.out has the node's log (progress, view size, bucket assignment, rebalance, any exception) and node-N.result its raw counters; locator-N.out and locator-N-geode.log the locators.
10.5 One host only¶
bash locktest/bin/locktest.sh run --nodes=10 --tenants=50 --durationSec=120 starts a locator and the nodes on this machine (results in results/run-<timestamp>/); locktest.sh help lists every option. This is what the harness's tests use, and what the 2026-10-14 runs on cust004 were.
11. How the harness works, as far as it matters for the results¶
- Tenants and regions. Tenant t is
lt_tenantNNN; its lock region islt_tenantNNN_owner_locks(113 buckets, 1 redundant copy in the profiles), its unique-key regionlt_tenantNNN_unique_keys. Harness-owned regions, not under test:lt_tenantNNN_db(the emulated database: committed keys and records),lt_tenantNNN_uopen(record ids of open unique transactions, for the stale and 132 classification),lt_control(barriers, start signal, published results) andlt_verify(the exclusivity checker). - Startup. Every node creates its cache, registers the lock and unique functions and their serializers as FWD's
GeodeConfigdoes, creates its lock managers and trackers, publishesready-Nand waits for all N (--startTimeoutSec); with--assignBuckets=lateit then creates the buckets of its share of the regions and waits until every region's buckets exist; node 0 rebalances (--rebalance=true, lock and unique regions only) and writes a relative start delay (3 s) intolt_control, so the hosts' clocks do not matter. - Sessions.
--sessionsthreads per hosted tenant per node (4 × 50 = 200 per node, 10,000 in all); what each does, transaction by transaction, is in sections 2.2 to 2.5. Warm-up is not measured. - End. Sessions stop after
--durationSec; nodes wait at an end barrier (a member leaving early would purge locks and move buckets), node 0 counts the reservations left, every node publishes its counters, node 0 aggregates them into the report, then all leave. - Record ids encode node, session and sequence, so they are unique cluster-wide; the random seed is fixed (
--seed=4369), so two runs of a profile draw the same records.
12. The harness's own tests and troubleshooting¶
bash locktest/test/all.sh runs, on this machine, the selftest and ten scripts (each takes an optional path to another locktest.sh, e.g. an unpacked bundle):
| test | what it proves |
|---|---|
locktest.sh selftest |
the real tracker on the synthetic table in one JVM: basics, null seq, key change |
unique-workload.sh |
2 nodes with the unique workload on and off; operation mix line; AsmClassLoader only for unique runs |
fault-injection.sh |
skipRelease fails with stale + never-released, bypassTracker with duplicate, clean run passes |
two-locators.sh |
two locators listing each other share one view; state / stop behave |
join-failure.sh |
no locator, or not all members: exit 1 with the "only K of N members joined" message, no hang |
pinned-ports.sh |
every JVM listens inside the fixed port set (14 ports for 2 locators + 2 nodes) |
compare.sh |
the comparison with a baseline, n/a for missing metrics, the unique-share caveat |
idle-node.sh |
the fixture of the clock-offset run fails with idle-nodes=1 |
cluster-poll.sh |
12 cases of the orchestrator's poll decision: wait, done, abort on a failed or vanished slice or the deadline |
cluster-ports.sh |
the fixed ranges are outside the ephemeral range; the old 40000/41000 ranges are reported |
late-buckets.sh |
2 nodes with --assignBuckets=late: the late assignment is logged after the barrier, the run passes |
Status: ALL PASS (10 tests) in the acceptance run of 15:32; late-buckets.sh was added afterwards, and the complete suite of 11 was rerun on the final harness on 2026-10-22 in the evening: ALL PASS, exit 0.
What went wrong on 2026-10-22, and the fix (all in the ledger):
| symptom | cause | fix |
|---|---|---|
| locator-2 never joined locator-1; TCP probes to the locator and membership ports blocked both ways | cust004 firewall (INPUT and OUTPUT policy DROP) | fixed port set + the rules of section 8 |
BindException: Address already in use on four nodes' tcp-port 400NN |
fixed ports inside the ephemeral range | ranges moved to 20000/21000; preflight and cluster-ports.sh check |
cross-host smoke2 PASS with node-1 at 0 tx |
start time from node 0's clock, cust007 466 s ahead | relative start delay; idle-nodes check |
prod50w-u: every node FATAL: no start signal after 900 s |
node 0's rebalance of 200 regions took 903 s | harness regions excluded from the rebalance; -u profiles wait 1,800 s |
| back-to-back local runs: "port (10334) is not available" | the previous run's locator not yet gone | run waits for the locator to stop |
stop counted 3 processes for 2 locators |
Geode's own vf.gf.locator.pid |
skipped |
errors samples EntryDestroyedException under the unique workload |
stale scanner reading a just-released entry | skipped |
ClassNotFoundException in the bundle |
jars missing from jars.txt |
3 jars added |
cluster.sh waiting up to 30 min for a host whose slice had died |
no early abort | poll verdict: abort on a failed or vanished slice or the deadline |
a refused run overwrote results/.last |
.last written before preflight |
written after preflight |
Other things to know: a run leaves its node and locator processes on the hosts only until collect/stop; preflight refuses to start while pids of an earlier run are alive or a locator port is in use; under 50 node JVMs an interactive ssh session can time out, which is why every slice is detached.
13. Appendix: every two-host run of 2026-10-22¶
| run | profile | nodes | outcome | purpose |
|---|---|---|---|---|
cluster-20261022_111148-smoke2 (hosts only) |
smoke2 | 1 + 1 | locator-2 never joined | first cross-host attempt, firewall found |
cluster-20261022_120943-smoke2 |
smoke2 | n/a | refused in seconds: cust007 cannot reach 192.168.1.83:10334 | after the port pinning, before the rules |
cluster-20261022_123507-smoke2 |
smoke2 | 1 + 1 | PASS, node-1 0 tx | rules in place; the clock-offset bug found |
cluster-20261022_123938-smoke2 |
smoke2 | 1 + 1 | PASS, node-1 0 tx | same bug, second run before the fix |
cluster-20261022_124414-smoke2 |
smoke2 | 1 + 1 | PASS, 347-349 tx/s per node | after the relative-start fix |
cluster-20261022_125054-smoke2 |
smoke2 | 1 + 1 | PASS (120 s) | .last fix: live run while a second one is refused |
cluster-20261022_125924-smoke2 |
smoke2 | 1 + 1 | PASS (120 s) | orchestrator killed mid-run; status / stop / collect resumed it |
cluster-20261022_130459-prod50w |
prod50w | 25 + 25 | did not start: BindException on 400NN |
ports inside the ephemeral range |
cluster-20261022_131947-smoke2 |
smoke2 | 1 + 1 | PASS | the 20000/21000 ports and the swapped rules |
cluster-20261022_132309-prod50w |
prod50w | 25 + 25 | PASS, 4,922 tx/s | acceptance |
cluster-20261022_133846-prod50m |
prod50m | 25 + 25 | PASS, 4,927 tx/s | acceptance |
cluster-20261022_135855-prod50w |
prod50w, 60/120 s | 25 + 25 | PASS, 4,926 tx/s | GC diagnosis (-Xlog:gc) |
cluster-20261022_141101-prod50w |
prod50w, 60/120 s | 25 + 25 | PASS, 4,948 tx/s | ZGC confirmation |
cluster-20261022_142220-prod50w-u |
prod50w-u | 25 + 25 | did not start: no start signal in 900 s | rebalance of 200 regions took 903 s |
cluster-20261022_144731-prod50w-u |
prod50w-u | 25 + 25 | PASS, 3,942 + 981 tx/s | acceptance |
cluster-20261022_151009-hot50-u |
hot50-u | 25 + 25 | PASS, 19,155 + 8,149 tx/s | acceptance |
cluster-20261022_154111-smoke2 |
smoke2 | 1 + 1 | PASS, 686 + 176 tx/s | acceptance.sh end to end |
cluster-20261022_155429-prod50w |
prod50w, late, 60/120 s | 25 + 25 | PASS, rebalance 448 ms | rebalance experiment 1 |
cluster-20261022_160210-prod50w-fast |
prod50w-fast, 60/120 s | 25 + 25 | PASS, acquire p99 1.17 ms | rebalance experiment 2 |
cluster-20261022_161118-prod50w-u-fast |
prod50w-u-fast, 60/120 s | 25 + 25 | PASS, reserve p99 1.46 ms | rebalance experiment 3 |
Every run's report.txt, compare.txt and per-host logs are under locktest/results/ on the development machine; the hosts keep the same directories under ~/4369d/locktest/results/. The 2026-10-14 baselines are run-20261014_092943 (prod50w), run-20261014_103334 (prod50m) and run-20261014_083012 (hot50) from cust004:~/lt-20261014/locktest/results, copied to locktest/baseline/2026-10-14/.