Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c470c55
docs: add load test messaging workers design
claude Apr 21, 2026
70502fd
docs: add load test messaging workers implementation plan
claude Apr 21, 2026
182ef25
feat(loadgen): scaffold main.go with subcommand dispatch
claude Apr 21, 2026
b6e9ac1
feat(loadgen): add Preset type and four built-in presets
claude Apr 21, 2026
354afa0
test(loadgen): guard preset lookup ok in uniform/realistic shape tests
claude Apr 21, 2026
2ae8310
feat(loadgen): deterministic fixture generation from (preset, seed)
claude Apr 21, 2026
7cd9a80
test(loadgen): drop unused default branch in realistic room-type switch
claude Apr 21, 2026
3df2cd6
fix(loadgen): address gocritic/errcheck findings in preset.go
claude Apr 21, 2026
4641d98
refactor(loadgen): pass Preset by pointer; revert lint config bump
claude Apr 21, 2026
b53be6b
test(loadgen): cover pickMembers padding and sampleWithoutReplacement…
claude Apr 21, 2026
9a9b6bc
feat(loadgen): Seed and Teardown mongo collections from fixtures
claude Apr 21, 2026
3787437
feat(loadgen): Prometheus registry with loadgen collectors
claude Apr 21, 2026
68e48b3
feat(loadgen): collector correlates publishes with replies and broadc…
claude Apr 22, 2026
3d483b8
fix(loadgen): close race in Collector samples; add coverage tests
claude Apr 22, 2026
c10f31b
feat(loadgen): percentiles, summary printer, CSV export, exit code
claude Apr 22, 2026
9ef41ef
test(loadgen): drop redundant nolint; _test.go is already excluded fr…
claude Apr 22, 2026
a5d86e5
feat(loadgen): open-loop generator with injected publisher
claude Apr 22, 2026
7e79a79
fix(loadgen): clear Collector orphans on publish failure; tighten tests
claude Apr 22, 2026
2bad977
feat(loadgen): JetStream consumer-lag sampler
claude Apr 22, 2026
9c8d962
fix(loadgen): warn (not debug) on consumer poll errors; document Snap…
claude Apr 22, 2026
021a409
feat(loadgen): wire seed/run/teardown subcommands in main.go
claude Apr 22, 2026
eac94f2
fix(loadgen): skip byReqID in canonical mode to avoid false missing-r…
claude Apr 22, 2026
b4ea921
feat(loadgen): docker-compose harness, Dockerfile, grafana dashboard
claude Apr 22, 2026
feb4c19
fix(loadgen): drop NATS scrape job (port 8222 serves JSON, not Promet…
claude Apr 22, 2026
d3b1e54
feat(loadgen): scoped Makefile for harness
claude Apr 22, 2026
6084ba7
test(loadgen): integration test for end-to-end wiring
claude Apr 22, 2026
dd19404
docs(loadgen): add operator README
claude Apr 22, 2026
69c0eab
test(loadgen): add unit tests for main helpers and sampler Snapshot
claude Apr 22, 2026
57d9f93
fix(loadgen): address final review — indexes, canonical rate, DM broa…
claude Apr 22, 2026
1905810
refactor(loadgen): simplify pass — pre-compute content, unify handler…
claude Apr 22, 2026
eb8eea8
fix(loadgen): split sent counter into warmup/measured phases for clea…
claude Apr 22, 2026
fdde0d0
fix(loadgen): index users.account so broadcast-worker enrichment isn'…
claude Apr 24, 2026
54acee8
perf(loadgen): dispatch publishes to worker pool; add opt-in pprof
claude Apr 24, 2026
45ff2ad
Merge branch 'main' into claude/load-test-messaging-workers-tDKZn
hmchangw Apr 27, 2026
8a9e64d
fix: group to channel
hmchangw Apr 27, 2026
6ef91ee
fix linting
hmchangw Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,780 changes: 2,780 additions & 0 deletions docs/superpowers/plans/2026-04-21-load-test-messaging-workers.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ require (
github.com/caarlos0/env/v11 v11.4.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/docker/docker v27.1.1+incompatible
github.com/elastic/go-elasticsearch/v8 v8.19.3
github.com/gin-gonic/gin v1.12.0
github.com/gocql/gocql v1.7.0
github.com/google/uuid v1.6.0
github.com/nats-io/jwt/v2 v2.8.1
github.com/nats-io/nats-server/v2 v2.12.6
github.com/nats-io/nats.go v1.50.0
github.com/nats-io/nkeys v0.4.15
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.34.0
Expand Down Expand Up @@ -52,7 +54,6 @@ require (
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/elastic/elastic-transport-go/v8 v8.8.0 // indirect
github.com/elastic/go-elasticsearch/v8 v8.19.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
Expand Down Expand Up @@ -94,7 +95,6 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
Expand Down
12 changes: 0 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ github.com/Marz32onE/instrumentation-go/otel-nats v0.2.0 h1:J+S/NmcUf+dSXQMzNkNV
github.com/Marz32onE/instrumentation-go/otel-nats v0.2.0/go.mod h1:xgj7JbYX3qHLZ8X7A6Hvc1yeE+t4L+KAgeo9h0JWJ1o=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op h1:+OSa/t11TFhqfrX0EOSqQBDJ0YlpmK0rDSiB19dg9M0=
github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE=
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
Expand Down Expand Up @@ -110,8 +108,6 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
Expand Down Expand Up @@ -146,8 +142,6 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q=
github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
Expand All @@ -173,8 +167,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.0 h1:fdwAT1d6DZW/4LUz5rkvQUe5leGEwjjOQYntzVRKvjE=
github.com/nats-io/nats-server/v2 v2.11.0/go.mod h1:leXySghbdtXSUmWem8K9McnJ6xbJOb0t9+NQ5HTRZjI=
github.com/nats-io/nats-server/v2 v2.12.6 h1:Egbx9Vl7Ch8wTtpXPGqbehkZ+IncKqShUxvrt1+Enc8=
github.com/nats-io/nats-server/v2 v2.12.6/go.mod h1:4HPlrvtmSO3yd7KcElDNMx9kv5EBJBnJJzQPptXlheo=
github.com/nats-io/nats.go v1.50.0 h1:5zAeQrTvyrKrWLJ0fu02W3br8ym57qf7csDzgLOpcds=
Expand Down Expand Up @@ -277,8 +269,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
Expand Down Expand Up @@ -356,8 +346,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
Expand Down
12 changes: 12 additions & 0 deletions pkg/subject/subject.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,18 @@ func RoomsGetWildcard() string {
return "chat.user.*.request.rooms.get.*"
}

func UserResponseWildcard() string {
return "chat.user.*.response.>"
}

func RoomEventWildcard() string {
return "chat.room.*.event"
}

func UserRoomEventWildcard() string {
return "chat.user.*.event.room"
}
Comment on lines +254 to +264
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add unit tests for the new exported helpers.

As per coding guidelines: "Every exported function in pkg/ must have corresponding test cases." Please add tests for UserResponseWildcard, RoomEventWildcard, and UserRoomEventWildcard in pkg/subject/subject_test.go asserting the exact returned strings.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/subject/subject.go` around lines 180 - 190, Add unit tests for the three
new exported helpers: create tests in the package test file that call
UserResponseWildcard, RoomEventWildcard, and UserRoomEventWildcard and assert
they return exactly "chat.user.*.response.>", "chat.room.*.event", and
"chat.user.*.event.room" respectively; implement either three small Test...
functions or a table-driven TestWildcards that uses testing.T and
t.Fatalf/t.Errorf to fail on mismatches, referencing the functions by name so
the test imports the same package and verifies the exact string values.


// --- natsrouter patterns (use {param} placeholders for named extraction) ---

func MsgHistoryPattern(siteID string) string {
Expand Down
59 changes: 59 additions & 0 deletions tools/loadgen/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# loadgen

Capacity-baseline load generator for the single-site messaging pipeline
(`message-gatekeeper` → `MESSAGES_CANONICAL` → `message-worker` +
`broadcast-worker`). Single Go binary with three subcommands.

## Quick start

```
make -C tools/loadgen/deploy up
make -C tools/loadgen/deploy seed PRESET=medium
make -C tools/loadgen/deploy run PRESET=medium RATE=500 DURATION=60s
```

For live dashboards:

```
make -C tools/loadgen/deploy run-dashboards PRESET=medium
# Grafana at http://localhost:3000 (anonymous admin)
```

Tear down:

```
make -C tools/loadgen/deploy down
```

## Presets

| preset | users | rooms | notes |
|-------------|--------|-------|--------------------------------------------------------|
| `small` | 10 | 5 | uniform, 200-byte content |
| `medium` | 1 000 | 100 | uniform, 200-byte content |
| `large` | 10 000 | 1 000 | uniform, 200-byte content |
| `realistic` | 1 000 | 100 | Zipf senders, mixed room sizes, 50–2000 bytes, mentions|

## Subcommands

- `loadgen seed --preset=<name> [--seed=42]` — idempotently populate
MongoDB with deterministic fixtures.
- `loadgen run --preset=<name> [flags]` — open-loop publish at `--rate`
msgs/sec for `--duration`, print a summary at the end. Flags:
`--seed`, `--warmup`, `--inject=frontdoor|canonical`, `--csv=<path>`.
- `loadgen teardown` — drop the three seeded collections.

## Reading the summary

- `final_pending == 0` on both durables, zero errors → the pipeline is
sustaining your target rate.
- `final_pending` climbing, or error counts > 0 → over capacity or a
regression upstream of the worker.

## Non-goals

- Not a CI regression gate. Invoked manually.
- Not an auth benchmark. Uses shared `backend.creds`.
- Not a cross-site benchmark. Single-site only.
- Not an absolute-number tool. Numbers vary by host — compare within one
machine across changes, don't compare across machines.
155 changes: 155 additions & 0 deletions tools/loadgen/collector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package main

import (
"sort"
"sync"
"time"
)

type publishEntry struct {
publishedAt time.Time
}

// sample pairs a latency with its publish timestamp so warmup can discard by time.
type sample struct {
publishedAt time.Time
latency time.Duration
}

// Collector correlates publishes with replies (E1) and broadcasts (E2).
type Collector struct {
m *Metrics
preset string
mu sync.Mutex
byReqID map[string]publishEntry
byMsgID map[string]publishEntry
e1 []sample
e2 []sample
}

// NewCollector returns a ready-to-use Collector.
func NewCollector(m *Metrics, preset string) *Collector {
return &Collector{
m: m, preset: preset,
byReqID: make(map[string]publishEntry),
byMsgID: make(map[string]publishEntry),
}
}

// RecordPublish stores the publish time under both correlation keys.
func (c *Collector) RecordPublish(requestID, messageID string, t time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
c.byReqID[requestID] = publishEntry{publishedAt: t}
c.byMsgID[messageID] = publishEntry{publishedAt: t}
}

// RecordReply consumes one pending publish keyed by requestID.
func (c *Collector) RecordReply(requestID string, at time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.byReqID[requestID]
if !ok {
return
}
delete(c.byReqID, requestID)
d := at.Sub(e.publishedAt)
c.e1 = append(c.e1, sample{publishedAt: e.publishedAt, latency: d})
c.m.E1Latency.WithLabelValues(c.preset).Observe(d.Seconds())
}

// RecordPublishBroadcastOnly stores only the message-ID correlation, for
// injection modes that bypass the gatekeeper (no reply is expected).
func (c *Collector) RecordPublishBroadcastOnly(messageID string, t time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
c.byMsgID[messageID] = publishEntry{publishedAt: t}
}

// RecordPublishFailed removes entries previously stored by RecordPublish.
// Use when the publish itself failed (message never reached NATS) so the
// orphans do not inflate Finalize's missing-reply / missing-broadcast counts.
func (c *Collector) RecordPublishFailed(requestID, messageID string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.byReqID, requestID)
delete(c.byMsgID, messageID)
}

// RecordBroadcast consumes one pending publish keyed by messageID.
func (c *Collector) RecordBroadcast(messageID string, at time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.byMsgID[messageID]
if !ok {
return
}
delete(c.byMsgID, messageID)
d := at.Sub(e.publishedAt)
c.e2 = append(c.e2, sample{publishedAt: e.publishedAt, latency: d})
c.m.E2Latency.WithLabelValues(c.preset).Observe(d.Seconds())
}

// DiscardBefore drops any samples whose publish time is before cutoff (warmup).
func (c *Collector) DiscardBefore(cutoff time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
c.e1 = filterAtOrAfter(c.e1, cutoff)
c.e2 = filterAtOrAfter(c.e2, cutoff)
}

func filterAtOrAfter(in []sample, cutoff time.Time) []sample {
out := in[:0]
for i := range in {
if !in[i].publishedAt.Before(cutoff) {
out = append(out, in[i])
}
}
return out
}

// Finalize returns the count of unmatched publishes as missing replies and broadcasts.
func (c *Collector) Finalize() (missingReplies int, missingBroadcasts int) {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.byReqID), len(c.byMsgID)
}

// E1Count returns the number of matched E1 samples.
func (c *Collector) E1Count() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.e1)
}

// E2Count returns the number of matched E2 samples.
func (c *Collector) E2Count() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.e2)
}

// E1Samples returns a sorted copy of E1 latencies for tests/reporting.
func (c *Collector) E1Samples() []time.Duration {
c.mu.Lock()
defer c.mu.Unlock()
return c.snapshotLatenciesLocked(c.e1)
}

// E2Samples returns a sorted copy of E2 latencies for tests/reporting.
func (c *Collector) E2Samples() []time.Duration {
c.mu.Lock()
defer c.mu.Unlock()
return c.snapshotLatenciesLocked(c.e2)
}

// snapshotLatenciesLocked copies and sorts latencies from in.
// Callers must hold c.mu before calling this method.
func (c *Collector) snapshotLatenciesLocked(in []sample) []time.Duration {
out := make([]time.Duration, len(in))
for i := range in {
out[i] = in[i].latency
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
Loading