Branch 4369d: cluster lock manager — tests, changes and results¶
- Branch 4369d: cluster lock manager — tests, changes and results
Summary¶
Branch 4369d replaces FWD's distributed lock manager and unique-key index in cluster mode with an owner-side design on Apache Geode 2.0.1: OwnerLockManager (record and table locks) and OwnerUniqueTracker (unique-key reservations). Every lock key has one owner, the primary of its Geode bucket, which decides alone; Geode's redundant copies provide failover.
Between 2026-10-22 and 2026-10-26 the branch was tested with converted 4GL code on real FWD servers, locally and on two hosts with up to 51 FWD servers, plus a bare-Geode harness and targeted correctness tests.
- Correctness: about 145 million lock holds and 38 million transactions were checked on two hosts with no exclusivity violation and no duplicate unique key. All 33 functional scenarios pass in cluster mode.
- Defects found and fixed: two in the branch's first version (a leaked table lock, unique keys released at the wrong block level) and six concurrency issues confirmed with deterministic tests (duplicate session ids across servers, a ghost-lock window, a stuck queue entry, a table-lock hole during a table EXCLUSIVE request, queue state shared between databases, a late lease revoke). The fixes were reviewed and approved after two further fix rounds.
- Performance, leak and robustness fixes (2026-10-26, after the reference run): a lock manager leaked per stateless request (about one per transaction, never freed), now one per database; re-acquiring a held lock answered locally; cached liveness checks; the admin Record Locks view in one call per server; bounded, non-blocking lease give-backs; no lease revoke for a NO-WAIT table EXCLUSIVE request; CTRL-C effective at once while waiting on a remote owner;
conserve-sockets=trueby default; a concurrency-checks setting for the unique-key regions. Each was shown with a test before and after; the review asked for one fix round (three important findings), the re-review approved it with five minor findings, which are parked. - Reference run: the final build with all recommended settings, 25 + 25 servers (51 after a join), 6,000 users, kills, restarts, a join and three rebalances: all judged checks PASS, 10.5 M transactions, 39 M lock holds, no error outside a killed server.
- Performance (reference run): a lock call to its owner takes 0.77 ms (p50) and 3.1 ms (p99) in steady load; 2,940 transactions/s at p50 38 ms, p99 94 ms. The lock logic itself is 2.5 % of a server's CPU.
- Rebalance (reference run): after a restart or a join, one rebalance (bucket-count load probe, 8 threads, 30 s after the event) takes 2.9–3.5 s with 51 servers; lock-call p99 during it is 3.6–5.1 ms, throughput 98–100 %. Geode never rebalances by itself; a coordinator is recommended.
- Failures: a killed server is detected in about 1.2–1.5 s with a 1000 ms member timeout; its locks are freed and its buckets fail over without loss.
- All FWD changes since r16798 are uncommitted in the branch's working tree. The reference run used the build before the 2026-10-26 performance and leak fixes (md5
5b1bfd9c…); the current build (md5c160cf2b…) has passed the local regression but not yet the two-host load test.
Test environment¶
| Item | Value |
|---|---|
| Local machine | 3 FWD servers + 2 Geode locators, user-space PostgreSQL (port 5440) |
| Host cust004 | 192.168.1.83, 64 cores, 503 GB RAM, Ubuntu 24.04, PostgreSQL 16 (user-space instance, port 5440) |
| Host cust007 | 192.168.1.86, 128 cores, 503 GB RAM |
| Java | OpenJDK 17, ZGC |
| Geode | 2.0.1 (the branch's jars) |
| FWD server (load runs) | 6 GB heap (12 GB in the first 4 + 4 run), 16 appserver sessions, 50 tenants (one database each) + landlord + default database |
| Geode ports | locators 10334/10335, members 20000–20099 (direct) and 21000–21099 (membership), inside the firewall rules of cust004 |
Test tools¶
All test tools are in locktest/ (unversioned).
FWD 4GL scenario suite¶
A converted 4GL project (locktest/fwd/: schema lt with tables lrec, ukey, bulk and the multi-tenant mt; 4GL programs), a runner (locktest/bin/fwdtest.sh) and a Java driver (locktest/src/com/goldencode/lt/fwd/).
- single — one server with FWD's local lock manager; the reference (oracle) for every scenario;
- cluster — three servers with the branch's classes;
- legacy — three servers with FWD's previous distributed manager; the "before" picture and the check whether a cluster-only failure comes from the branch;
- negative — deliberately broken node-local classes, to prove the checks can fail.
The runner also kills, restarts, freezes (SIGSTOP/SIGCONT) and rebalances servers, and sets the member timeout, the lock regions' concurrency checks and the FastFindCache switch.
The 4GL actors are stateless: every call is a session-free appserver request that runs one transaction script (hold.p), with ready/await stages released through signal files, so a holder keeps its locks while other actors probe. A, B and C below are actors on server 1, 2 and 3.
Record locks
| Id | Steps and expected result |
|---|---|
| L1 | A EXCLUSIVE r1 · B NO-WAIT → LOCKED · B waits · A commits → B still waits (A's lock downgraded to SHARE while its buffer is in scope) · A releases the buffer → B granted |
| L2 | A and B SHARE on r2 → both OK, nobody blocked · B NO-WAIT EXCLUSIVE → LOCKED |
| L3 | A and B SHARE on r3 · A upgrades to EXCLUSIVE → waits · B releases → A granted |
| L4 | A EXCLUSIVE r4 and changes it · B NO-LOCK read → not blocked · A commits → B reads the new value |
| L5 | A EXCLUSIVE r5, changes it, rolls back → B EXCLUSIVE granted, value unchanged |
| L6 | A EXCLUSIVE r6 · B and C wait · A releases → exactly one is granted, the other still waits · its release → the other granted |
| L7 | A EXCLUSIVE on 50 records spread over the id range (owners on all three servers) · B NO-WAIT on each → all LOCKED · A releases → all OK |
| L8 | A EXCLUSIVE r8 · B waits with a short STOP-AFTER → TIMEOUT · B's session still answers · no table or record lock left behind for B |
| L9 | A FOR EACH ... EXCLUSIVE-LOCK over 10 records · B NO-WAIT on each → LOCKED, on the next record → OK · A releases → all OK |
| L10 | A OPEN QUERY + GET NEXT SHARE-LOCK · B SHARE → OK, NO-WAIT EXCLUSIVE → LOCKED · A closes the query → B EXCLUSIVE OK |
| L11 | A NO-LOCK read · B changes the record · A FIND CURRENT ... EXCLUSIVE-LOCK → granted, CURRENT-CHANGED yes |
| L12 | A DO FOR block: EXCLUSIVE, leave the block (transaction still open) · B NO-WAIT → LOCKED until A commits, then OK |
| L13 | A EXCLUSIVE + RELEASE inside the transaction · B NO-WAIT → LOCKED until A commits, then OK |
Unique keys
| Id | Steps and expected result |
|---|---|
| U1 | A creates key K1 · B creates K1 → 132 · A rolls back → B creates K1 → OK |
| U2 | A creates K2 in one committed transaction · B creates K2 → 132 |
| U3 | committed row OLD · A changes OLD→NEW · B creates NEW → 132, OLD → 132 · A commits → OLD OK, NEW 132 |
| U4 | committed row DEL · A deletes it · B creates DEL → 132 · A commits → OK |
| U5 | two different keys of the same customer → both OK |
| U6 | A creates SUB in an undone sub-transaction → B creates SUB → OK · kept sub-transaction → 132 |
| U7 | 20 rounds: A and B create the same new key at the same moment → exactly one OK, one 132; one row per key |
| U8 | two unique indexes: collisions on either index → 132; a failed create leaves nothing reserved |
| U9 | A deletes 5 committed rows with FOR EACH ... DELETE (uncommitted) · B creates one key → 132 · A commits → OK |
| U10 | change of a two-field key: old and new values → 132 until A commits, then the old one is free |
| U11 | undone sub-transaction rekey and delete leave the original reservations correct |
Table locks, _Lock, departures, rebalance
| Id | Steps and expected result |
|---|---|
| T1 | B EXCLUSIVE on a bulk record · A deletes all bulk records in a FOR EACH ... DELETE loop → waits at that record · B releases → A completes, table empty |
| T2 | A's delete loop in an open transaction · B NO-WAIT on a deleted record → LOCKED (deleted records keep their locks until commit) · A commits → NOTFOUND |
| S1 | A EXCLUSIVE · _Lock shows one row, EXCLUSIVE, holder A · A releases → no row |
| D1 | a client (separate JVM) holds lrec 500 EXCLUSIVE and creates a key in a request, and is killed with kill -9 · B's waiting request is granted no later than the killed request's time cap · the key is gone · the server serves the next request |
| D2 | A EXCLUSIVE + key · B waits · kill -9 of A's server → B granted after the departure (waited until the kill) · A's key free · the server restarted, L7 again |
| D3 | A EXCLUSIVE on L7's 50 records · B waits on one · kill -9 of an owner server → all other locks still held (backups took over) · A releases → B granted · all free |
| R1 | A EXCLUSIVE on 50 records + 10 keys · B waits · server 3 restarted (it owns nothing) · rebalance moves lock primaries onto it (judged: at least one moved) → all locks still held, all keys still 132 · A releases → B granted |
| R2 | A EXCLUSIVE · B waits · server frozen for half the member timeout → nothing lost, probes wait for the thaw · frozen for 4 × the timeout + 4 s → the server is removed, its locks are freed only after its removal, the survivors stay consistent |
| R3 | 6 traffic loops (2 per server) lock random records of a set of 200 for 20 ms each, 3 sessions wait on records held by a holder · server 3 restarted and a rebalance run under traffic → judged: no two holders at the same time, no errors, the waiters granted, traffic completed during the rebalance, lock primaries moved |
Each cluster run has 1,089 checks: about 330 behaviour checks and about 760 clean-up checks (no lock or key left behind after each scenario), plus a run-level check that every server departure matches a kill, freeze or restart of the run. Hidden helper scenarios cover the export gate, leak detection, stale caches and a key-release regression test.
Two-host load test¶
The load test runs converted 4GL code on real FWD servers spread over two hosts, under a production-like multi-tenant load, while servers are killed, restarted, added and rebalanced. It answers two questions: do the locks and unique keys stay correct through all of that, and how long does a lock call take.
Tool: locktest/bin/fwdload.sh (preflight | deploy | db | start | calibrate | run | collect | report | stop | status), all settings in locktest/fwdload.conf (see How to run the tests).
Layout¶
| Host | What runs there |
|---|---|
| cust004 (64 cores) | 25 FWD servers (node1–node25), Geode locator 1, the "rebalancer" (a small Geode member that holds no data and starts the rebalances), PostgreSQL 16 with all databases, the load driver for half of the users |
| cust007 (128 cores) | 25 FWD servers (node26–node50), the server that joins during the run (node51), Geode locator 2, the load driver for the other half of the users |
All servers form one Geode cluster across the two hosts. Every database has its two regions (locks, unique keys), 113 buckets each, one primary and one backup copy per bucket on two different servers. The primaries are spread over all servers of both hosts, so about half of all lock calls go to a server on the other host.
Application and data¶
- The 4GL test project
lt, converted with the branch's FWD. Databases: the default databaselt, the landlord database and 50 tenant databases (one per tenant), 52 in total, all in the one PostgreSQL instance. - The table
mtis multi-tenant: 100,000 records per tenant, two unique indexes. Its record locks and unique keys live in the tenant's own regions, so the load uses all 102 regions. - Each request runs
loadhold.pthrough the server's appserver: it binds the session to the request's tenant, then runshold.p, which executes a short script of steps (FIND with SHARE-LOCK or EXCLUSIVE-LOCK, pause, CREATE, DELETE, UNDO) as one 4GL transaction and returns the server's time stamps of every step.
Virtual users¶
- 6,000 stateless users, 3,000 per host, 120 per server. A user thinks 2 s, sends one request (one transaction) to its server and waits for the answer; nothing is kept in the session between requests.
- Each server has 16 appserver sessions. The requests of its users queue for them, so a slow server shows up as latency, measured from the moment the request was due, not from when a session picked it up.
- Each request picks a random tenant, so every server works in all 50 tenants.
Transaction mix¶
| Type | Share (reference run) | What it does |
|---|---|---|
| lock-excl | 66 % | 1–8 distinct mt records, locked in ascending id order (so no record-lock deadlock), each SHARE or EXCLUSIVE with 50 % chance, at least one EXCLUSIVE; hold 20 ms; commit |
| lock-share | 10 % | the same with all records SHARE |
| lock-upgrade | 4 % | the same, then the first record is upgraded from SHARE to EXCLUSIVE; two upgraders of the same record deadlock, which a 10 s STOP-AFTER breaks (counted apart, not as an error) |
| unique-create | 9 % | creates an mt record with new unique keys |
| unique-change | 8 % | deletes a record and creates it with another id and code (release old keys, reserve new ones) |
| unique-delete | 1 % | deletes a record created earlier |
| unique-hot | 2 % | create or delete on 20 hot keys per tenant that both hosts fight over; "already exists" (error 132) and "not found" are legal answers |
- A hot set of 1,000 of the 100,000 records per tenant gets 5 % of the record choices, so some records are contended between servers and hosts.
- 5 % of all transactions end with UNDO (rollback), and another 5 % also run a sub-transaction that creates a key and is undone.
- In the reference run this was about 2,940 transactions/s, 14,000 lock acquisitions/s and 11,500 releases/s.
Timeline of a main run¶
| Phase | Duration (reference run) | What happens |
|---|---|---|
| Start and session warm-up | 12 min | all servers start; every server is warmed up in all 50 tenants on all its sessions before it gets users |
| Ramp | 2.5 min | users added gradually up to 6,000 |
| Warm-up | 5 min | full load, not judged |
| Steady | 5 min | the baseline |
| Kill on cust007 | 1 s | kill -9 of one server; its users pause, their in-flight requests fail (expected) |
| Restart, rebalance, warm-up | 15 min | the server is restarted at once; 30 s after it is ready one rebalance; the server warms up all tenants (about 12 min), then its users come back; 150 s to settle |
| Kill on cust004 | 15 min | the same on the other host |
| Join | 12 min | a new server (node51) starts on cust007; 30 s after it is ready one rebalance; after its warm-up it gets users |
| Final steady | 5 min | the second baseline, with 51 servers |
| End checks and collection | 10 min | load stopped, region contents read, logs summarized on each host and collected |
- Calibration (optional, before the main run): the load is raised step by step (5 minutes per level) until the transaction p99 exceeds 1 s or an error occurs; the main run then uses the last level that held. The reference run used a fixed 6,000 users.
How correctness is judged¶
| Check | How | Fails when |
|---|---|---|
| No exclusivity violation | hold.p returns the server time when each lock was granted (end of the locking FIND) and released (end of the hold); the drivers store every interval; at the end all intervals of the same record from both hosts are compared, with the hosts' clocks aligned through the database (re-measured every 20 s) |
an EXCLUSIVE interval overlaps another interval of the same record by more than 3 ms |
| No duplicate or lost unique key | the hot keys' final state in the database is compared with what the drivers committed; the servers' logs are searched for database unique-constraint errors | any mismatch or any constraint error |
| No unexpected error | every failed request is classified | any error on a server that was not down at the time |
| No unexpected server removal | the locators' membership views | a server removed that the run did not kill |
| Recovery | throughput in 10 s windows against the 120 s before the event | throughput not back to 90 % within 60 s after the event |
| No leak | after the load, the entries of all regions read through Geode's JMX manager | any lock or unique-key entry left |
| Tenant regions at start | regions of every server read right after start | a tenant region missing on any server |
What is measured¶
- Transactions: throughput and latency (p50, p99, max) per window and per type.
- Lock calls: every server writes latency histograms every 10 s (temporary instrumentation in the branch): the owner round trip of an acquire, by where the owner is (the same server, another server on the same host, the other host), the whole acquisition as the session sees it (waits included), releases and table-level calls. The report merges all servers per window (steady, each kill, each restart, each rebalance, the join, final steady).
- Rebalances: duration, bucket and primary moves, primaries per server before and after.
- Resources: CPU per host, GC per server and window, PostgreSQL connections, threads; Java Flight Recorder CPU profiles in separate runs.
Limits¶
- The hot set is spread over 50 tenants, so contended hand-offs between hosts are relatively rare; a defect that breaks exclusivity only in a rare failover race could pass. There is no negative control (no run injects an overlap on purpose).
- Only
mtis used: table locks and leases,lrecand bulk operations are not driven by the load (they are covered by the scenario suite). - The leak check reads the regions only at the end.
Rebalance investigation¶
A bare-Geode harness (the branch's lock and unique classes without the FWD runtime), 20 nodes and 50 tenants (100 regions) on cust004, about 3,800 lock transactions per second with the unique-key workload and a violation checker. Each rebalance variant was run under this traffic. Findings: locktest/docs/2026-10-24-rebalance-spike-findings.md.
Correctness confirmation tests¶
locktest/test/correctness.sh and CorrectnessTest.java start Geode members in separate JVMs and drive the lock manager directly. Races are forced with test hooks in the FWD code that are off unless the system property fwd.4369.test.table is set. Each case prints VERDICT <case> PRESENT or ABSENT.
Performance and leak tests¶
locktest/test/perfleak.sh and PerfLeakTest.java work the same way (Geode members as separate JVMs on one host, verdict per case). The 14 default cases measure the performance and leak issues of 2026-10-26 before and after their fixes: tenant drop/re-init (leak), purge after the cache closed (purgeclose), liveness checks (livemembers), re-acquisitions (reacquire), the admin view (admin), a failover re-execution (failover), lease give-backs with an owner that does not answer (leaserelease, sweepburst, releasetimeout), NO-WAIT table EXCLUSIVE (nowaitrevoke, nowaitidle), CTRL-C during a remote wait (interrupt), an owner crash right after CTRL-C (ghostcrash) and a failed release (failedrelease). The tombstone counts are measured by the extra case tombstones (perfleak.sh tombstones, run once with each concurrency-checks setting).
Results¶
Scenario suite¶
| Mode | Result |
|---|---|
| Single | PASS (1 known deviation) |
| Cluster | PASS, 1,089 checks, 5 known deviations (all base FWD, see below) |
| Legacy (FWD's previous manager) | 82 of 1,088 checks fail: undone unique keys stay reserved, a crashed or removed server's locks are never freed |
| Negative | the broken classes are detected |
Before the fixes described below, cluster mode failed one check (L8, a leaked table lock); the legacy comparison showed it came from the branch.
Two-host load test¶
What was tried¶
Each run changed one thing that the previous run had shown to be the main cost. All judged checks passed in every run.
| Run | Setup | Transactions / lock holds checked | What it showed | What changed next |
|---|---|---|---|---|
| 1 | 4 + 4 servers, 8,000 users, all events, 12 GB heap | 9.06 M / 34.3 M | 3,906 tx/s, p99 159 ms, but the live heap and the p99 grew over the run (p99 per 5 minutes from 128 to 914 ms): unique-key tombstones and session state | 25 + 25 servers with 6 GB heap; the new defaults from the rebalance investigation (lazy buckets, 113 buckets, bucket-count probe, tenant regions at start) |
| Calibration | 25 + 25 servers, FastFindCache on | — | 10,000 and 8,000 users failed (cust004, which also runs PostgreSQL, at 98.8 % CPU); 6,000 users held (p99 420 ms) | 6,000 users for the event runs |
| 2 | 25 + 25, 6,000 users, all events, FastFindCache on | 10.31 M / 39.0 M | lock call p99 14 ms in steady load; the CPU profile showed the FastFindCache broadcast as the main cost growing with the number of servers | FastFindCache off |
| 3 | 25 + 25, FastFindCache off, steady only | 2.87 M / 11.4 M | lock call p99 3.6 ms, CPU per transaction −33 %; still 4,243 threads per server (Geode's thread-owned connections) | conserve-sockets=true |
| 4 | 25 + 25, FastFindCache off + conserve-sockets=true, 6,000–10,000 users |
5.53 M / 21.1 M | lock call p99 3.1 ms, 1,019 threads per server; 8,000 users hold (p99 183 ms), at 10,000 cust004 saturates (p99 613 ms) | the six concurrency fixes, final build |
| 5 (reference) | final build, all recommended settings, 25 + 25 (+1 joined), 6,000 users, all events | 10.47 M / 39.0 M | see below | — |
Steady phase, 50 servers, 6,000 users:
| Measure | FastFindCache on (run 2) | Cache off (run 3) | Cache off + conserve-sockets (run 4) | Reference (run 5) |
|---|---|---|---|---|
| Transactions/s | 2,889 | 2,936 | 2,936 | 2,938 |
| Transaction p50 / p99 | 57 / 374 ms | 40 / 102 ms | 39 / 96 ms | 39 / 94 ms |
| Lock call to the owner, p50 | 1.54 ms | 0.77 ms | 0.77 ms | 0.77 ms |
| p90 | 5.1 ms | 1.3 ms | 1.3 ms | 1.3 ms |
| p99 | 12.3 ms | 3.6 ms | 3.1 ms | 3.1 ms |
| p99.9 | 41 ms | 6.1 ms | 5.1 ms | 5.1 ms |
| CPU per transaction | 37.9 core-ms | 25.3 core-ms | 23.7 core-ms | — |
| cust004 CPU busy | 92 % | 63 % | 58 % | 56 % |
| Threads per FWD server | 5,174 | 4,243 | 1,019 | — |
| Largest GC pause | 19 ms | 4.6 ms | 2.8 ms | 2.6 ms |
Reference run (run 5): final build, recommended settings¶
Build md5 5b1bfd9c66b02430c45e978f5488189d; 25 + 25 servers, a 51st joins during the run; 113 buckets, lazy bucket creation, bucket-count load probe, concurrency checks off on the lock regions, FastFindCache off, conserve-sockets=true, member timeout 1000 ms, rebalance with 8 threads 30 s after each restart or join, 50 tenants, 6 GB heap, 16 sessions per server; 6,000 users. Each setting was confirmed in the servers' logs and JVM properties. Results: locktest/results/fwdload-20261026_123644/report.txt.
| Judged check | Result |
|---|---|
| No exclusivity violation | PASS: 0 in 39.0 M lock holds (19.5 M exclusive), largest overlap 0 ms |
| No duplicate or lost unique key | PASS: 1,000 hot keys consistent with the database, 0 unique-constraint errors |
| No unexpected error | PASS: 0; the only failed requests (74) went to a killed server while it was down |
| No unexpected server removal | PASS: only the two kills, both seen as crashed members |
| Recovery after each kill, join and rebalance | PASS (6 of 6): throughput 98–100 % during the event, 100–101 % within 60 s after it |
| No leak | PASS: 0 lock or reservation entries left in the 102 regions after the load |
| Tenant regions at start | PASS: 5,000 of 5,000 (5,100 of 5,100 after the join) |
Latency per window (lock call = owner round trip of an acquire answered without waiting, over all owner locations; values are histogram bucket limits, the true value up to 20 % lower):
| Window | Lock call p50 | p99 | p99.9 | Transaction p99 | Throughput |
|---|---|---|---|---|---|
| Steady before the events (5 min) | 0.77 ms | 3.1 ms | 5.1 ms | 94 ms | 2,938 tx/s |
| Kill of a server on cust007, first 30 s | 0.90 ms | 14 ms | 1.6 s | 1.8 s | 95 % |
| Kill of a server on cust004, first 30 s | 0.77 ms | 12 ms | 1.6 s | 1.7 s | 95 % |
| Kill, restart and warm-up, cust007 (12 min) | 0.77 ms | 3.6 ms | 10 ms | 120 ms | 98 % |
| Kill, restart and warm-up, cust004 (12 min) | 0.77 ms | 3.1 ms | 8.2 ms | 112 ms | 98 % |
| Rebalance 1 (after the cust007 restart) | 0.77 ms | 3.6 ms | 6.1 ms | 100 ms | 99 % |
| Rebalance 2 (after the cust004 restart) | 0.77 ms | 4.1 ms | 7.2 ms | 104 ms | 98 % |
| Rebalance 3 (after the join) | 0.77 ms | 5.1 ms | 8.2 ms | 107 ms | 100 % |
| Join and warm-up of the new server (12 min) | 0.77 ms | 3.1 ms | 6.1 ms | 105 ms | 100 % |
| Final steady (5 min) | 0.77 ms | 3.6 ms | 7.2 ms | 110 ms | 2,938 tx/s |
| Whole run after the ramp (60 min) | 0.77 ms | 3.1 ms | 7.2 ms | 105 ms | 2,912 tx/s |
- Owner location makes almost no difference: same server p50 0.51 / p99 2.0 ms (2 % of the calls), another server on the same host 0.77 / 3.1 ms (48 %), a server on the other host 0.77 / 3.1 ms (50 %). The spread between servers is small (per-server p99 2.6–3.6 ms).
- Whole acquisition as the session sees it (table lock, waits and parks included): p50 0.90 ms, p99 4.1 ms; 0.08 % of the acquire attempts had to wait for another session's lock; 0.06 % of the acquisitions took 10 ms or more.
- After a kill the calls whose bucket was owned by the killed server wait for the failover (member timeout 1000 ms): that is the 1.6 s p99.9 and the 1.7–1.8 s transaction p99 of the first 30 s; the other calls slow down only a little (p90 1.8–3.1 ms instead of 1.3 ms) and are back to normal over the whole restart window. Throughput over the first 30 s is 95 % of the steady level, the missing part being the killed server's own users.
- Resources: cust004 (25 servers + PostgreSQL) 55 % CPU on average, cust007 (25–26 servers) 27 %; GC pauses at most 4.5 ms (0.002 % of the time); about 6,000 PostgreSQL connections (max 6,076).
- A restarted or joined server is ready in 16–21 s but needs about 700 s of tenant warm-up before it gets its full share of users (see the base FWD findings).
For comparison, the bare-Geode harness measured an acquire p99 of 1.23 ms with 50 nodes.
Failure detection and its effect on lock acquisition¶
A server that dies plays two roles for the lock manager: it is the owner (bucket primary) of about 1/N of all lock keys, and its sessions are holders of locks owned by other servers. Until Geode's membership removes it, the cluster cannot tell a dead server from a slow one, so both roles wait for the failure detection; nothing else does.
| Who | What happens until the server is removed | After removal |
|---|---|---|
| Lock calls for keys owned by the dead server (about 2 % of all calls with 50 servers) | the call gets no answer and waits | the backup copy becomes primary, the call is resent there and answered; no lock state is lost (the backup has it) |
| Sessions waiting for a lock that a session of the dead server holds | they wait | the branch frees the dead server's locks (0.04–0.13 s after the membership view) and the first waiter gets the lock |
| Lock calls for keys owned by live servers (about 98 %) | answered normally | — |
| The dead server's own users | their requests fail (expected; not a lock effect) | — |
kill -9).
- From 0 to about 1.2 s: B still keeps A's lock on record 42; a session on server C that asks for record 42 waits. Until Geode removes A from the membership, B cannot tell a dead server from a slow one, and releasing early could give the lock away while A's session still uses it.
- At about 1.2–1.5 s (member timeout 1000 ms): Geode removes A. Every surviving server gets the "member crashed" event (
LockMembershipHandler) and removes all locks and unique-key reservations of A from the entries it owns (OwnerLockManager.purgeDepartedMember,OwnerUniqueIndex.purgeDepartedMember), record 42 included; this takes 0.04–0.13 s. - At about 1.25 s: the waiting session on C gets record 42 (measured: 1.25 s with a 1000 ms member timeout, 5.3 s with FWD's default 5000 ms).
- B did not fail, so the lock entry and its queue were never in danger; only A's own locks are removed.
- A's transaction never committed: PostgreSQL rolls it back when A's database connections close, so record 42 is unchanged and can be given to the next session; A's uncommitted unique keys become free.
- The purge runs a second time a few seconds later, for entries whose bucket moved to the server in the meantime. If a lock of A is still missed, the next request that would wait behind it removes it on the spot and decides again (logged as "lock … was held by a departed member; removed"), so a dead server's lock can never block anyone for good.
- A lock request of A that arrives after A was removed is not granted, so it cannot leave a new lock behind.
- Table leases held by A are dropped by their owners the same way.
Example 2: the killed server is the owner of a lock held by another server. A session on server B holds a lock whose entry lives on A, and A is killed. The lock is not lost: the backup copy of A's bucket on another server holds the same entry. When A is removed, that backup becomes primary with B's lock still in it; calls for that bucket wait the same 1.2–1.5 s and are then answered by the new owner.
All of this was tested with kill -9, where the operating system closes the dead process's connections at once. If a whole host is lost (power or network), the lock manager still frees the locks after the member timeout, but PostgreSQL notices the dead connections only through TCP keepalive, so its own row locks could stay longer (loss of a whole host is not covered by the tests).
So the failure detection time is the extra wait of the affected lock calls:
| Member timeout | Killed server detected after | Waiting lock granted after | Frozen server removed after |
|---|---|---|---|
| 5000 ms (FWD default) | 5.3–5.5 s | 5.3 s | 14.5 s |
| 2000 ms | 2.5 s | 2.5 s | 5.0 s |
| 1000 ms | 1.2–1.5 s | 1.25 s | 2.7 s |
A frozen (hung, not dead) server takes about 2.5–3 times the member timeout to remove, because Geode first suspects it and then checks it again; lock calls for its keys wait that long.
Seen in the reference run (member timeout 1000 ms, 50 servers, 6,000 users), first 30 s after each kill:- lock-call p50 unchanged (0.77–0.90 ms): most calls are not affected;
- 1.5–2.4 % of acquisitions took 10 ms or more (0.02 % in steady load): the share of calls owned by the killed server;
- those calls waited about 1.6 s (lock-call p99.9 1.6 s), i.e. detection plus resend; transaction p99 1.7–1.8 s in the same 30 s;
- no lock lost, no exclusivity violation, no failed request except those sent to the killed server;
- throughput 95 % of the steady level (the killed server's users), 98 % over the whole restart window.
At 1000 ms there was no false removal, also with 51 members on two hosts under load (reference run: only the two kills were removed; the largest GC pause was 4.5 ms). A smaller member timeout shortens the wait but raises the risk of removing a healthy server during a long GC pause or a network hiccup; it should be checked on the production network.
CPU profile (50 servers, FastFindCache on)¶
Geode messaging was about 59 % of a server's Java CPU, the branch's lock code 2.5 %. The main per-transaction cost growing with the number of servers was FWD's FastFindCache: every record change sends a Java-serialized invalidation to all other servers. Turning it off produced the gains in the table above. Geode's thread-owned connections (conserve-sockets=false) created about 5,200 threads per server.
Rebalance¶
Background¶
The lock and unique-key state of every database lives in two Geode partitioned regions (<db>_owner_locks, <db>_unique_keys), each split into buckets with a primary and one backup copy. The primary's server answers all lock calls for that bucket. Geode restores lost backups by itself after a server leaves, but it never moves buckets or primaries to a new or restarted server: that needs a rebalance, which today is started only by an operator (gfsh rebalance) or a test.
Bucket creation (fill) modes¶
How the buckets are first created decides how much a later rebalance has to move.
| Mode | Behaviour | Result |
|---|---|---|
Eager (assignBuckets=true, the branch's original default) |
each server creates all buckets of a region as soon as it starts | the first server up holds a copy of every bucket and all tombstones; in the investigation its heap filled (OutOfMemoryError with 1 GB, allocation stalls with 2 GB) and the cluster dropped to 400–1,400 transactions/s; rebalancing out of it under load ran at 6–13 moves/s (30–60 minutes) |
Lazy (assignBuckets=false, new default) |
a bucket is created on its first key, on the least loaded server | even when all servers are up before the load: lock primaries max/avg 1.08 → 1.02 after a 10 s rebalance |
| Late (harness only) | buckets created once all members have joined | perfectly balanced (max/avg 1.01) |
With many tenants a second effect matters: FWD created a tenant database's regions on a server only at that tenant's first use there, so the first server that saw a tenant kept all its primaries. The branch now creates every tenant's regions on every server at start, from the landlord database (geode/tenantRegionsAtStart); in the 50-server run all 5,000 tenant regions were present on all servers right after start (5,100 on 51 servers after a join).
Methods tried¶
In the bare-Geode harness, all under lock traffic, 20 nodes, 100 regions, 113 buckets unless stated.
| Method | Result |
|---|---|
gfsh rebalance (one region at a time) |
about 4.5 s per region; 200 regions took 15 minutes (harness, 50 nodes, 2026-10-22) |
| API rebalance, Geode's default size-based load probe | chases the changing size of lock buckets: 23,737 moves planned for an already balanced cluster (balance got worse, 1.01 → 1.10), 24,430 after a restart (about 30 min) |
| API rebalance, bucket-count load probe | balanced cluster: 23 moves in 2.2 s; after a restart: 1,100 moves + 392 primaries |
8 resource-manager threads (gemfire.resource.manager.threads=8, API only) |
restart rebalance 65.9 s → 8.2 s (8 regions at a time); traffic 101 %, acquire p50 0.58 ms unchanged, p99 1.7 → 3.0 ms, no timeout or violation |
| Primaries only (restore redundancy with primary reassignment) | 0.35–7.5 s, but a restarted server gets no primaries (it holds no copies yet) |
| 1009 buckets (the original default) | a restart needs 7,764 moves (estimated 1,100 s with 1 thread against 65.9 s with 113 buckets); the default probe's planning alone took over 541 s |
| Concurrency checks off on the lock regions (no tombstones) | restart rebalance 4.5–5.2 s instead of 5.8–7.8 s; out of a skewed server 52 moves/s instead of 6–12; no ghost or lost lock in 4 provoked runs |
| Rebalance on every join (join ramp: 2 servers, 18 more join one by one under load) | did not converge: about 13 moves/s whatever the number of coordinators (bound by the two source servers), Geode serializes concurrent rebalances per region |
| One rebalance after the last join (same ramp) | worst: the first servers' heaps filled, cluster 800–1,400 transactions/s |
| One coordinator after a single restart or join into a balanced cluster | works: 8.2 s, traffic unaffected |
A rebalance does not interrupt lock acquisition when memory is not overloaded: in every such run throughput stayed at 99–101 % and acquire latency barely changed.
Recommended approach¶
From the methods tried, the combination that works:
| Part | Choice | Why | Status |
|---|---|---|---|
| Bucket creation | lazy (ownerLocks/assignBuckets, ownerUnique/assignBuckets = false) |
no server holds a copy of every bucket; little to move later | FWD default (changed in the branch) |
| Buckets per region | 113 (ownerLocks/buckets, ownerUnique/buckets) |
with 1009 buckets a restart needs about 7 times as many moves (7,764 against 1,100) | FWD default (changed in the branch) |
| Load probe | bucket count (geode/loadProbe = bucketcount; FWD sets it before the Geode cache is created; an explicit -Dgemfire.ResourceManager.PR_LOAD_PROBE_CLASS wins; locators and other non-FWD members need the -D themselves) |
Geode's size probe chases the changing size of lock buckets and moves tens of thousands of buckets for nothing | FWD default (changed in the branch) |
| Tenant regions | created on every server at start, from the landlord database (geode/tenantRegionsAtStart) |
otherwise the first server that sees a tenant keeps all its primaries | FWD default (changed in the branch) |
| Concurrency checks of the lock and unique-key regions | on for now (geode/ownerLocks/concurrencyChecks, geode/ownerUnique/concurrencyChecks, both default true); off only after the targeted test below |
off removes the tombstones (faster moves, smaller heap, lock/release about 25–30 % faster locally) and was used for the lock regions in the reference run, but is safe only with one redundant copy and is unproven while Geode rebuilds a lost copy (see "Concurrency checks" below) | settings; defaults unchanged |
| When to rebalance | once, through the Geode API, about 30 s after the last restart or join has settled; not once per joining server | concurrent rebalances are serialized per region and never converge during a join ramp | test tool only (a small Geode member that holds no data); a coordinator in FWD is recommended |
| Rebalance threads | 8 (gemfire.resource.manager.threads=8 on the member that starts the rebalance) |
8 regions moved at a time: 65.9 s → 8.2 s in the harness | test tool only |
Results with the recommended approach (reference run)¶
Two-host load test, run 5: 25 + 25 servers under 6,000 users, 102 regions (51 databases × lock and unique-key region), 113 buckets each, one redundant copy. Each rebalance started 30 s after the restarted or joined server was ready.
| Rebalance | Trigger | Duration | Bucket moves | Primaries moved | Balance after (max/avg primaries per server) | Lock call p50 / p99 during it | Throughput |
|---|---|---|---|---|---|---|---|
| 1 | kill and restart of a server on cust007 | 3.5 s | 410 | 174 | 1.07 | 0.77 / 3.6 ms | 99 % |
| 2 | kill and restart of a server on cust004 | 3.4 s | 408 | 176 | 1.08 | 0.77 / 4.1 ms | 98 % |
| 3 | a new (51st) server joins on cust007 | 2.9 s | 408 | 140 | 1.07 (unique-key regions) / 1.09 (lock regions) | 0.77 / 5.1 ms | 100 % |
- In steady load, without a rebalance, the lock call is 0.77 / 3.1 ms: a rebalance raises the p99 by 0.5–2 ms for about 3 s; p99.9 stays at 6–8 ms, and no request failed during a rebalance.
- Before its rebalance a restarted or joined server holds no primaries (Geode does not move buckets to a new server by itself), so it takes no lock calls and nothing waits for it. After the rebalance it holds its share (the joined server: 106 and 112 primaries against an average of 113).
- No region was ever without a host; after every rebalance all 51 lock regions had buckets on every server.
- The new server has all tenant regions and is ready 16 s after its start; it then needs about 700 s of tenant warm-up before it gets its full share of users (base FWD, see below), independent of the rebalance.
- a rebalance coordinator in FWD: one server (e.g. the oldest) starts an asynchronous rebalance through the API about 30 s after joins or restarts have settled, with 8 resource-manager threads and FWD's regions only;
- until then, an operations procedure: start all servers before users connect, and run one rebalance after a restart or a join (as the test tool does);
- before concurrency checks are turned off in production: a guard that refuses checks off with more than one redundant copy, and a test that releases locks while a new redundant copy is being built and then kills the primary (see "Concurrency checks" below).
Changes to FWD¶
All changes are in the branch's working tree on top of r16798, not committed. Current build build/lib/p2j.jar, md5 c160cf2bbf31419052f754bbbd7023d2.
| Change | Files | Status |
|---|---|---|
| Leaked table SHARE lock after a STOP-AFTER-ended record-lock wait (L8): the table lock is restored on every exit that does not grant the record lock | distributed/lock/OwnerLockManager.java |
reviewed, approved |
Unique-key reservations registered at the wrong block level: UniqueTracker.Context.register ignored the depth it was given, so keys were not given back when their transaction ended (base FWD code, all trackers) |
persist/orm/UniqueTracker.java |
reviewed, approved |
| New defaults: lazy bucket creation, 113 buckets, bucket-count load probe | OwnerLockManager, OwnerUniqueIndex, GeodeConfig, GeodeSettings |
reviewed, approved |
| Tenant regions at start | new distributed/config/TenantRegions.java; TenantManager, Persistence, UniqueTracker, OwnerLockManager, OwnerUniqueIndex |
reviewed, approved |
FastFindCache switch persistence/fast-find-cache (default on; off = no caching and no broadcast) |
persist/FastFindCache.java, distributed/cache/FastFindCacheInvalidationFunction.java |
tested |
| Six concurrency fixes (next section) | OwnerLockFunction, OwnerLockManager, LockQueue, TableLeases, OwnerLockEntry, distributed/DistributedSequence.java |
reviewed, approved after two fix rounds |
Performance, leak and robustness fixes of 2026-10-26 and the conserve-sockets default (section "Performance, leak and robustness fixes") |
OwnerLockManager, OwnerLockFunction, TableLeases, LockMembershipHandler, OwnerLockEntry, new distributed/lock/LiveMembers.java, OwnerUniqueIndex, OwnerUniqueFunction, GeodeConfig, GeodeSettings, persist/lock/LockManagerFactory.java |
reviewed; approved after one fix round, five minor findings parked |
Lock-call latency histograms (geode/ownerLocks/histogramSec) and test hooks |
OwnerLockManager, OwnerLockFunction, TableLeases |
temporary, to be removed with the other [4369-locks] instrumentation |
Concurrency issues: confirmation and fixes¶
Each issue was first shown with a deterministic test, then fixed, then re-tested.
| # | Issue | Confirmation | Fix | After |
|---|---|---|---|---|
| 1 | Duplicate session ids across servers (the id sequence was atomic only within one JVM) | 6,736 of 8,219 ids drawn more than once; two sessions with the same id and user both got EXCLUSIVE on one record | the counter moved to a partitioned region with a cluster-wide atomic update | 0 duplicates in 20,000 |
| 2 | Ghost lock: a parked request granted to a session that had given up | the session that gave up was recorded as the EXCLUSIVE holder | every owner call carries an increasing number; a give-up is a fence, older requests of that session for that key are ignored | absent |
| 3 | Queue entry left parked after an exception | a request for the free key timed out after 3 s | the owner unparks on any exception; the client sends CANCEL on any exception | granted in 1 ms |
| 4 | Table locks during a table EXCLUSIVE request: another session could get table EXCLUSIVE while the requester held record locks, and a failed request lost the table SHARE | both shown | a new owner operation (CONVERT) turns the lease-held SHARE into the session's own SHARE on the owner; the EXCLUSIVE request is then an ordinary upgrade; the special lease drain path was removed | both closed |
| 5 | Queue state shared between databases (same table and record id in two tenant databases) | a free record waited 2 s behind another database's waiter; a cross-database wait cycle formed | owner queues, parked registry and per-key locks keyed by region + key | answered in 4 ms, no cycle |
| 6 | Late lease revoke | a table SHARE waited 2.2 s without reason | revokes carry the lease's identity; stale revokes ignored; draining cleared after a failed acquire | 5 ms |
Regression after the fixes: the 33-scenario cluster suite PASS (5 known), the key-release regression scenario PASS, smoke and cluster-start tests PASS, the bare harness's test list (465 s) ALL PASS, a contended load run with 0 violations.
Follow-up fixes after the reviews (each first reproduced with a new test, then fixed):
| Review item | Before | Fix | After |
|---|---|---|---|
| The session-id counter restarted at 0 when both copies of its bucket were lost | ids 1 and 2 drawn twice while the counter's servers were killed | counter back in the replicated region (survives while any server lives), each increment under a cluster-wide Geode lock | 0 duplicates, also while the counter's servers are killed (about 2,700 ids/s) |
| A request parked on a former owner could be granted after the bucket's primary moved | the bucket moved in 64 ms and the old primary granted the lock to a session that had given up | every application runs inside Geode's primary lock of the bucket (computeWithPrimaryLocked); a server that is no longer primary drops its queue for the key and the client resends to the new primary |
absent |
| A CONVERT that failed after the owner applied it left the session's own table SHARE | a late CONVERT left a SHARE that blocked another session's table EXCLUSIVE | a fenced best-effort RELEASE on failure | nothing left; the EXCLUSIVE is granted |
| Possible deadlock between the dead-server purge and a primary move (introduced by the previous fix) | purge, request and move all stuck after 16 s | one lock order on every path: bucket primary lock, then the key's stripe, then Geode's key lock; purge goes through the same path | all finish in about 3 s |
| A bucket destroyed at the end of a move failed the call | a RELEASE failed and the lock stayed held | resent to the new primary when not yet applied | the record is free |
| The primary check silently skipped if the Geode method were missing | — | the lock manager refuses to start in cluster mode (SEVERE) | fail-safe |
Final regression: all 16 confirmation cases absent (except the one that deliberately forces a duplicate id), the 33-scenario cluster suite PASS (5 known), the key-release regression scenario, smoke and cluster-start tests PASS, the bare harness's test list ALL PASS. Final build md5 5b1bfd9c66b02430c45e978f5488189d.
- A request delayed more than 300 s (the lifetime of a give-up mark,
ownerLocks/fenceKeep), or a bucket that moves away and back within about a second, can still produce a ghost lock. - A departed server's lock can stay in an entry when its bucket moved during both purge passes; the first request that would wait for it removes it (nobody is blocked, nothing granted twice), but it shows in the lock status and
_Lockuntil then. - A lease's identity has millisecond resolution; a revoke arriving exactly while a lease is being acquired is dropped.
DistributedSecurityCacheGenerationshas the same non-atomic counter update the session-id sequence had (outside this work).- The build of these fixes passed the one-hour two-host load test with kills, restarts, a join and rebalances (reference run, 0 violations in 39 M lock holds), but that load spreads its hot records over 50 tenants; a stress run with a concentrated hot set and many hand-offs during failovers was not done. The later performance and leak fixes have passed the local regression only.
Behaviour change to note: two sessions that both hold a table SHARE lock and both ask for table EXCLUSIVE now wait for each other until a timeout or STOP, as with FWD's single-server lock manager.
Performance, leak and robustness fixes (2026-10-26)¶
The tenant lock-manager leak was found in the reference run: node1's log showed 162,347 lock managers created in one hour, about one per transaction. With stateless requests a tenant's last session ends after nearly every request, FWD drops the tenant, and the next request initializes it again; every initialization created a new OwnerLockManager and added it to a static registry that was never pruned. The other items were open points of the design review. As for the concurrency issues, each was first shown with a test (perfleak.sh), then fixed and re-tested; the before figures come from the source before the fixes.
| Item | Before | Fix | After |
|---|---|---|---|
| Tenant lock-manager leak | 20,000 tenant drop/re-init cycles: 20,000 managers registered, 20,000 start log lines, live heap +18.5 MB | one manager per database, reused across re-initializations (OwnerLockManager.forDatabase, used by LockManagerFactory); the purge threads stop quietly when the cache is closing |
1 manager, 1 log line, +2.3 MB; 0 warnings at shutdown (before: 12 in the test, about 67,000 at the end of the reference run) |
| Re-acquiring a lock of the type already held | 2,000 owner calls for 1,000 SHARE + 1,000 EXCLUSIVE re-acquisitions, 386 / 160 µs each | answered from the session's own record of its locks; upgrades, downgrades and releases still go to the owner; after a failed release the record is marked unverified and goes to the owner again | 0 owner calls, 4 / 1 µs |
| Liveness checks built the set of all members per operation (O(N)); a request from a departed member was answered WAIT until its timeout, or forever | 197 ns per check; the departed requester waited 3 s with no answer | cached member view (new LiveMembers), a member missing from the cache rechecked against Geode's current view before acting; a departed requester gets an error |
46–53 ns; error after 11 ms |
| Admin Record Locks view fetched every remote bucket on each refresh | 3,000 record locks: 1,019 messages, 137 ms | one function call per server and database (CollectFunction) |
2 messages, 18 ms, same output |
| Tombstones | no setting for the unique-key regions; tombstones not measured | setting geode/ownerUnique/concurrencyChecks (default true); measured, see "Concurrency checks" below |
20,000 releases leave 20,100 tombstones per copy with checks on, 0 with checks off |
| Lease give-backs could block a thread for about 90 s (one thread per member for all databases; give-backs also on session threads) | owner not answering for 15 s: a session's release took 15,018 ms; an unrelated table EXCLUSIVE waited 14,708 ms | give-backs on a fixed pool (ownerLocks/leaseReleaseThreads, 8) with one serial queue per owner server, failed ones resent as fences; the lease is blocked for local use until the give-back is settled (ownerLocks/leaseReleaseRetry, 30 s) |
224 ms; 4 ms |
| A NO-WAIT table EXCLUSIVE request revoked every server's lease | 1 revoke, 1 draining lease; another session's SHARE blocked 2 s | a NO-WAIT request that cannot be granted at once sends only an "idle only" revoke, applied to leases nobody holds | 0 draining leases; the SHARE took 29–95 ms |
| CTRL-C while waiting on a remotely owned key took effect only when the owner answered | interrupt 100 ms into the first wait: 951–958 ms; during a later wait: 901–904 ms | later waits on a helper thread (bounded, ownerLocks/callWaiters, 256), the session gives up at once and sends CANCEL; the first wait on a remote owner parks at most 100 ms (ownerLocks/firstParkRemote) |
56–60 ms; 2 ms |
conserve-sockets had to be set on the command line |
Geode's default false | FWD sets it to true (geode/conserveSockets); an explicit -Dgemfire.conserve-sockets wins; locators do not need it |
true on every server of the cluster suite; -D…=false still wins |
Review. An independent review asked for one fix round with three important findings, each reproduced with a new test before the fix:
| Finding | Before | Fix | After |
|---|---|---|---|
| After a CTRL-C the abandoned request could still be re-executed by Geode on a new owner when the old owner crashed, and grant a lock the session had given up (nothing would release it) | ghostcrash: the new owner held the record for the session that gave up; another session timed out |
a parked ACQUIRE that Geode re-executes after its owner left is answered WAIT without being applied; a still-waiting session asks again | entry empty, the other session granted |
| The give-back pool was unbounded: the idle sweep could start one thread per lease | sweepburst (400 idle leases): 77 threads |
fixed pool fed by a serial queue per owner server | 4 threads |
| After a failed RELEASE the local answer of a re-acquire could let two sessions hold the same record | failedrelease: both sessions held it |
the record is unverified until the owner answers again | the second session gets UNAVAILABLE |
Seven minor findings were fixed in the same round, among them a table SHARE request now respects its lock timeout while a give-back is unsettled (9,533 ms → 999 ms with a 1 s timeout) and a NO-WAIT retry is no longer starved by idle leases (30 tries in 3 s → 2 tries in 115 ms). The re-review approved the round.
Regression after the round: the 14 performance and leak cases and the 16 concurrency cases as expected, the 33-scenario cluster suite PASS (5 known), the key-release regression scenario, smoke and cluster-start tests PASS, the bare harness's test list ALL PASS; every server writes its start line once and runs with conserve-sockets true.
- An owner that hangs without leaving the cluster turns its give-back queue into a chain of about 90 s per lease (callAttempts × callTimeout); fail fast suggested.
- After a failed give-back the next lease request is not fenced, so a late RELEASE of the failed give-back can remove the new lease (existed before; a fenced lease request suggested).
- The unverified mark is not applied to table locks held on the owner (no regression; table locks were answered locally before).
- An idle-only revoke ignores leases the owner still lists after a failed give-back, so a NO-WAIT retry can still fail against them.
- A table SHARE wait and the record wait can together take up to twice the lock timeout.
- Not fixed by design: after a Geode auto-reconnect the lock manager keeps the old region and member id; its calls now fail with "left the cluster" instead of waiting. Recommended:
disable-auto-reconnect=truewith a supervisor that restarts the server.
Concurrency checks¶
Measured with two members: with checks on, 20,000 released locks leave 20,100 tombstones per copy and 20,000 released unique-key reservations 20,000 more (kept up to 8 minutes, until Geode's tombstone collection); with checks off, none, and the lock/release and reserve/release loops ran about 25 % and 30 % faster (one run each). A lock call applied on an owner that dies before answering and re-executed by Geode on the new owner is applied exactly once with checks on and off (failover), because the lock manager compares entry values and never relies on Geode's entry versions.
Verdict: checks off is safe with one redundant copy (the default) and not with two or more, where the surviving copies can differ after a failure and only the version checks reconcile them. It is unproven while Geode builds a new redundant copy (after a server loss or during a rebalance): a lock released during the copy could survive in the new copy, and after a failover to it come back as a lock nobody releases. The defaults stay on; before switching them off, a guard against more than one redundant copy and a test of that window are needed.
Findings outside the lock manager (base FWD)¶
- FastFindCache in cluster mode broadcasts every record change to all other servers (see above); the largest performance cost at 50 servers.
- Tenant warm-up: a restarted or new server needs about 700 s (14 s per tenant for 16 sessions) before it carries its full load; cause not yet isolated (connection pool of 2 per tenant database, tenant binding or a lock).
- NullPointerException in
RecordMeta.composeValidationSQLs:694on the first concurrent unique-key validation after a server start. - Database connections grow as servers × tenant databases (6,076 PostgreSQL connections at 50 servers × 51 databases).
- Threads: about 412 connection-pool helper threads per server (one pool per database).
- Stale record caches across servers (known deviations F11, F21),
_Lockshows only the local server's locks (F20),_Lock-Typeis never written (F12),DELETE FROMdeletes nothing (P3), servers ignore SIGTERM for 30 s. - FWD's previous unique index never frees the key of an undone create.
- Tenant re-initialization per request: with stateless requests a tenant is dropped and initialized again after nearly every request (found through the lock-manager leak above). FWD's previous distributed lock manager (
DistributedInMemoryLockManager) is also created at every initialization and adds a cache listener to the database's lock-table region that is never removed (from the code, to confirm at runtime); with many short sessions every lock-table update would run a growing number of listeners.
Recommendations¶
- Remove the temporary histograms, counters and test hooks, run the local regression and one two-host load run with the current build and the recommended configuration (it also confirms that the heap no longer grows), then commit the branch changes. The unique-key block-level fix and the tenant and FastFindCache changes touch base FWD code and should also be seen by a trunk reviewer and go through FWD's standard regression testing.
- Keep the new defaults: lazy buckets, 113 buckets, bucket-count load probe, tenant regions at start, one lock manager per database,
conserve-sockets=true. - Add a rebalance coordinator: one rebalance through the API, 8 resource-manager threads, about 30 s after a restart or join (see Rebalance); 2.9–3.5 s with 51 servers in the reference run.
- Fix the FastFindCache broadcast for cluster mode (batching, compact serialization or a per-table version), or run clustered servers with
persistence/fast-find-cache=false(as in the reference run). - Member timeout: start with 2000 ms in production, 1000 ms after checking it on the production network (no false removal at 1000 ms on the test hosts).
- Keep concurrency checks on (the default) until the guard and the copy-rebuild test of "Concurrency checks" are done.
- Run with Geode's
disable-auto-reconnect=trueand a supervisor that restarts a server that leaves the cluster; keep network partition detection on and two locators on different hosts. - Decide on the parked minor findings; the unfenced lease request after a failed give-back and the unverified mark for table locks touch lock correctness and are small to fix.
- Start all servers before users connect; plan host capacity with headroom (the host also running PostgreSQL saturated first).
- Investigate the tenant warm-up time and the
RecordMetaNullPointerException.
Not covered¶
- Races that the fault-injection tests do not target, and a randomized stress test against a reference model.
- A network split between hosts, loss of a whole host, redundancy 2, several permanent databases at once.
- The 33 scenarios on the two hosts; the tenant scenarios (M1, M2) and a schema with many tables per tenant database.
- Comparison with OpenEdge (lock grant order, message 121).
- Long runs (the longest load run was about one hour).
- Concurrency checks off while Geode builds a new redundant copy, followed by a failover (see "Concurrency checks").
- The 2026-10-26 performance and leak fixes under the two-host load.
How to run the tests¶
All commands run on the development machine, from the branch directory (/home/es/gcd/branches/4369d). The README (locktest/README.md) has the same instructions.
Functional scenarios and concurrency tests (local, no hosts needed)¶
./gradlew jar # FWD build: build/lib/p2j.jar bash locktest/bin/fwdtest.sh build # the converted test project # the 33 scenarios (also --mode=single, --mode=legacy); about 10 min in cluster mode bash locktest/bin/fwdtest.sh start --mode=cluster bash locktest/bin/fwdtest.sh run --mode=cluster bash locktest/bin/fwdtest.sh stop # the concurrency confirmation tests (each case prints VERDICT <case> PRESENT|ABSENT) bash locktest/test/correctness.sh # the performance and leak tests (14 cases; or name cases, e.g. perfleak.sh leak ghostcrash) bash locktest/test/perfleak.sh
Results: locktest/results/fwd-<time>-<mode>/ (the RESULT: line of driver.log), locktest/results/correctness-<time>/ and locktest/results/perfleak-<time>/.
Two-host load test: prerequisites¶
1. Tunnels to both hosts (with your own login on the jump host, never -l mfg):
ssh www.goldencode.com -p 2222 -l es -NC -f -L2227:192.168.1.83:22 # cust004 ssh www.goldencode.com -p 2222 -l es -NC -f -L2228:192.168.1.86:22 # cust007
2. The ssh master connection to cust007. The scripts reach cust007 only through it; open it in your own terminal and type the password once:
ssh -o ControlMaster=yes -o ControlPath=/tmp/cm-mfg-cust007 -o ControlPersist=12h -fN -p 2228 mfg@localhost ssh -o ControlPath=/tmp/cm-mfg-cust007 -O check -p 2228 mfg@localhost # must say "Master running"
3. PostgreSQL 16 and the firewall rules on cust004 (admin, sudo). The firewall rules are not persistent and disappear on a reboot of cust004. Check:
ls /usr/lib/postgresql/16/bin/initdb sudo iptables -S | grep 192.168.1.86
Expected: INPUT tcp 5440 from 192.168.1.86; INPUT and OUTPUT for 10334:10335, 20000:20099 and 21000:21099 (TCP, and UDP for 21000:21099) from and to 192.168.1.86; OUTPUT RELATED,ESTABLISHED. PostgreSQL 16 is installed from the Ubuntu packages without its system cluster (create_main_cluster = false in /etc/postgresql-common/createcluster.conf before apt-get install postgresql-16). A missing rule is added by the admin, e.g.:
sudo iptables -I INPUT -p tcp -s 192.168.1.86 --dport 5440 -j ACCEPT
4. The FWD build to test in build/lib/p2j.jar (./gradlew jar); the report records its md5.
Two-host load test: settings¶
All settings are in locktest/fwdload.conf; any of them can be overridden on the command line (e.g. SERVERS_H1=6 bash locktest/bin/fwdload.sh start). The recommended configuration:
| Setting | Value | Meaning |
|---|---|---|
| SERVERS_H1, SERVERS_H2 | 25, 25 | FWD servers on cust004 and cust007 |
| BUCKETS | 113 | buckets of the lock and unique-key regions |
| LOAD_PROBE | org.apache.geode.internal.cache.partitioned.BucketCountLoadProbe |
bucket-count load probe on every member |
| LOCK_CONCURRENCY_CHECKS | false | no tombstones in the lock regions |
| FFC | false | FastFindCache off |
| CONSERVE_SOCKETS | true | shared Geode connections |
| MEMBER_TIMEOUT_MS | 1000 | failure detection |
| REBALANCE_THREADS, REBALANCE_DELAY_SEC | 8, 30 | rebalance after a restart or join |
| TENANTS | 50 | tenant databases |
| SERVER_HEAP_MB, SESSIONS | 6144, 16 | per FWD server |
The load mix (THINK_MS, HOLD_MS, MAX_RECORDS, SHARE_RATIO, UPGRADE_RATIO, HOT_RECORDS, HOT_RATIO, UNIQUE_RATIO, ROLLBACK_RATIO, SUBUNDO_RATIO), the timeline (RAMP_SEC, WARMUP_SEC, STEADY_SEC, RECOVER_SEC, FINAL_STEADY_SEC) and the calibration (CAL_USERS, CAL_DOWN, CAL_HOLD_SEC) are in the same file.
Two-host load test: run¶
cd /home/es/gcd/branches/4369d/locktest bash bin/fwdload.sh preflight # hosts, ssh master, Java, limits, free ports, PostgreSQL, firewall probes bash bin/fwdload.sh deploy # copies the build and the test project to ~/4369d/fwdload on both hosts bash bin/fwdload.sh db init # first time only, or to start fresh: lt + 50 tenant databases + landlord bash bin/fwdload.sh start # PostgreSQL, locators, all servers, rebalancer, configuration and tenant-region checks bash bin/fwdload.sh calibrate # optional (about 30 min): the user level that holds (p99 under 1 s, no errors) bash bin/fwdload.sh run # main run: load, kills, restarts, join, rebalances, judged checks # bash bin/fwdload.sh run --users=6000 (a fixed level instead of the calibrated one) bash bin/fwdload.sh collect <RUNID> # RUNID is printed by "run", e.g. fwdload-20261026_140000 bash bin/fwdload.sh report <RUNID> # (re)analyses into results/<RUNID>/report.txt bash bin/fwdload.sh stop # stops everything on both hosts (recorded PIDs only) bash bin/fwdload.sh status # nothing should be alive afterwards
Exit codes: 0 OK / PASS, 2 a judged check failed, 3 needs the user (the cust007 master connection is gone or a host is unreachable), 1 another failure. Timing: start takes 5–15 minutes (tenant warm-up included), run about 85 minutes with 25 + 25 servers (the reference run: 12 minutes of session warm-up, 63 minutes of load, 10 minutes of checks and collection; a restarted or joined server warms up for about 12 minutes before it gets users).
Two-host load test: results¶
locktest/results/<RUNID>/report.txt: the judged checks (CHECK load/...PASS or FAIL), the numbers (INFOlines), the lock-call section per window and a finalRESULT:line.- Raw summaries next to it:
lockcalls-10s.tsv.gz,lockcalls-by-server.tsv.gz,gc-per-server.txt,rebalance-balance.txt,tenant-regions.tsv. - The full server logs stay on the hosts under
~/4369d/fwdload/fwd/; only a slim copy is collected.
Notes¶
- On cust007 only
~/4369dis used; another application of the same user runs there and must not be touched. - After an interrupted run always run
fwdload.sh stopandstatus. - To test another FWD build, rebuild and run
deployagain.
References¶
locktest/docs/2026-10-23-fwd-4gl-scenarios-design.md— design of the scenario suite and the load testlocktest/docs/2026-10-23-fwd-spike-notes.md— FWD behaviour findings (P1–P9, F1–F25)locktest/docs/2026-10-24-rebalance-spike-findings.md— rebalance investigationlocktest/.sdd/2026-10-23-fwd-4gl-scenarios-plan/fwdload-report.md— load test runs, lock-call tables, CPU analysislocktest/.sdd/2026-10-23-fwd-4gl-scenarios-plan/correctness-report.mdandcorrectness-review.md— concurrency issueslocktest/.sdd/2026-10-23-fwd-4gl-scenarios-plan/fwd-fixes-defaults-report.md— leaked table lock, key release, defaultslocktest/.sdd/2026-10-23-fwd-4gl-scenarios-plan/perf-leak-report.mdandperf-leak-review.md— performance, leak and robustness fixes, their review and re-reviewlocktest/results/fwdload-20261026_123644/report.txt— the reference run (final build, recommended settings)locktest/results/— run results