WIP: test: add independent-node presence workload #49
No files matched your search
@@ -0,0 +1,264 @@
|
||||
# Independent-node presence workload
|
||||
|
||||
This fixture targets QQQ #1302/#1291. It runs 2–8 independent JVM HTTP servers
|
||||
against an owned loopback Redis container. Every node serves the same configured
|
||||
mock identities across 2–8 tenant scopes. The scope comes from the authenticated
|
||||
fixture identity, never a supplied tenant parameter. Records are independently
|
||||
seeded in memory; this does not qualify shared database or production IAM behavior.
|
||||
|
||||
Build with Java 21:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd test-compile dependency:build-classpath '-Dmdep.includeScope=test' '-Dmdep.outputFile=target/capacity-classpath.txt'
|
||||
python docs/performance/presence_nodes.py --java /path/to/java --nodes 3 --tenants 2 --users 6 --tabs 4 --rounds 100
|
||||
```
|
||||
|
||||
Four tabs per user means two duplicate tabs on each of two records. Every round
|
||||
renews each issued lease on a rotating node, with sequential per-tab sequences.
|
||||
All nodes must then return exactly the expected tenant's distinct people and
|
||||
exactly two editors on both records. Any mismatch or request failure fails the
|
||||
run. Requests use bounded worker concurrency and ten-second timeouts. Each node
|
||||
has eight pooled Redis connections, a 500 ms pool wait and a 384 MiB heap ceiling.
|
||||
|
||||
Results and per-node logs are retained under `target/capacity-nodes-<id>`.
|
||||
The result records parameters, node PIDs, elapsed time, sample counts and
|
||||
nearest-rank HTTP p50/p95/p99/max. Renewal throughput includes snapshot checks
|
||||
and is not maximum server throughput. All owned JVMs and the Redis container are
|
||||
removed in `finally`; the receipt reports cleanup separately from test success.
|
||||
|
||||
Each fixture JVM emits resource samples every 100 ms using Java management APIs
|
||||
and the Redis QBit's existing pool metrics. The receipt summarizes sampled heap
|
||||
used/committed bytes, active/idle/waiting pool counts and the configured maximum.
|
||||
CPU core equivalents are process CPU time divided by observed wall time; they
|
||||
can exceed one for a multithreaded JVM. Observation begins after server startup
|
||||
and includes lease setup and workload execution. Unsupported CPU counters are
|
||||
reported as null. Linux fixtures also read process `VmRSS` from `/proc/self/status`
|
||||
every 100 ms and record `residentBytes_sampled_max`; unsupported platforms report
|
||||
null. Linux-container runs require resident-memory samples. Heap is not total
|
||||
resident memory, and process RSS is not the container's complete cgroup footprint.
|
||||
Sampled peaks may miss
|
||||
short bursts and must not be represented as exact maxima. Raw samples remain in
|
||||
node logs; missing node samples or a sampled pool-bound violation fail the run.
|
||||
|
||||
The final Redis INFO capture records `used_memory`, lifetime `used_memory_peak`,
|
||||
and current `used_memory_rss`, plus process user/system CPU seconds since Redis
|
||||
startup. Redis RSS is an end-of-run observation, not a sampled peak. These fresh,
|
||||
owned Redis instances include fixture setup and diagnostics in their lifetime;
|
||||
do not interpret those CPU values as workload-only measurements or add independent
|
||||
process peaks and call the sum a measured simultaneous maximum. Missing Redis
|
||||
memory/CPU diagnostics fail acceptance while cleanup still runs.
|
||||
|
||||
The [100-user resource run](results/linux-100-users-resources.json) used three
|
||||
Linux JVMs, two tenants with 50 people each, four leases per person, 16 request
|
||||
workers and 200 rounds. All 80,000 renewals and 2,400 exact-membership snapshots
|
||||
passed in 102.568 workload seconds. Renewal p50/p95/p99/max were
|
||||
11.326/21.765/25.881/1,007.404 ms; snapshot p99 was 19.424 ms. The one-second
|
||||
renewal maximum is retained, not discarded. Cleanup passed.
|
||||
|
||||
Sampled JVM RSS peaks were 662,786,048 / 662,188,032 / 703,479,808 bytes
|
||||
(approximately 632–671 MiB), while used-heap peaks were about 171–177 MiB.
|
||||
CPU observations were 1.73–1.83 core equivalents per JVM; sampled pool-active
|
||||
peaks were 7/7/6 of eight with no sampled waiters. Redis peak allocated memory
|
||||
was 2,579,896 bytes; final RSS was 8,777,728 bytes. Redis process CPU since
|
||||
startup was 23.738 system plus 35.526 user seconds. These measurements include
|
||||
fixture overhead, omit browser load, and do not establish deployment limits.
|
||||
|
||||
Initial smoke run: three JVMs, two tenants, six users each, four tabs per user,
|
||||
three rounds: 144 renewals and 36 snapshots passed with tenant isolation,
|
||||
duplicate-tab deduplication and two editors retained. Cleanup passed. This short
|
||||
run validates the harness, not sustained capacity or operational thresholds.
|
||||
|
||||
A second run with 12 users per tenant and 100 rounds passed 9,600 renewals and
|
||||
1,200 snapshots in 23.110 seconds. Renewal p50/p95/p99 were 7.246/16.454/21.853 ms;
|
||||
snapshot p50/p95/p99 were 12.908/15.776/18.239 ms. Every round checked exact
|
||||
tenant membership, deduplication and two editors on both records at every node.
|
||||
Cleanup passed. These are measurements from one local Windows/Docker run;
|
||||
they do not establish deployment limits or browser propagation latency.
|
||||
|
||||
## Sustained-run limitation under investigation
|
||||
|
||||
The 1,000-round workload has intermittently failed with HTTP 503 on lease
|
||||
renewal. Four runs failed; one completed 96,000 renewals and 12,000 snapshots.
|
||||
The passing run does not resolve the failures. Temporary local diagnostics on
|
||||
the third failure identified `JedisConnectionException` caused by
|
||||
`SocketTimeoutException` while reading the `EVALSHA` response in
|
||||
`RedisPresenceStore.mutate`. The configured socket timeout remained two seconds.
|
||||
The temporary production-file diagnostics were removed after identifying this
|
||||
path. No timeout increase, hidden request retry or production fix is included.
|
||||
|
||||
Resource samples did not show pool waiters, but cannot rule out short bursts.
|
||||
Redis slow-command timings and latency events are now captured before fixture
|
||||
cleanup, alongside Redis CPU, memory and command counters, to distinguish server
|
||||
execution cost from transport or scheduling delays. Slow-command summaries omit
|
||||
command arguments. These diagnostics belong only to the disposable fixture.
|
||||
Until the cause is established and qualified, this workload establishes no
|
||||
supported sustained throughput or production alert thresholds.
|
||||
|
||||
The timing-instrumented Windows-JVM run failed at round 480, with no commands
|
||||
at or above the configured one-millisecond Redis slow-log threshold and no
|
||||
latency-monitor events. Redis reported about 93 microseconds per `EVALSHA` on
|
||||
average. This weakens a slow-Lua explanation but does not identify the delayed
|
||||
component or exclude time spent outside command execution.
|
||||
|
||||
## Linux comparison
|
||||
|
||||
`--node-image eclipse-temurin:21-jre` optionally runs each JVM in its own Linux
|
||||
container on an owned Docker network shared with Redis. The HTTP driver remains
|
||||
on the host. Only loopback HTTP ports are published. Compiled fixture classes
|
||||
and copied dependency JARs are mounted read-only; Redis remains disposable.
|
||||
The existing loopback defaults remain unchanged when this option is absent.
|
||||
Container mode records Docker client PIDs separately from container identities.
|
||||
It removes the Windows-JVM-to-published-Redis-port path, but also changes the JVM
|
||||
platform and scheduling environment; it is a diagnostic comparison, not proof
|
||||
of a specific operating-system or Docker defect. Cleanup includes every owned
|
||||
node container and the owned network.
|
||||
|
||||
`--churn-every 20` departs all tabs belonging to the last user in each tenant
|
||||
every 20 rounds. Every departed lease must reject a later renewal with HTTP 409
|
||||
and `PRESENCE_LEASE_EXPIRED` on another node. All surviving nodes must show that
|
||||
user absent on both records, then show the original exact membership after
|
||||
fresh, distinct leases are acquired and activated. Only this explicit rejoin
|
||||
path acquires new leases; the node-crash path preserves existing ones.
|
||||
|
||||
`--pause-redis` adds a controlled failure phase after the ordinary rounds. It
|
||||
freezes only the owned Redis container, sends up to 64 concurrent renewal
|
||||
requests, and requires HTTP 503 within an eight-second fixture bound. Snapshots
|
||||
must also fail explicitly rather than return an empty room. Redis is unpaused
|
||||
in `finally`; once reachable, higher-sequence renewals must recover the same
|
||||
leases and exact membership. The test requires observed pool waiters and rejects
|
||||
sampled active counts above the configured maximum. It does not retry failed
|
||||
requests invisibly or raise production timeouts.
|
||||
|
||||
The [Linux Redis-stall receipt](results/linux-redis-stall.json) passed with three
|
||||
nodes, two tenants, 24 users and 96 tab leases. All 96 stalled renewals and all
|
||||
three node snapshots returned the expected unavailable response. Existing
|
||||
leases recovered after Redis resumed, and cleanup passed. This proves bounded
|
||||
failure and recovery for an injected stall; it does not explain the intermittent
|
||||
Windows timeout or establish a saturation throughput threshold.
|
||||
|
||||
```powershell
|
||||
python docs/performance/presence_nodes.py --java java --node-image eclipse-temurin:21-jre --nodes 3 --tenants 2 --users 12 --tabs 4 --rounds 1000
|
||||
```
|
||||
|
||||
The first Linux comparison completed 1,000 rounds in 250.811 seconds: 96,000
|
||||
renewals and 12,000 snapshots, with exact membership/editor assertions and
|
||||
cleanup passing. Renewal p95/p99 were 15.331/17.760 ms. The sampled active pool
|
||||
peak was five of eight, with no sampled waiters. This one run is separate from
|
||||
the unresolved Windows failures and does not establish a production SLA.
|
||||
|
||||
`--crash-round 50` abruptly kills the first node before that round, then sends
|
||||
all existing leases through surviving nodes. It does not acquire replacement
|
||||
leases or hide failed requests with retries. Exact tenant membership, duplicate
|
||||
tabs and editor counts must remain correct on every surviving node. This tests
|
||||
HTTP lease continuity after process loss, not browser/SSE reconnect, ingress
|
||||
failover, database durability or a Redis outage.
|
||||
|
||||
The Linux crash run passed 100 rounds (9,600 renewals and 996 snapshots) in
|
||||
25.148 seconds. The first JVM exited with code 137 at round 50; the remaining
|
||||
two retained all issued leases and expected membership. Cleanup passed.
|
||||
|
||||
A [combined churn and node-loss run](results/linux-node-crash-churn.json) passed
|
||||
100 rounds with the first node killed at round 50 and churn every 20 rounds.
|
||||
Five cycles rejected all 40 departed handles and checked absence/restored
|
||||
membership across both tenants and records on every surviving node. Cleanup
|
||||
passed. This is bounded HTTP lifecycle qualification; browser reconnect and
|
||||
missed-event behavior remain separate requirements.
|
||||
|
||||
Recorded receipts retain both successful and failing evidence:
|
||||
|
||||
- [Windows timeout with Redis diagnostics](results/windows-timeout.json)
|
||||
- [Linux 1,000-round run](results/linux-1000-rounds.json)
|
||||
- [Linux node crash](results/linux-node-crash.json)
|
||||
|
||||
Only the Java executable path is normalized in these receipts. The Linux image
|
||||
used for these runs was
|
||||
`eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99`.
|
||||
|
||||
## Browser propagation baseline
|
||||
|
||||
`browser_subscription_affinity.mjs FRONTEND_REPO BASE_URL RESULT_PATH` verifies
|
||||
the current owning-node subscription contract against the two-node fixture. It
|
||||
uses the connection ID from the real browser's successful subscription request,
|
||||
then preserves the same session cookies while changing only the test routing
|
||||
cookie. The [published-artifact receipt](results/browser-subscription-affinity.json)
|
||||
records owner 200, other node 404, owner 200, with the requested channel active
|
||||
on both successful responses and the unsaved draft retained. This proves why
|
||||
subscription requests need owning-node routing even when presence state is shared.
|
||||
It does not test the deployed Traefik cookie, HTTPS, buffering or failover.
|
||||
The packaged runner accepts this script through `--affinity-script`.
|
||||
|
||||
`browser_presence_latency.mjs` measures Chromium field-focus changes until a
|
||||
different editor sees the corresponding remote marker. Supply a caller-owned
|
||||
two-node loopback fixture serving the published frontend with `alice`/`admin`
|
||||
mock identities and the acceptance router's `presence_node=first/second`
|
||||
affinity cookies:
|
||||
|
||||
```powershell
|
||||
node docs/performance/browser_presence_latency.mjs /path/to/frontend http://localhost:PORT result.json 25
|
||||
```
|
||||
|
||||
Use `presence_router.mjs FIRST_NODE_PORT SECOND_NODE_PORT ROUTER_PORT` for this
|
||||
test. It binds only to loopback and adds a test-only POST endpoint that severs
|
||||
first-node SSE responses. Never deploy this fault-injection router. The packaged
|
||||
acceptance fixture accepts it through `--router-script` alongside `--latency-script`.
|
||||
|
||||
The test verifies the target marker is absent before every change, the old
|
||||
marker disappears afterward, both real SSE streams opened successfully,
|
||||
issued-lease writes reached distinct nodes, and unsaved drafts survive. API
|
||||
responses are not mocked. The fixture owns backend/router/Redis cleanup; this
|
||||
script owns and closes its browser.
|
||||
|
||||
After timing the normal transitions, the script takes the observer offline and
|
||||
explicitly severs its existing SSE response through the fixture router. It requires
|
||||
an observed stream failure, a failed offline fetch, three acknowledged sender focus
|
||||
changes, and absence of the new marker while disconnected. After network restoration
|
||||
it requires a new successful SSE response and the current marker with the previous
|
||||
marker removed, without reload or loss of either unsaved draft. Recovery may use
|
||||
the shipped reconnect and polling behavior; this does not prove event replay.
|
||||
|
||||
The [disconnect/recovery receipt](results/browser-reconnect.json) passed against
|
||||
the public RC37 JAR: one stream severed, three missed changes, recovery in 1,858 ms,
|
||||
both drafts preserved. The accompanying six-user and Redis admin checks passed;
|
||||
fixture cleanup passed. This is one Chromium disconnect case without concurrent
|
||||
load, not a reconnect storm, lease-expiry test, or production recovery objective.
|
||||
|
||||
Append `load` to the browser command (or use `--latency-load` on the packaged
|
||||
fixture) to add 16 concurrent HTTP lease workers: four mock identities with four
|
||||
leases each on the same record, split between both nodes. Each worker renews
|
||||
sequentially with a 100 ms pause after each response. The browser observations
|
||||
are spread over at least 60 seconds; every observation interval must include
|
||||
completed background renewals. The workload continues during disconnect/reconnect.
|
||||
Any request or lease-cleanup failure fails the test. Responses are disposed after
|
||||
reading, and workers stop before the contexts close. The receipt reports completed
|
||||
renewals, observed node counts, elapsed time and any errors. These are HTTP load
|
||||
workers, not 16 additional browser tabs or distinct people.
|
||||
|
||||
The [bounded load/reconnect receipt](results/browser-load-reconnect.json) passed
|
||||
9,046 renewals over 69.728 seconds, with no worker or lease-release errors.
|
||||
Across 25 observed focus transitions, p50/p95/p99 were 32.384/85.391/785.495 ms;
|
||||
p99 is the maximum of this small sample, not a reliable tail-latency estimate.
|
||||
The observer recovered in 1,855.887 ms after missing three updates while load
|
||||
continued. Both drafts survived. Six-user and Redis admin acceptance also passed
|
||||
against the public RC37 JAR; fixture cleanup passed. An initial run failed only
|
||||
because the new load helper used the wrong lease-release endpoint; that receipt
|
||||
is retained in the local packaged fixture history, not counted as a passing run.
|
||||
|
||||
The [25-transition receipt](results/browser-focus-25.json) passed against the
|
||||
downloaded public RC37 JAR and published backend snapshots. Focus-to-observed-DOM
|
||||
p50/p95/p99 were 31.648/33.078/36.092 ms; p99 is the maximum in this small sample.
|
||||
There were no page errors or failed lease responses. Six-user presence and Redis
|
||||
admin browser checks also passed in the same fixture run; fixture cleanup passed.
|
||||
|
||||
This baseline includes browser automation, UI debounce, network, invalidation
|
||||
and rendering. It is not a server-only transport measurement, an SSE-only causal
|
||||
proof, an under-load result, or a production latency objective. The separate load
|
||||
receipt above covers one bounded workload and disconnect scenario; larger loads,
|
||||
reconnect storms and deployment-specific behavior remain unqualified.
|
||||
|
||||
Remaining qualification includes longer soak and larger workloads, container
|
||||
resource limits, reconnect storms and the actual deployment's capacity and alert
|
||||
thresholds. The receipts above cover specific bounded HTTP, browser, saturation,
|
||||
churn, missed-update and process-crash cases, not every combination. The native
|
||||
Windows intermittent Redis read timeout remains unresolved. Loopback HTTP latency must not be reported
|
||||
as browser propagation or a production SLA. No deployed capacity threshold is
|
||||
established by this fixture.
|
||||
@@ -0,0 +1,145 @@
|
||||
// Real UI observations against a caller-owned two-node acceptance fixture.
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { startPresenceLoad } from './browser_presence_load.mjs';
|
||||
|
||||
const [frontendRepo, base, output, countValue = '25', loadMode] = process.argv.slice(2);
|
||||
if (loadMode && loadMode !== 'load') throw new Error('Optional final argument must be load');
|
||||
const samples = Number(countValue);
|
||||
if (!frontendRepo || !base || !output || !Number.isInteger(samples) || samples < 5 || samples > 100) {
|
||||
throw new Error('Expected frontend repo, loopback base URL, result path and 5..100 samples');
|
||||
}
|
||||
if (!['localhost', '127.0.0.1'].includes(new URL(base).hostname)) {
|
||||
throw new Error('Only the disposable loopback fixture is supported');
|
||||
}
|
||||
const require = createRequire(resolve(frontendRepo, 'package.json'));
|
||||
const { chromium, expect } = require('@playwright/test');
|
||||
const result = { status: 'FAIL', samples: [], streams: [], requests: [], errors: [],
|
||||
scope: 'Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load' };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
let load;
|
||||
try {
|
||||
async function editor(user, node) {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([
|
||||
{ name: 'collab_dev_user', value: user, url: base },
|
||||
{ name: 'presence_node', value: node, url: base },
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => result.errors.push(error.message));
|
||||
page.on('response', response => {
|
||||
const path = new URL(response.url()).pathname;
|
||||
if (path.endsWith('/realtime/stream')) {
|
||||
result.streams.push({ user, status: response.status(), type: response.headers()['content-type'] });
|
||||
}
|
||||
if (path.includes('/presence/leases')) {
|
||||
result.requests.push({ user, status: response.status(), node: response.headers()['x-acceptance-node'] });
|
||||
}
|
||||
});
|
||||
await page.goto(`${base}/app/person/1/edit?createMockSession=true`);
|
||||
await expect(page.locator('[data-qqq-id="firstName"]')).toBeVisible();
|
||||
return page;
|
||||
}
|
||||
const observer = await editor('alice', 'first');
|
||||
const sender = await editor('admin', 'second');
|
||||
await observer.locator('[data-qqq-id="firstName"]').fill('Retained observer draft');
|
||||
await sender.locator('[data-qqq-id="firstName"]').fill('Retained sender draft');
|
||||
await expect(observer.locator('[data-presence-field="firstName"]')).toContainText('Demo Administrator');
|
||||
if (loadMode) {
|
||||
load = await startPresenceLoad(browser, base, expect);
|
||||
result.load = load.state;
|
||||
result.scope = 'Chromium focus-to-remote-DOM and reconnect under 16 concurrent HTTP lease workers; loopback, four background identities';
|
||||
}
|
||||
for (let index = 0; index < samples; index++) {
|
||||
const previousCompleted = load?.state.completed;
|
||||
if (load) await delay(60000 / samples);
|
||||
const field = index % 2 === 0 ? 'lastName' : 'firstName';
|
||||
const previous = field === 'firstName' ? 'lastName' : 'firstName';
|
||||
await expect(observer.locator(`[data-presence-field="${field}"]`)).not.toContainText('Demo Administrator');
|
||||
const before = performance.now();
|
||||
await sender.locator(`[data-qqq-id="${field}"]`).focus();
|
||||
await expect(observer.locator(`[data-presence-field="${field}"]`)).toContainText('Demo Administrator');
|
||||
result.samples.push({ field, propagation_ms: performance.now() - before });
|
||||
await expect(observer.locator(`[data-presence-field="${previous}"]`)).not.toContainText('Demo Administrator');
|
||||
if (load) {
|
||||
expect(load.state.errors).toEqual([]);
|
||||
expect(load.state.completed).toBeGreaterThan(previousCompleted);
|
||||
}
|
||||
}
|
||||
// Drop a real browser connection, miss multiple focus updates, then converge
|
||||
// through the shipped reconnect/polling behavior without reloading the form.
|
||||
const oldField = samples % 2 === 0 ? 'firstName' : 'lastName';
|
||||
const newField = oldField === 'firstName' ? 'lastName' : 'firstName';
|
||||
const streamsBefore = result.streams.filter(stream => stream.user === 'alice').length;
|
||||
result.reconnect = { status: 'FAIL', missed_changes: 0 };
|
||||
const interrupted = observer.waitForEvent('requestfailed', {
|
||||
predicate: request => new URL(request.url()).pathname.endsWith('/realtime/stream'),
|
||||
timeout: 10000,
|
||||
});
|
||||
await observer.context().setOffline(true);
|
||||
const disconnect = await fetch(`${base}/__acceptance/disconnect-first`, { method: 'POST' });
|
||||
expect(disconnect.status).toBe(200);
|
||||
result.reconnect.disconnected_streams = (await disconnect.json()).disconnected;
|
||||
expect(result.reconnect.disconnected_streams).toBeGreaterThan(0);
|
||||
const failedStream = await interrupted;
|
||||
result.reconnect.stream_failure = failedStream.failure()?.errorText;
|
||||
const offlineFetch = await observer.evaluate(async () => {
|
||||
try {
|
||||
await fetch(`/app/?offlineProbe=${Date.now()}`, { cache: 'no-store' });
|
||||
return false;
|
||||
} catch { return true; }
|
||||
});
|
||||
expect(offlineFetch).toBe(true);
|
||||
for (const field of [newField, oldField, newField]) {
|
||||
const renewed = sender.waitForResponse(response =>
|
||||
new URL(response.url()).pathname.endsWith('/presence/leases/renew') &&
|
||||
response.request().method() === 'POST' && response.status() === 200 &&
|
||||
response.request().postDataJSON()?.fieldName === field);
|
||||
await sender.locator(`[data-qqq-id="${field}"]`).focus();
|
||||
await renewed;
|
||||
result.reconnect.missed_changes++;
|
||||
}
|
||||
await expect(observer.locator(`[data-presence-field="${newField}"]`)).not.toContainText('Demo Administrator');
|
||||
const resumed = performance.now();
|
||||
await observer.context().setOffline(false);
|
||||
await expect.poll(() => result.streams.filter(stream => stream.user === 'alice' &&
|
||||
stream.status === 200 && stream.type?.includes('text/event-stream')).length,
|
||||
{ timeout: 30000 }).toBeGreaterThan(streamsBefore);
|
||||
await expect(observer.locator(`[data-presence-field="${newField}"]`)).toContainText('Demo Administrator', { timeout: 30000 });
|
||||
await expect(observer.locator(`[data-presence-field="${oldField}"]`)).not.toContainText('Demo Administrator');
|
||||
result.reconnect.recovery_ms = performance.now() - resumed;
|
||||
result.reconnect.status = 'PASS';
|
||||
if (load) {
|
||||
await load.stop();
|
||||
expect(load.state.errors).toEqual([]);
|
||||
expect(load.state.elapsed_ms).toBeGreaterThanOrEqual(60000);
|
||||
expect(load.state.nodes.first).toBeGreaterThan(0);
|
||||
expect(load.state.nodes.second).toBeGreaterThan(0);
|
||||
load = undefined;
|
||||
}
|
||||
await expect(observer.locator('[data-qqq-id="firstName"]')).toHaveValue('Retained observer draft');
|
||||
await expect(sender.locator('[data-qqq-id="firstName"]')).toHaveValue('Retained sender draft');
|
||||
for (const user of ['alice', 'admin']) {
|
||||
expect(result.streams.some(stream => stream.user === user && stream.status === 200 && stream.type?.includes('text/event-stream'))).toBe(true);
|
||||
}
|
||||
expect(result.requests.some(request => request.user === 'alice' && request.node === 'first')).toBe(true);
|
||||
expect(result.requests.some(request => request.user === 'admin' && request.node === 'second')).toBe(true);
|
||||
expect(result.requests.every(request => request.status === 200)).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
const ordered = result.samples.map(sample => sample.propagation_ms).sort((a, b) => a - b);
|
||||
result.percentiles_ms = Object.fromEntries([['p50', .5], ['p95', .95], ['p99', .99], ['max', 1]]
|
||||
.map(([name, quantile]) => [name, ordered[Math.ceil(ordered.length * quantile) - 1]]));
|
||||
result.status = 'PASS';
|
||||
} catch (error) {
|
||||
result.failure = String(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (load) await load.stop();
|
||||
await browser.close();
|
||||
await writeFile(output, JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Bounded real HTTP load using caller-owned authenticated browser contexts.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
export async function startPresenceLoad(browser, base, expect) {
|
||||
const state = { workers: 16, completed: 0, errors: [], nodes: {}, elapsed_ms: 0 };
|
||||
const contexts = [];
|
||||
const leases = [];
|
||||
let running = true;
|
||||
let stopped = false;
|
||||
let tasks = [];
|
||||
const began = performance.now();
|
||||
const endpoint = `${base}/qqq/v1/qbit/collaboration/presence/leases`;
|
||||
async function post(context, path, data) {
|
||||
const response = await context.request.post(endpoint + path, { data, timeout: 10000 });
|
||||
try {
|
||||
expect(response.status()).toBe(200);
|
||||
const node = response.headers()['x-acceptance-node'];
|
||||
state.nodes[node] = (state.nodes[node] ?? 0) + 1;
|
||||
return await response.json();
|
||||
} finally { await response.dispose(); }
|
||||
}
|
||||
async function stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
running = false;
|
||||
await Promise.all(tasks);
|
||||
state.elapsed_ms = performance.now() - began;
|
||||
for (const lease of leases) {
|
||||
try {
|
||||
const response = await lease.context.request.delete(`${base}/qqq/v1/qbit/collaboration/presence`, {
|
||||
data: { ...lease.target, connectionId: lease.id, sequence: ++lease.sequence }, timeout: 10000,
|
||||
});
|
||||
try { expect(response.status()).toBe(200); } finally { await response.dispose(); }
|
||||
} catch (error) { state.errors.push(String(error)); }
|
||||
}
|
||||
for (const context of contexts) await context.close();
|
||||
}
|
||||
try {
|
||||
for (const [index, user] of ['bob', 'carol', 'dave', 'erin'].entries()) {
|
||||
const context = await browser.newContext();
|
||||
contexts.push(context);
|
||||
await context.addCookies([
|
||||
{ name: 'collab_dev_user', value: user, url: base },
|
||||
{ name: 'presence_node', value: index % 2 ? 'second' : 'first', url: base },
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${base}/app/person/1?createMockSession=true`);
|
||||
await expect(page.locator('[data-qqq-id="record-view-title-person"]')).toBeVisible();
|
||||
await page.close();
|
||||
for (let tab = 0; tab < 4; tab++) {
|
||||
const target = { table: 'person', pk: '1' };
|
||||
const opened = await post(context, '', target);
|
||||
expect(typeof opened.connectionId).toBe('string');
|
||||
leases.push({ context, target, id: opened.connectionId, sequence: 0, completed: 0 });
|
||||
}
|
||||
}
|
||||
tasks = leases.map(async lease => {
|
||||
try {
|
||||
while (running) {
|
||||
await post(lease.context, '/renew', { ...lease.target, connectionId: lease.id,
|
||||
sequence: ++lease.sequence, mode: 'VIEWING' });
|
||||
lease.completed++;
|
||||
state.completed++;
|
||||
await delay(100);
|
||||
}
|
||||
} catch (error) { state.errors.push(String(error)); running = false; }
|
||||
});
|
||||
await expect.poll(() => leases.filter(lease => lease.completed > 0).length).toBe(16);
|
||||
return { state, stop };
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Verify owning-node routing with real published UI and backend artifacts.
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
const [frontendRepo, base, output] = process.argv.slice(2);
|
||||
if (!frontendRepo || !output || !['localhost', '127.0.0.1'].includes(new URL(base).hostname)) {
|
||||
throw new Error('Expected frontend repo, owned loopback URL and result path');
|
||||
}
|
||||
const require = createRequire(resolve(frontendRepo, 'package.json'));
|
||||
const { chromium, expect } = require('@playwright/test');
|
||||
const result = { status: 'FAIL', checks: [], errors: [] };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([
|
||||
{ name: 'collab_dev_user', value: 'alice', url: base },
|
||||
{ name: 'presence_node', value: 'first', url: base },
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => result.errors.push(error.message));
|
||||
const subscribed = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname.endsWith('/realtime/subscriptions') && response.status() === 200);
|
||||
await page.goto(`${base}/app/person/1/edit?createMockSession=true`);
|
||||
await expect(page.locator('[data-qqq-id="firstName"]')).toBeVisible();
|
||||
await page.locator('[data-qqq-id="firstName"]').fill('Keep this unsaved draft');
|
||||
const response = await subscribed;
|
||||
expect(response.headers()['x-acceptance-node']).toBe('first');
|
||||
const { connectionId } = response.request().postDataJSON();
|
||||
expect(typeof connectionId).toBe('string');
|
||||
for (const [node, expected] of [['first', 200], ['second', 404], ['first', 200]]) {
|
||||
// Keep the same session/user cookies and change only the test routing cookie.
|
||||
const cookies = (await context.cookies(base)).map(cookie =>
|
||||
`${cookie.name}=${cookie.name === 'presence_node' ? node : cookie.value}`).join('; ');
|
||||
const changed = await context.request.post(`${base}/qqq/v1/realtime/subscriptions`, {
|
||||
headers: { Cookie: cookies },
|
||||
data: { connectionId, add: ['record:person:1'], remove: [] }, timeout: 10000,
|
||||
});
|
||||
try {
|
||||
result.checks.push({ node: changed.headers()['x-acceptance-node'], status: changed.status() });
|
||||
expect(changed.headers()['x-acceptance-node']).toBe(node);
|
||||
expect(changed.status()).toBe(expected);
|
||||
if (expected === 200) {
|
||||
const body = await changed.json();
|
||||
expect(body.active).toContain('record:person:1');
|
||||
expect(body.refused ?? []).toEqual([]);
|
||||
}
|
||||
} finally { await changed.dispose(); }
|
||||
}
|
||||
await expect(page.locator('[data-qqq-id="firstName"]')).toHaveValue('Keep this unsaved draft');
|
||||
expect(result.errors).toEqual([]);
|
||||
result.status = 'PASS';
|
||||
} catch (error) {
|
||||
result.failure = String(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await writeFile(output, JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Bounded HTTP presence workload across independent, disposable JVMs.
|
||||
|
||||
This measures loopback HTTP service latency, not browser propagation or a
|
||||
production capacity limit. Run test-compile and dependency:build-classpath with
|
||||
test scope and output target/capacity-classpath.txt before invoking this script.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
def port():
|
||||
"""Choose a loopback port; child startup detects a competing bind."""
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def request(node, user, path, body=None, tab=0, method=None, expected=200):
|
||||
"""Use real HTTP and the explicit mock identity; no tenant parameter."""
|
||||
separator = "&" if "?" in path else "?"
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{node}/qqq/v1/qbit/collaboration{path}{separator}user={user}",
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Cookie": f"sessionUUID={user}-capacity-{tab}"})
|
||||
try:
|
||||
response = urllib.request.urlopen(req, timeout=10)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == expected:
|
||||
with error:
|
||||
return json.load(error)
|
||||
with error:
|
||||
detail = error.read(4096).decode(errors="replace")
|
||||
raise RuntimeError(f"HTTP {error.code} node={node} user={user} path={path}: {detail}") from error
|
||||
with response:
|
||||
if response.status != expected:
|
||||
raise AssertionError(f"Unexpected status {response.status}")
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def distribution(values):
|
||||
"""Nearest-rank milliseconds, with no discarded slow or failed samples."""
|
||||
ordered = sorted(values)
|
||||
return {"samples": len(ordered), **{
|
||||
name: ordered[max(0, math.ceil(len(ordered) * fraction) - 1)]
|
||||
for name, fraction in (("p50_ms", .5), ("p95_ms", .95), ("p99_ms", .99), ("max_ms", 1))}}
|
||||
|
||||
|
||||
def main():
|
||||
"""Own child JVMs/container and retain results even when a check fails."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java", required=True)
|
||||
parser.add_argument("--node-image", help="Optional Linux Java21 image; isolate Redis traffic on an owned Docker network")
|
||||
parser.add_argument("--nodes", type=int, default=3)
|
||||
parser.add_argument("--tenants", type=int, default=2)
|
||||
parser.add_argument("--users", type=int, default=6)
|
||||
parser.add_argument("--tabs", type=int, default=4)
|
||||
parser.add_argument("--rounds", type=int, default=10)
|
||||
parser.add_argument("--workers", type=int, default=8)
|
||||
parser.add_argument("--crash-round", type=int, default=0, help="Kill the first node before this round; zero disables")
|
||||
parser.add_argument("--churn-every", type=int, default=0, help="Leave/rejoin the last user's tabs every N rounds; zero disables")
|
||||
parser.add_argument("--pause-redis", action="store_true", help="After the workload, freeze owned Redis and verify bounded failures/recovery")
|
||||
args = parser.parse_args()
|
||||
for name, lower, upper in (("nodes", 2, 8), ("tenants", 2, 8), ("users", 2, 100),
|
||||
("tabs", 2, 8), ("rounds", 1, 1000), ("workers", 1, 64)):
|
||||
if not lower <= getattr(args, name) <= upper:
|
||||
parser.error(f"{name} must be {lower}..{upper}")
|
||||
if args.crash_round and not 2 <= args.crash_round <= args.rounds:
|
||||
parser.error("crash-round must be zero or 2..rounds")
|
||||
if args.churn_every and not 1 <= args.churn_every <= args.rounds:
|
||||
parser.error("churn-every must be zero or 1..rounds")
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
output = repo / "target" / ("capacity-nodes-" + uuid.uuid4().hex[:10])
|
||||
output.mkdir(parents=True)
|
||||
classpath = os.pathsep.join([str(repo / "target/test-classes"), str(repo / "target/classes"),
|
||||
(repo / "target/capacity-classpath.txt").read_text().strip()])
|
||||
processes, logs = [], []
|
||||
container = "qqq-capacity-" + uuid.uuid4().hex[:12]
|
||||
network = container + "-net"
|
||||
node_containers = []
|
||||
network_created = False
|
||||
result = {"status": "FAIL", "parameters": vars(args), "scope": "loopback HTTP, mock identities, memory records"}
|
||||
try:
|
||||
network_args = []
|
||||
if args.node_image:
|
||||
result["node_image_id"] = subprocess.check_output(
|
||||
["docker", "image", "inspect", args.node_image, "--format", "{{.Id}}"], text=True).strip()
|
||||
subprocess.run(["docker", "network", "create", network], check=True, capture_output=True)
|
||||
network_created = True
|
||||
network_args = ["--network", network]
|
||||
dependencies = output / "dependencies"
|
||||
dependencies.mkdir()
|
||||
linux_classpath = ["/fixture/target/test-classes", "/fixture/target/classes"]
|
||||
for index, dependency in enumerate((repo / "target/capacity-classpath.txt").read_text().strip().split(os.pathsep)):
|
||||
destination = dependencies / f"{index}.jar"
|
||||
shutil.copyfile(dependency, destination)
|
||||
linux_classpath.append("/fixture/" + destination.relative_to(repo).as_posix())
|
||||
classpath = ":".join(linux_classpath)
|
||||
subprocess.run(["docker", "run", "-d", "--name", container, "-p", "127.0.0.1::6379",
|
||||
*network_args,
|
||||
"redis:7.4-alpine", "redis-server", "--slowlog-log-slower-than", "1000",
|
||||
"--slowlog-max-len", "128", "--latency-monitor-threshold", "1"], check=True, capture_output=True)
|
||||
redis_port = int(subprocess.check_output(["docker", "port", container, "6379"], text=True).strip().rsplit(":", 1)[1])
|
||||
nodes = [port() for _ in range(args.nodes)]
|
||||
if len(set(nodes)) != len(nodes):
|
||||
raise RuntimeError("Port allocation collision; no nodes started")
|
||||
for node in nodes:
|
||||
argfile = output / f"node-{node}.args"
|
||||
connection_properties = (f"-Dcollab.capacity.redisHost={container}\n-Dcollab.dev.bindAddress=0.0.0.0\n"
|
||||
if args.node_image else "")
|
||||
argfile.write_text(f'-Xmx384m\n-Dcollab.capacity.tenants={args.tenants}\n-Dcollab.capacity.users={args.users}\n'
|
||||
+ connection_properties +
|
||||
'-cp\n"' + classpath.replace("\\", "/") + '"\n'
|
||||
'com.kingsrook.qbits.collaboration.devserver.CapacityPresenceDevServer\n'
|
||||
+ (f'8080\n6379\n' if args.node_image else f'{node}\n{redis_port}\n'), encoding="utf-8")
|
||||
log = (output / f"node-{node}.log").open("w")
|
||||
logs.append(log)
|
||||
command = [args.java, "@" + str(argfile)]
|
||||
if args.node_image:
|
||||
node_container = f"{container}-node-{node}"
|
||||
node_containers.append(node_container)
|
||||
command = ["docker", "run", "--rm", "--name", node_container, "--network", network,
|
||||
"-p", f"127.0.0.1:{node}:8080", "--mount", f"type=bind,source={repo},target=/fixture,readonly",
|
||||
args.node_image, "java", "@/fixture/" + argfile.relative_to(repo).as_posix()]
|
||||
processes.append(subprocess.Popen(command, cwd=repo,
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)))
|
||||
deadline = time.monotonic() + 120
|
||||
for node, process in zip(nodes, processes):
|
||||
while "CAPACITY_PRESENCE_READY " not in (output / f"node-{node}.log").read_text(errors="replace"):
|
||||
if process.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError(f"Node {node} failed startup; inspect {output}")
|
||||
time.sleep(.2)
|
||||
actors = []
|
||||
for tenant in range(args.tenants):
|
||||
for user in range(args.users):
|
||||
for tab in range(args.tabs):
|
||||
identity = f"load-t{tenant}-u{user}"
|
||||
record = str(1 + tab % 2)
|
||||
node = nodes[len(actors) % len(nodes)]
|
||||
lease = request(node, identity, "/presence/leases", {"table": "person", "pk": record}, tab)["connectionId"]
|
||||
actors.append((identity, record, tab, lease, user))
|
||||
renewals, snapshots = [], []
|
||||
|
||||
def check_rooms(excluded=None):
|
||||
"""Check every tenant/record on every surviving node, including absence."""
|
||||
for tenant in range(args.tenants):
|
||||
expected_users = {f"load-t{tenant}-u{user}" for user in range(args.users) if user != excluded}
|
||||
for node in nodes:
|
||||
for record in ("1", "2"):
|
||||
before = time.perf_counter()
|
||||
viewers = request(node, f"load-t{tenant}-u0", f"/presence?table=person&pk={record}")["viewers"]
|
||||
snapshots.append((time.perf_counter() - before) * 1000)
|
||||
actual = {viewer["userId"] for viewer in viewers}
|
||||
if actual != expected_users or len(viewers) != len(expected_users):
|
||||
raise AssertionError(f"Tenant isolation/deduplication mismatch: {actual} != {expected_users}")
|
||||
if sum(viewer["mode"] == "EDITING" for viewer in viewers) != sum(user != excluded for user in range(2)):
|
||||
raise AssertionError("Unexpected editor count")
|
||||
|
||||
def renew(item, sequence):
|
||||
"""Keep each tab sequential while moving its writes between nodes."""
|
||||
index, (identity, record, tab, lease, user) = item
|
||||
before = time.perf_counter()
|
||||
request(nodes[(index + sequence) % len(nodes)], identity, "/presence/leases/renew",
|
||||
{"connectionId": lease, "table": "person", "pk": record,
|
||||
"mode": "EDITING" if user < 2 else "VIEWING", "sequence": sequence}, tab)
|
||||
return (time.perf_counter() - before) * 1000
|
||||
|
||||
started = time.perf_counter()
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as workers:
|
||||
for sequence in range(1, args.rounds + 1):
|
||||
result["current_round"] = sequence
|
||||
if sequence == args.crash_round:
|
||||
crashed = nodes.pop(0)
|
||||
if args.node_image:
|
||||
subprocess.run(["docker", "kill", node_containers[0]], check=True, capture_output=True)
|
||||
else:
|
||||
processes[0].kill()
|
||||
processes[0].wait(timeout=10)
|
||||
result["crash"] = {"round": sequence, "http_port": crashed,
|
||||
"surviving_nodes": len(nodes), "process_exit": processes[0].returncode}
|
||||
renewals.extend(workers.map(lambda item: renew(item, sequence), enumerate(actors)))
|
||||
check_rooms()
|
||||
if args.churn_every and sequence % args.churn_every == 0:
|
||||
departing = [(index, actor) for index, actor in enumerate(actors) if actor[4] == args.users - 1]
|
||||
for index, (identity, record, tab, lease, user) in departing:
|
||||
request(nodes[index % len(nodes)], identity, "/presence",
|
||||
{"connectionId": lease, "table": "person", "pk": record, "sequence": sequence + 1}, tab, method="DELETE")
|
||||
response = request(nodes[(index + 1) % len(nodes)], identity, "/presence/leases/renew",
|
||||
{"connectionId": lease, "table": "person", "pk": record,
|
||||
"mode": "VIEWING", "sequence": sequence + 2}, tab, expected=409)
|
||||
if response.get("code") != "PRESENCE_LEASE_EXPIRED":
|
||||
raise AssertionError("Departed lease was not rejected as expired")
|
||||
check_rooms(excluded=args.users - 1)
|
||||
for index, (identity, record, tab, lease, user) in departing:
|
||||
replacement = request(nodes[(index + 1) % len(nodes)], identity, "/presence/leases",
|
||||
{"table": "person", "pk": record}, tab)["connectionId"]
|
||||
if replacement == lease:
|
||||
raise AssertionError("Rejoin reused departed authority")
|
||||
actors[index] = (identity, record, tab, replacement, user)
|
||||
renew((index, actors[index]), sequence)
|
||||
check_rooms()
|
||||
result["churn_cycles"] = result.get("churn_cycles", 0) + 1
|
||||
result["departed_leases_rejected"] = result.get("departed_leases_rejected", 0) + len(departing)
|
||||
result["completed_rounds"] = sequence
|
||||
if args.pause_redis:
|
||||
failures = []
|
||||
|
||||
def stalled_renew(item):
|
||||
"""A frozen authority must fail explicitly, never acknowledge a write."""
|
||||
index, (identity, record, tab, lease, user) = item
|
||||
before = time.perf_counter()
|
||||
response = request(nodes[index % len(nodes)], identity, "/presence/leases/renew",
|
||||
{"connectionId": lease, "table": "person", "pk": record,
|
||||
"mode": "EDITING" if user < 2 else "VIEWING", "sequence": args.rounds + 1},
|
||||
tab, expected=503)
|
||||
if "viewers" in response or "temporarily unavailable" not in response.get("message", ""):
|
||||
raise AssertionError("Stalled authority did not return the expected unavailable response")
|
||||
duration = (time.perf_counter() - before) * 1000
|
||||
if duration > 8000:
|
||||
raise AssertionError("Unavailable response exceeded the eight-second fixture bound")
|
||||
return duration
|
||||
|
||||
subprocess.run(["docker", "pause", container], check=True, capture_output=True)
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=64) as burst:
|
||||
failures.extend(burst.map(stalled_renew, enumerate(actors)))
|
||||
for node in nodes:
|
||||
response = request(node, "load-t0-u0", "/presence?table=person&pk=1", expected=503)
|
||||
if "viewers" in response:
|
||||
raise AssertionError("Stalled authority was presented as a room snapshot")
|
||||
finally:
|
||||
subprocess.run(["docker", "unpause", container], check=True, capture_output=True)
|
||||
subprocess.run(["docker", "exec", container, "redis-cli", "PING"], check=True, capture_output=True, timeout=10)
|
||||
for item in enumerate(actors):
|
||||
renew(item, args.rounds + 2)
|
||||
check_rooms()
|
||||
result["redis_stall"] = {"rejected_renewals": len(failures), "failed_snapshot_nodes": len(nodes),
|
||||
"response_times": distribution(failures), "same_leases_recovered": True}
|
||||
elapsed = time.perf_counter() - started
|
||||
result.update(status="PASS", seconds=elapsed, renewals=distribution(renewals),
|
||||
snapshots=distribution(snapshots), renewal_throughput=len(renewals) / elapsed,
|
||||
node_pids=[process.pid for process in processes],
|
||||
pid_kind="docker-client" if args.node_image else "JVM", node_containers=node_containers)
|
||||
except Exception as error:
|
||||
result["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
failure_active = sys.exc_info()[0] is not None
|
||||
try:
|
||||
slow = json.loads(subprocess.check_output(
|
||||
["docker", "exec", container, "redis-cli", "--json", "SLOWLOG", "GET", "128"], text=True, timeout=10))
|
||||
result["redis_slow_commands"] = [{"unix_time": entry[1], "duration_us": entry[2], "command": entry[3][0]}
|
||||
for entry in slow]
|
||||
result["redis_latency_events"] = json.loads(subprocess.check_output(
|
||||
["docker", "exec", container, "redis-cli", "--json", "LATENCY", "LATEST"], text=True, timeout=10))
|
||||
for section in ("cpu", "memory", "commandstats", "stats"):
|
||||
value = subprocess.check_output(["docker", "exec", container, "redis-cli", "INFO", section], text=True, timeout=10)
|
||||
(output / f"redis-{section}.txt").write_text(value)
|
||||
parsed = dict(line.split(":", 1) for line in value.splitlines() if ":" in line and not line.startswith("#"))
|
||||
if section == "memory":
|
||||
result["redis_memory"] = {key: int(parsed[key]) for key in
|
||||
("used_memory", "used_memory_peak", "used_memory_rss")}
|
||||
if section == "cpu":
|
||||
result["redis_cpu_seconds_since_start"] = {key: float(parsed[key]) for key in
|
||||
("used_cpu_sys", "used_cpu_user")}
|
||||
except (subprocess.SubprocessError, ValueError, KeyError) as error:
|
||||
result["redis_diagnostic_error"] = type(error).__name__
|
||||
node_cleanup = True
|
||||
for node_container in node_containers:
|
||||
removed = subprocess.run(["docker", "rm", "-f", node_container], capture_output=True)
|
||||
if removed.returncode != 0:
|
||||
remaining = subprocess.run(["docker", "ps", "-aq", "--filter", f"name=^{node_container}$"], capture_output=True, text=True)
|
||||
node_cleanup = node_cleanup and remaining.returncode == 0 and not remaining.stdout.strip()
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
for log in logs:
|
||||
log.close()
|
||||
resources = []
|
||||
for node_log in sorted(output.glob("node-*.log")):
|
||||
samples = [json.loads(line.split("CAPACITY_METRIC ", 1)[1])
|
||||
for line in node_log.read_text(errors="replace").splitlines()
|
||||
if line.startswith("CAPACITY_METRIC ")]
|
||||
if samples:
|
||||
first, last = samples[0], samples[-1]
|
||||
wall_ms = last["uptimeMillis"] - first["uptimeMillis"]
|
||||
cpu_ns = last["cpuNanos"] - first["cpuNanos"]
|
||||
resources.append({"node_log": node_log.name, "samples": len(samples),
|
||||
"observed_wall_ms": wall_ms,
|
||||
"cpu_seconds": cpu_ns / 1e9 if first["cpuNanos"] >= 0 else None,
|
||||
"cpu_core_equivalents": cpu_ns / (wall_ms * 1e6) if wall_ms > 0 and first["cpuNanos"] >= 0 else None,
|
||||
**{key + "_sampled_max": max(sample[key] for sample in samples)
|
||||
for key in ("heapUsedBytes", "heapCommittedBytes", "poolActive", "poolIdle", "poolWaiting", "poolMaximum")},
|
||||
"residentBytes_sampled_max": max(sample["residentBytes"] for sample in samples)
|
||||
if all(sample.get("residentBytes", -1) >= 0 for sample in samples) else None})
|
||||
if any(sample["poolActive"] > sample["poolMaximum"] for sample in samples):
|
||||
result.update(status="FAIL", error="Sampled pool active count exceeded configured maximum")
|
||||
result["resources"] = resources
|
||||
if result["status"] == "PASS" and args.node_image and any(node["residentBytes_sampled_max"] is None for node in resources):
|
||||
result.update(status="FAIL", error="Linux resident-memory samples missing")
|
||||
if result["status"] == "PASS" and any(key not in result for key in ("redis_memory", "redis_cpu_seconds_since_start")):
|
||||
result.update(status="FAIL", error="Redis memory/CPU diagnostics missing")
|
||||
if result["status"] == "PASS" and len(resources) != args.nodes:
|
||||
result.update(status="FAIL", error="Missing resource samples for a node")
|
||||
if result["status"] == "PASS" and args.pause_redis and not any(node["poolWaiting_sampled_max"] > 0 for node in resources):
|
||||
result.update(status="FAIL", error="Redis stall did not demonstrate pool waiters")
|
||||
cleanup = subprocess.run(["docker", "rm", "-f", container], capture_output=True)
|
||||
network_cleanup = True
|
||||
if network_created:
|
||||
network_cleanup = subprocess.run(["docker", "network", "rm", network], capture_output=True).returncode == 0
|
||||
result["cleanup"] = cleanup.returncode == 0 and node_cleanup and network_cleanup and all(process.poll() is not None for process in processes)
|
||||
(output / "result.json").write_text(json.dumps(result, indent=2))
|
||||
print(json.dumps(result, indent=2))
|
||||
print(output)
|
||||
if result["status"] == "PASS" and not result["cleanup"]:
|
||||
raise RuntimeError("Workload passed but owned resource cleanup failed")
|
||||
if result["status"] == "FAIL" and not failure_active:
|
||||
raise RuntimeError(result.get("error", "Capacity resource validation failed"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
// Disposable loopback fixture only; never deploy this fault-injection router.
|
||||
import http from 'node:http';
|
||||
|
||||
const [first, second, listen] = process.argv.slice(2).map(Number);
|
||||
if (![first, second, listen].every(port => Number.isInteger(port) && port > 0 && port < 65536)) {
|
||||
throw new Error('Expected first-node, second-node and router ports');
|
||||
}
|
||||
const firstStreams = new Set();
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.method === 'POST' && request.url === '/__acceptance/disconnect-first') {
|
||||
const disconnected = firstStreams.size;
|
||||
for (const stream of firstStreams) stream.destroy();
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ disconnected }));
|
||||
return;
|
||||
}
|
||||
const isSecond = /(?:^|;\s*)presence_node=second(?:;|$)/.test(request.headers.cookie ?? '');
|
||||
const port = isSecond ? second : first;
|
||||
const upstream = http.request({
|
||||
hostname: '127.0.0.1', port, path: request.url, method: request.method,
|
||||
headers: { ...request.headers, host: `127.0.0.1:${port}` },
|
||||
}, incoming => {
|
||||
response.writeHead(incoming.statusCode, {
|
||||
...incoming.headers, 'x-acceptance-node': isSecond ? 'second' : 'first',
|
||||
});
|
||||
if (!isSecond && incoming.headers['content-type']?.includes('text/event-stream')) {
|
||||
firstStreams.add(response);
|
||||
}
|
||||
incoming.pipe(response);
|
||||
});
|
||||
upstream.on('error', () => {
|
||||
if (!response.headersSent) response.writeHead(502);
|
||||
response.end('Acceptance node unavailable');
|
||||
});
|
||||
response.on('close', () => {
|
||||
firstStreams.delete(response);
|
||||
upstream.destroy();
|
||||
});
|
||||
request.pipe(upstream);
|
||||
});
|
||||
server.listen(listen, '127.0.0.1', () => console.log(`PRESENCE_ROUTER_READY ${listen}`));
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close());
|
||||
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"samples": [
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 36.092099999999846
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.51870000000008
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.758600000000115
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.090299999999843
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.64980000000014
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.648199999999633
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.395500000000084
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.36750000000029
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.601799999999912
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.90599999999995
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.44150000000036
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.186699999999746
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.115800000000036
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.539400000000114
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.188000000000102
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.021200000000135
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 33.07759999999962
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.7176000000004
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.457300000000032
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.920900000000074
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.89130000000023
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.847200000000157
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 30.158300000000054
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.48239999999987
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.337600000000293
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"scope": "Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load",
|
||||
"percentiles_ms": {
|
||||
"p50": 31.648199999999633,
|
||||
"p95": 33.07759999999962,
|
||||
"p99": 36.092099999999846,
|
||||
"max": 36.092099999999846
|
||||
},
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"fixture_cleanup": true,
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records."
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"samples": [
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 36.560600000000704
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.44050000000061
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.50890000000072
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 785.4951000000001
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 85.39069999999992
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.589599999999336
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.756099999998696
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 38.17399999999907
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 30.243099999999686
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.600799999992887
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 29.57220000000234
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.197800000001735
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 33.05130000000645
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.38390000000072
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.52850000000035
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 34.66169999999693
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.258300000001327
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 44.19029999999475
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 36.56189999999333
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.240600000004633
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 29.79899999999907
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.621500000001106
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.66189999999915
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.198399999993853
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.931200000006356
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"scope": "Chromium focus-to-remote-DOM and reconnect under 16 concurrent HTTP lease workers; loopback, four background identities",
|
||||
"load": {
|
||||
"workers": 16,
|
||||
"completed": 9046,
|
||||
"errors": [],
|
||||
"nodes": {
|
||||
"first": 4540,
|
||||
"second": 4522
|
||||
},
|
||||
"elapsed_ms": 69728.3579
|
||||
},
|
||||
"reconnect": {
|
||||
"status": "PASS",
|
||||
"missed_changes": 3,
|
||||
"disconnected_streams": 1,
|
||||
"stream_failure": "net::ERR_INCOMPLETE_CHUNKED_ENCODING",
|
||||
"recovery_ms": 1855.8868999999977
|
||||
},
|
||||
"percentiles_ms": {
|
||||
"p50": 32.38390000000072,
|
||||
"p95": 85.39069999999992,
|
||||
"p99": 785.4951000000001,
|
||||
"max": 785.4951000000001
|
||||
},
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"fixture_cleanup": true,
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
|
||||
"fixture_checks": [
|
||||
"browser-six-presence",
|
||||
"browser-redis-admin",
|
||||
"browser-presence-latency"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"samples": [
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 37.25770000000011
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.14499999999998
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.40950000000021
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.52500000000009
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.228200000000015
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.430999999999585
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.493199999999888
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.14539999999988
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.11259999999993
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.23129999999992
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.06800000000021
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.109199999999873
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.534299999999803
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.770800000000236
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.654199999999946
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.31700000000001
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 33.108600000000024
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.27370000000019
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.452499999999873
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.86220000000003
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.734399999999823
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.04489999999987
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.051899999999932
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.876300000000356
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.56970000000001
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"scope": "Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load",
|
||||
"reconnect": {
|
||||
"status": "PASS",
|
||||
"missed_changes": 3,
|
||||
"disconnected_streams": 1,
|
||||
"stream_failure": "net::ERR_INCOMPLETE_CHUNKED_ENCODING",
|
||||
"recovery_ms": 1858.2972000000004
|
||||
},
|
||||
"percentiles_ms": {
|
||||
"p50": 31.86220000000003,
|
||||
"p95": 33.770800000000236,
|
||||
"p99": 37.25770000000011,
|
||||
"max": 37.25770000000011
|
||||
},
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"fixture_cleanup": true,
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
|
||||
"fixture_checks": [
|
||||
"browser-six-presence",
|
||||
"browser-redis-admin",
|
||||
"browser-presence-latency"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"checks": [
|
||||
{ "node": "first", "status": 200 },
|
||||
{ "node": "second", "status": 404 },
|
||||
{ "node": "first", "status": 200 }
|
||||
],
|
||||
"errors": [],
|
||||
"fixture_cleanup": true,
|
||||
"fixture_checks": ["browser-six-presence", "browser-redis-admin", "browser-subscription-affinity"],
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
|
||||
"scope": "Same browser session and live SSE connection ID; change only test-router affinity. Not deployed Traefik qualification."
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "/path/to/java21",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 50,
|
||||
"tabs": 4,
|
||||
"rounds": 200,
|
||||
"workers": 16,
|
||||
"crash_round": 0,
|
||||
"churn_every": 0,
|
||||
"pause_redis": false
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 200,
|
||||
"completed_rounds": 200,
|
||||
"seconds": 102.56801009993069,
|
||||
"renewals": {
|
||||
"samples": 80000,
|
||||
"p50_ms": 11.325599974952638,
|
||||
"p95_ms": 21.764500066637993,
|
||||
"p99_ms": 25.881099980324507,
|
||||
"max_ms": 1007.4040000326931
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 2400,
|
||||
"p50_ms": 15.130399959161878,
|
||||
"p95_ms": 16.613700077868998,
|
||||
"p99_ms": 19.423600053414702,
|
||||
"max_ms": 28.864299994893372
|
||||
},
|
||||
"renewal_throughput": 779.9702843221492,
|
||||
"node_pids": [
|
||||
105696,
|
||||
4828,
|
||||
117588
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-f58f3ddcf5f3-node-59675",
|
||||
"qqq-capacity-f58f3ddcf5f3-node-59676",
|
||||
"qqq-capacity-f58f3ddcf5f3-node-59677"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793033347,
|
||||
"duration_us": 1432,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033347,
|
||||
"duration_us": 1612,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033340,
|
||||
"duration_us": 1877,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033340,
|
||||
"duration_us": 1313,
|
||||
"command": "ZRANGE"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033336,
|
||||
"duration_us": 1316,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033305,
|
||||
"duration_us": 1459,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033300,
|
||||
"duration_us": 1767,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033290,
|
||||
"duration_us": 1180,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033283,
|
||||
"duration_us": 1421,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033260,
|
||||
"duration_us": 1291,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033260,
|
||||
"duration_us": 1551,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033260,
|
||||
"duration_us": 1083,
|
||||
"command": "ZRANGE"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033255,
|
||||
"duration_us": 1039,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033254,
|
||||
"duration_us": 1215,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793033347,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"redis_cpu_seconds_since_start": {
|
||||
"used_cpu_sys": 23.737706,
|
||||
"used_cpu_user": 35.526214
|
||||
},
|
||||
"redis_memory": {
|
||||
"used_memory": 2415952,
|
||||
"used_memory_peak": 2579896,
|
||||
"used_memory_rss": 8777728
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-59675.log",
|
||||
"samples": 1112,
|
||||
"observed_wall_ms": 111099,
|
||||
"cpu_seconds": 203.14,
|
||||
"cpu_core_equivalents": 1.828459302063925,
|
||||
"heapUsedBytes_sampled_max": 181098896,
|
||||
"heapCommittedBytes_sampled_max": 211812352,
|
||||
"poolActive_sampled_max": 7,
|
||||
"poolIdle_sampled_max": 7,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8,
|
||||
"residentBytes_sampled_max": 662786048
|
||||
},
|
||||
{
|
||||
"node_log": "node-59676.log",
|
||||
"samples": 1145,
|
||||
"observed_wall_ms": 114399,
|
||||
"cpu_seconds": 200.44,
|
||||
"cpu_core_equivalents": 1.7521132177728826,
|
||||
"heapUsedBytes_sampled_max": 185167416,
|
||||
"heapCommittedBytes_sampled_max": 219152384,
|
||||
"poolActive_sampled_max": 7,
|
||||
"poolIdle_sampled_max": 7,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8,
|
||||
"residentBytes_sampled_max": 662188032
|
||||
},
|
||||
{
|
||||
"node_log": "node-59677.log",
|
||||
"samples": 1159,
|
||||
"observed_wall_ms": 115800,
|
||||
"cpu_seconds": 200.16,
|
||||
"cpu_core_equivalents": 1.728497409326425,
|
||||
"heapUsedBytes_sampled_max": 179378632,
|
||||
"heapCommittedBytes_sampled_max": 210763776,
|
||||
"poolActive_sampled_max": 6,
|
||||
"poolIdle_sampled_max": 7,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8,
|
||||
"residentBytes_sampled_max": 703479808
|
||||
}
|
||||
],
|
||||
"cleanup": true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin:21-jre",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 1000,
|
||||
"workers": 8
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"current_round": 1000,
|
||||
"completed_rounds": 1000,
|
||||
"seconds": 250.81072249996942,
|
||||
"renewals": {
|
||||
"samples": 96000,
|
||||
"p50_ms": 6.353199947625399,
|
||||
"p95_ms": 15.330899972468615,
|
||||
"p99_ms": 17.760399961844087,
|
||||
"max_ms": 326.8311999272555
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 12000,
|
||||
"p50_ms": 13.105699908919632,
|
||||
"p95_ms": 14.261199976317585,
|
||||
"p99_ms": 16.519399941898882,
|
||||
"max_ms": 915.3468000004068
|
||||
},
|
||||
"renewal_throughput": 382.7587554595546,
|
||||
"node_pids": [
|
||||
64000,
|
||||
40240,
|
||||
115400
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-0a8b7956499d-node-64951",
|
||||
"qqq-capacity-0a8b7956499d-node-64952",
|
||||
"qqq-capacity-0a8b7956499d-node-64953"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793030517,
|
||||
"duration_us": 1117,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793030517,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-64951.log",
|
||||
"samples": 2580,
|
||||
"observed_wall_ms": 257899,
|
||||
"cpu_seconds": 165.55,
|
||||
"cpu_core_equivalents": 0.6419179601316795,
|
||||
"heapUsedBytes_sampled_max": 166667368,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-64952.log",
|
||||
"samples": 2569,
|
||||
"observed_wall_ms": 256799,
|
||||
"cpu_seconds": 165.85,
|
||||
"cpu_core_equivalents": 0.6458358482704372,
|
||||
"heapUsedBytes_sampled_max": 169216944,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-64953.log",
|
||||
"samples": 2608,
|
||||
"observed_wall_ms": 260700,
|
||||
"cpu_seconds": 164.72,
|
||||
"cpu_core_equivalents": 0.631837360951285,
|
||||
"heapUsedBytes_sampled_max": 166319008,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 100,
|
||||
"workers": 8,
|
||||
"crash_round": 50,
|
||||
"churn_every": 20
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 100,
|
||||
"completed_rounds": 100,
|
||||
"churn_cycles": 5,
|
||||
"departed_leases_rejected": 40,
|
||||
"crash": {
|
||||
"round": 50,
|
||||
"http_port": 52824,
|
||||
"surviving_nodes": 2,
|
||||
"process_exit": 137
|
||||
},
|
||||
"seconds": 28.090077199973166,
|
||||
"renewals": {
|
||||
"samples": 9600,
|
||||
"p50_ms": 7.532799965701997,
|
||||
"p95_ms": 17.53149996511638,
|
||||
"p99_ms": 29.359900043345988,
|
||||
"max_ms": 88.13189994543791
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 1092,
|
||||
"p50_ms": 13.582199928350747,
|
||||
"p95_ms": 15.386099927127361,
|
||||
"p99_ms": 18.502700026147068,
|
||||
"max_ms": 22.144700051285326
|
||||
},
|
||||
"renewal_throughput": 341.75769371004685,
|
||||
"node_pids": [
|
||||
22844,
|
||||
66652,
|
||||
61920
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-bb9ba87919d2-node-52824",
|
||||
"qqq-capacity-bb9ba87919d2-node-52825",
|
||||
"qqq-capacity-bb9ba87919d2-node-52827"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793031681,
|
||||
"duration_us": 1206,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793031681,
|
||||
"duration_us": 1147,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793031681,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-52824.log",
|
||||
"samples": 212,
|
||||
"observed_wall_ms": 21099,
|
||||
"cpu_seconds": 29.48,
|
||||
"cpu_core_equivalents": 1.3972226171856486,
|
||||
"heapUsedBytes_sampled_max": 163641024,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 3,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-52825.log",
|
||||
"samples": 354,
|
||||
"observed_wall_ms": 35299,
|
||||
"cpu_seconds": 43.09,
|
||||
"cpu_core_equivalents": 1.22071446783195,
|
||||
"heapUsedBytes_sampled_max": 168178592,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-52827.log",
|
||||
"samples": 340,
|
||||
"observed_wall_ms": 33900,
|
||||
"cpu_seconds": 42.42,
|
||||
"cpu_core_equivalents": 1.2513274336283187,
|
||||
"heapUsedBytes_sampled_max": 166291160,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 100,
|
||||
"workers": 8,
|
||||
"crash_round": 50
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 100,
|
||||
"completed_rounds": 100,
|
||||
"crash": {
|
||||
"round": 50,
|
||||
"http_port": 54039,
|
||||
"surviving_nodes": 2,
|
||||
"process_exit": 137
|
||||
},
|
||||
"seconds": 25.147547999978997,
|
||||
"renewals": {
|
||||
"samples": 9600,
|
||||
"p50_ms": 7.125200005248189,
|
||||
"p95_ms": 17.407900071702898,
|
||||
"p99_ms": 27.217599912546575,
|
||||
"max_ms": 74.53640003222972
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 996,
|
||||
"p50_ms": 13.538200058974326,
|
||||
"p95_ms": 15.59239998459816,
|
||||
"p99_ms": 20.58999997097999,
|
||||
"max_ms": 315.0679999962449
|
||||
},
|
||||
"renewal_throughput": 381.7469599822622,
|
||||
"node_pids": [
|
||||
85832,
|
||||
90280,
|
||||
67764
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-e599dca8e163-node-54039",
|
||||
"qqq-capacity-e599dca8e163-node-54040",
|
||||
"qqq-capacity-e599dca8e163-node-54041"
|
||||
],
|
||||
"redis_slow_commands": [],
|
||||
"redis_latency_events": [],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-54039.log",
|
||||
"samples": 198,
|
||||
"observed_wall_ms": 19699,
|
||||
"cpu_seconds": 28.58,
|
||||
"cpu_core_equivalents": 1.4508350677699375,
|
||||
"heapUsedBytes_sampled_max": 163478640,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 3,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-54040.log",
|
||||
"samples": 300,
|
||||
"observed_wall_ms": 29900,
|
||||
"cpu_seconds": 41.85,
|
||||
"cpu_core_equivalents": 1.399665551839465,
|
||||
"heapUsedBytes_sampled_max": 165177008,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-54041.log",
|
||||
"samples": 331,
|
||||
"observed_wall_ms": 33000,
|
||||
"cpu_seconds": 41.52,
|
||||
"cpu_core_equivalents": 1.2581818181818183,
|
||||
"heapUsedBytes_sampled_max": 168546936,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 30,
|
||||
"workers": 8,
|
||||
"crash_round": 0,
|
||||
"churn_every": 0,
|
||||
"pause_redis": true
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 30,
|
||||
"completed_rounds": 30,
|
||||
"redis_stall": {
|
||||
"rejected_renewals": 96,
|
||||
"failed_snapshot_nodes": 3,
|
||||
"response_times": {
|
||||
"samples": 96,
|
||||
"p50_ms": 1013.8123999349773,
|
||||
"p95_ms": 4014.4751999760047,
|
||||
"p99_ms": 4015.4822999611497,
|
||||
"max_ms": 4015.4822999611497
|
||||
},
|
||||
"same_leases_recovered": true
|
||||
},
|
||||
"seconds": 20.33888840000145,
|
||||
"renewals": {
|
||||
"samples": 2880,
|
||||
"p50_ms": 8.835799992084503,
|
||||
"p95_ms": 23.678299970924854,
|
||||
"p99_ms": 35.42319999542087,
|
||||
"max_ms": 102.84149996004999
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 372,
|
||||
"p50_ms": 13.98350007366389,
|
||||
"p95_ms": 16.946100047789514,
|
||||
"p99_ms": 20.020700059831142,
|
||||
"max_ms": 20.943500101566315
|
||||
},
|
||||
"renewal_throughput": 141.6006589622565,
|
||||
"node_pids": [
|
||||
88080,
|
||||
78036,
|
||||
121352
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-68ad627e7be5-node-58312",
|
||||
"qqq-capacity-68ad627e7be5-node-58314",
|
||||
"qqq-capacity-68ad627e7be5-node-58315"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793032016,
|
||||
"duration_us": 2198,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793032016,
|
||||
2,
|
||||
2
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-58312.log",
|
||||
"samples": 280,
|
||||
"observed_wall_ms": 27899,
|
||||
"cpu_seconds": 23.96,
|
||||
"cpu_core_equivalents": 0.8588121438044375,
|
||||
"heapUsedBytes_sampled_max": 164221400,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 6,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 15,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-58314.log",
|
||||
"samples": 289,
|
||||
"observed_wall_ms": 28800,
|
||||
"cpu_seconds": 23.2,
|
||||
"cpu_core_equivalents": 0.8055555555555556,
|
||||
"heapUsedBytes_sampled_max": 162849016,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 3,
|
||||
"poolWaiting_sampled_max": 14,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-58315.log",
|
||||
"samples": 272,
|
||||
"observed_wall_ms": 27100,
|
||||
"cpu_seconds": 23.85,
|
||||
"cpu_core_equivalents": 0.8800738007380073,
|
||||
"heapUsedBytes_sampled_max": 165779696,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 14,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"status": "FAIL",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 1000,
|
||||
"workers": 8
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"current_round": 480,
|
||||
"completed_rounds": 479,
|
||||
"error": "HTTP 503 node=56028 user=load-t0-u5 path=/presence/leases/renew: {\"error\":\"Presence is temporarily unavailable. Please retry.\",\"code\":\"ERROR\",\"message\":\"Presence is temporarily unavailable. Please retry.\",\"traceId\":\"cea30b82-e82b-49ec-af8a-64d8b6bd5af6\"}",
|
||||
"redis_slow_commands": [],
|
||||
"redis_latency_events": [],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-56027.log",
|
||||
"samples": 1067,
|
||||
"observed_wall_ms": 106600,
|
||||
"cpu_seconds": 94.328125,
|
||||
"cpu_core_equivalents": 0.8848792213883677,
|
||||
"heapUsedBytes_sampled_max": 160559696,
|
||||
"heapCommittedBytes_sampled_max": 178257920,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-56028.log",
|
||||
"samples": 1066,
|
||||
"observed_wall_ms": 106501,
|
||||
"cpu_seconds": 95.8125,
|
||||
"cpu_core_equivalents": 0.8996394400052582,
|
||||
"heapUsedBytes_sampled_max": 160251104,
|
||||
"heapCommittedBytes_sampled_max": 178257920,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-56029.log",
|
||||
"samples": 1066,
|
||||
"observed_wall_ms": 106500,
|
||||
"cpu_seconds": 95.25,
|
||||
"cpu_core_equivalents": 0.8943661971830986,
|
||||
"heapUsedBytes_sampled_max": 157995216,
|
||||
"heapCommittedBytes_sampled_max": 175112192,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.devserver;
|
||||
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceScopeProvider;
|
||||
import com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig;
|
||||
import com.kingsrook.qbits.redis.RedisConnectionConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitProducer;
|
||||
import com.kingsrook.qbits.redis.RedisRealtimeRuntimeService;
|
||||
import com.kingsrook.qbits.redis.RedisRuntimeService;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
|
||||
/*******************************************************************************
|
||||
** Loopback-only browser acceptance node. Run separate JVMs against one disposable
|
||||
** Redis endpoint. Record data is independently seeded, not a shared database.
|
||||
*******************************************************************************/
|
||||
public class CapacityPresenceDevServer
|
||||
{
|
||||
/***************************************************************************
|
||||
** Arguments are HTTP port and disposable local Redis port. Never deploy this
|
||||
** fixture: it uses the demo's mock authentication and plaintext loopback Redis.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
if(args.length != 2)
|
||||
{
|
||||
throw (new IllegalArgumentException("Expected HTTP port and local Redis port"));
|
||||
}
|
||||
int port = Integer.parseInt(args[0]);
|
||||
int redisPort = Integer.parseInt(args[1]);
|
||||
var instance = CollaborationDevServer.buildInstance(port);
|
||||
CollaborationDevServer.seed(instance);
|
||||
instance.withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData()
|
||||
.withName("mock").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(AcceptanceSessions.class)));
|
||||
PresenceRegistry.close(instance);
|
||||
RealtimeBus.close(instance);
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("presence",
|
||||
new RedisConnectionConfig().withHost(System.getProperty("collab.capacity.redisHost", "127.0.0.1")).withPort(redisPort).withTls(false).withMaximumConnections(8).withMaximumWaitMillis(500))
|
||||
.withRealtime("presence", "acceptance", "collaboration")).produce(instance);
|
||||
instance.getQBits().values().stream().map(bit -> bit.getConfig())
|
||||
.filter(CollaborationQBitConfig.class::isInstance).map(CollaborationQBitConfig.class::cast)
|
||||
.findFirst().orElseThrow().withRedisPresence(new RedisPresenceConfig().withConnectionName("presence")
|
||||
.withDeployment("acceptance").withApplication("collaboration")
|
||||
.withScopeProvider(new QCodeReference(FixtureTenant.class)));
|
||||
var redis = new RedisRuntimeService();
|
||||
var transport = new RedisRealtimeRuntimeService();
|
||||
new com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider().defineAll(instance, "memory", null);
|
||||
var health = new com.kingsrook.qqq.backend.core.health.HealthRunner(instance, "acceptance-" + port, java.time.Clock.systemUTC());
|
||||
var server = CollaborationDevServer.server(instance, port, false);
|
||||
var sampler = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
try
|
||||
{
|
||||
redis.start(instance);
|
||||
transport.start(instance);
|
||||
health.start();
|
||||
server.start();
|
||||
sampler.scheduleAtFixedRate(() ->
|
||||
{
|
||||
var pool = RedisRuntimeService.connection(instance, "presence").metrics();
|
||||
var heap = java.lang.management.ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
|
||||
var values = new java.util.LinkedHashMap<String, Object>();
|
||||
values.put("uptimeMillis", java.lang.management.ManagementFactory.getRuntimeMXBean().getUptime());
|
||||
values.put("cpuNanos", ProcessHandle.current().info().totalCpuDuration().map(java.time.Duration::toNanos).orElse(-1L));
|
||||
values.put("heapUsedBytes", heap.getUsed());
|
||||
values.put("heapCommittedBytes", heap.getCommitted());
|
||||
values.put("residentBytes", residentBytes());
|
||||
values.put("poolActive", pool.active());
|
||||
values.put("poolIdle", pool.idle());
|
||||
values.put("poolWaiting", pool.waiting());
|
||||
values.put("poolMaximum", pool.maximum());
|
||||
System.out.println("CAPACITY_METRIC " + com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(values));
|
||||
}, 0, 100, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
System.out.println("CAPACITY_PRESENCE_READY " + port);
|
||||
Thread.currentThread().join();
|
||||
}
|
||||
finally
|
||||
{
|
||||
sampler.shutdownNow();
|
||||
server.stop();
|
||||
health.stop();
|
||||
transport.stop();
|
||||
PresenceRegistry.close(instance);
|
||||
RealtimeBus.close(instance);
|
||||
redis.stop();
|
||||
redis.afterApplicationStop();
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Read Linux process resident memory; unsupported platforms report -1.
|
||||
***************************************************************************/
|
||||
private static long residentBytes()
|
||||
{
|
||||
try
|
||||
{
|
||||
for(String line : java.nio.file.Files.readAllLines(java.nio.file.Path.of("/proc/self/status")))
|
||||
{
|
||||
if(line.startsWith("VmRSS:"))
|
||||
{
|
||||
return (Long.parseLong(line.trim().split("\\s+")[1]) * 1024L);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException | NumberFormatException e)
|
||||
{
|
||||
return (-1L);
|
||||
}
|
||||
return (-1L);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Tenant comes from the configured mock identity, never a request tenant parameter.
|
||||
***************************************************************************/
|
||||
public static class FixtureTenant implements PresenceScopeProvider
|
||||
{
|
||||
/************************************************************************
|
||||
** Identical record IDs are isolated by the authenticated fixture identity.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public String tenantScope()
|
||||
{
|
||||
String identity = com.kingsrook.qqq.backend.core.context.QContext.getQSession().getUser().getIdReference();
|
||||
if(!identity.matches("load-t[0-7]-u[0-9]{1,2}"))
|
||||
{
|
||||
throw (new IllegalStateException("Capacity presence requires a configured load identity"));
|
||||
}
|
||||
return (identity.substring(0, identity.indexOf("-u")));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Grant Redis dashboard access only to the fixture's existing administrator.
|
||||
***************************************************************************/
|
||||
public static class AcceptanceSessions extends CollaborationDevServer.DemoSessions
|
||||
{
|
||||
/************************************************************************
|
||||
** Keep normal demo identity/grants and add the service-view permission.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance,
|
||||
com.kingsrook.qqq.backend.core.model.session.QSession target, java.util.Map<String, Object> context)
|
||||
{
|
||||
super.customizeSession(instance, target, context);
|
||||
if("admin".equals(target.getUser().getIdReference()))
|
||||
{
|
||||
target.withPermissions("redisView.hasAccess", "health.read");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-3
@@ -123,11 +123,35 @@ import io.javalin.config.JavalinConfig;
|
||||
public class CollaborationDevServer extends AbstractQQQApplication
|
||||
{
|
||||
private final QInstance instance;
|
||||
static final List<String> USERS = List.of("alice", "bob", "admin");
|
||||
static final List<String> USERS = fixtureUsers();
|
||||
static final String USER_COOKIE = "collab_dev_user";
|
||||
private static final ThreadLocal<String> REQUEST_USER = new ThreadLocal<>();
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt-in bounded identities for the disposable capacity fixture only.
|
||||
*******************************************************************************/
|
||||
private static List<String> fixtureUsers()
|
||||
{
|
||||
int tenants = Integer.getInteger("collab.capacity.tenants", 0);
|
||||
int users = Integer.getInteger("collab.capacity.users", 0);
|
||||
if(tenants < 0 || tenants > 8 || users < 0 || users > 100)
|
||||
{
|
||||
throw (new IllegalArgumentException("Capacity fixture permits at most 8 tenants and 100 users each"));
|
||||
}
|
||||
var identities = new java.util.ArrayList<>(List.of("alice", "bob", "admin"));
|
||||
for(int tenant = 0; tenant < tenants; tenant++)
|
||||
{
|
||||
for(int user = 0; user < users; user++)
|
||||
{
|
||||
identities.add("load-t" + tenant + "-u" + user);
|
||||
}
|
||||
}
|
||||
return (List.copyOf(identities));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep the already seeded instance when Javalin initializes the application.
|
||||
@@ -186,7 +210,7 @@ public class CollaborationDevServer extends AbstractQQQApplication
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
|
||||
.withJavalinConfigCustomizer(config ->
|
||||
{
|
||||
config.jetty.host = "127.0.0.1";
|
||||
config.jetty.host = System.getProperty("collab.dev.bindAddress", "127.0.0.1");
|
||||
userSelection(config);
|
||||
MemoryStorage.routes(config);
|
||||
}));
|
||||
@@ -332,7 +356,7 @@ public class CollaborationDevServer extends AbstractQQQApplication
|
||||
{
|
||||
case "alice" -> "Alice Morgan";
|
||||
case "bob" -> "Bob Chen";
|
||||
default -> "Demo Administrator";
|
||||
default -> user.equals("admin") ? "Demo Administrator" : user;
|
||||
};
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference(user).withFullName(name))
|
||||
.withPermissions("person.read", "person.comment")
|
||||
|
||||
Reference in new issue
Block a user