Skip to content

Commit 4ba022b

Browse files
committed
Merge remote-tracking branch 'refs/remotes/origin/fix/rate-limiter' into fix/rate-limiter
2 parents 00a95af + 4dd93a5 commit 4ba022b

21 files changed

Lines changed: 500 additions & 9 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"nostream": patch
3+
---
4+
5+
Implement geohash wildcard/prefix behavior for `#g` filters (closes #265): a
6+
criterion ending in `*` matches any event `g` tag whose value starts with the
7+
prefix before `*`; exact matching (no `*`) is unchanged. Only normal geohash
8+
prefixes are intended as input. This is a Nostream extension, not part of
9+
NIP-12.

.changeset/jolly-canyons-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
perf: added k6 performance tests for connection and message rate limiting

CONTRIBUTING.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,41 @@ To observe client and subscription counts in real-time during a test, you can in
302302
docker compose logs -f nostream
303303
```
304304

305+
## Performance Testing (k6)
306+
307+
Nostream includes k6-based load tests to validate rate limiter behavior under concurrent WebSocket
308+
connections. These tests verify that connection and message rate limits are correctly enforced.
309+
310+
### Prerequisites
311+
312+
Install [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/) before running performance
313+
tests. k6 is a standalone Go binary and is not included as an npm dependency.
314+
315+
### Running the Tests
316+
317+
Ensure the relay is running first (`pnpm run cli -- start`), then:
318+
319+
```bash
320+
# Test connection rate limiting
321+
pnpm run cli -- dev test:perf:connection
322+
323+
# Test message rate limiting
324+
pnpm run cli -- dev test:perf:message
325+
```
326+
327+
To test against a different relay instance:
328+
329+
```bash
330+
k6 run -e RELAY_URL=ws://your-host:8008 test/performance/connection-limiting-k6.ts
331+
```
332+
333+
### What the Tests Validate
334+
335+
- **Connection rate limiter** — Ramps concurrent connections through multiple stages and verifies
336+
the relay rejects excess connections beyond the configured limit (default: 12 conn/sec).
337+
- **Message rate limiter** — Opens WebSocket connections and sends continuous REQ messages,
338+
verifying the relay returns NOTICE rejections when the message rate limit is exceeded.
339+
305340
## Local Quality Checks
306341

307342
Run dead code and dependency analysis before opening a pull request:

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
"test:load": "node -r ts-node/register ./scripts/security-load-test.ts",
7777
"smoke:nip03": "node -r ts-node/register scripts/smoke-nip03.ts",
7878
"test:integration": "cucumber-js",
79+
"test:performance:connection-rate-limit": "k6 run test/performance/connection-limiting-k6.ts",
80+
"test:performance:message-rate-limit": "k6 run test/performance/message-limiting-k6.ts",
7981
"cover:integration": "nyc --report-dir .coverage/integration pnpm run test:integration -p cover",
8082
"export": "node --env-file-if-exists=.env -r ts-node/register src/scripts/export-events.ts",
8183
"docker:compose:start": "pnpm run cli -- start",
@@ -124,6 +126,7 @@
124126
"@types/chai-as-promised": "^7.1.5",
125127
"@types/express": "4.17.21",
126128
"@types/js-yaml": "4.0.5",
129+
"@types/k6": "^1.7.0",
127130
"@types/mocha": "^9.1.1",
128131
"@types/node": "^24.12.2",
129132
"@types/pg": "^8.6.5",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli/commands/dev.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,21 @@ export const runDevTestIntegration = async (): Promise<number> => {
137137
() => runCommand('pnpm', ['run', 'test:integration']),
138138
)
139139
}
140+
141+
export const runDevTestPerfConnection = async (): Promise<number> => {
142+
return runWithSpinner(
143+
'Running connection rate limit performance test...',
144+
'Connection rate limit test completed',
145+
'Connection rate limit test failed',
146+
() => runCommand('k6', ['run', 'test/performance/connection-limiting-k6.ts']),
147+
)
148+
}
149+
150+
export const runDevTestPerfMessage = async (): Promise<number> => {
151+
return runWithSpinner(
152+
'Running message rate limit performance test...',
153+
'Message rate limit test completed',
154+
'Message rate limit test failed',
155+
() => runCommand('k6', ['run', 'test/performance/message-limiting-k6.ts']),
156+
)
157+
}

src/cli/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
runDevTestCli,
2929
runDevTestIntegration,
3030
runDevTestUnit,
31+
runDevTestPerfConnection,
32+
runDevTestPerfMessage
3133
} from './commands/dev'
3234
import { runTui } from './tui/main'
3335
import { logError, logInfo } from './utils/output'
@@ -97,6 +99,8 @@ const devSubHelp: Record<string, string> = {
9799
'test:unit': 'Usage: nostream dev test:unit',
98100
'test:cli': 'Usage: nostream dev test:cli',
99101
'test:integration': 'Usage: nostream dev test:integration',
102+
'test:perf:connection': 'Usage: nostream dev test:perf:connection',
103+
'test:perf:message': 'Usage: nostream dev test:perf:message',
100104
}
101105

102106
const withErrorBoundary =
@@ -410,6 +414,10 @@ cli
410414
return runDevTestCli()
411415
case 'test:integration':
412416
return runDevTestIntegration()
417+
case 'test:perf:connection':
418+
return runDevTestPerfConnection()
419+
case 'test:perf:message':
420+
return runDevTestPerfMessage()
413421
default:
414422
logInfo(
415423
'Usage: nostream dev <db:clean|db:reset|seed:relay|docker:clean|test:unit|test:cli|test:integration> [args]',

src/constants/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export enum EventTags {
5656
Invoice = 'bolt11',
5757
// NIP-03: target event kind on an OpenTimestamps attestation
5858
Kind = 'k',
59+
// NIP-12: geohash tag for location-based queries
60+
Geohash = 'g',
5961
}
6062

6163
export const ALL_RELAYS = 'ALL_RELAYS'

src/repositories/event-repository.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { DBEvent, Event } from '../@types/event'
4040
import { EventPurgeCounts, EventRetentionOptions, IEventRepository, IQueryResult } from '../@types/repositories'
4141
import { toBuffer, toJSON } from '../utils/transform'
4242
import { createLogger } from '../factories/logger-factory'
43-
import { isGenericTagQuery } from '../utils/filter'
43+
import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from '../utils/filter'
4444
import { SubscriptionFilter } from '../@types/subscription'
4545

4646
const even = pipe(modulo(__, 2), equals(0))
@@ -193,8 +193,21 @@ export class EventRepository implements IEventRepository {
193193
isEmpty,
194194
() => andWhereRaw('1 = 0', bd),
195195
forEach(
196-
(criterion: string) =>
197-
void orWhereRaw('event_tags.tag_name = ? AND event_tags.tag_value = ?', [filterName[1], criterion], bd),
196+
(criterion: string) => {
197+
if (isGeohashPrefixCriterion(filterName, criterion)) {
198+
return void orWhereRaw(
199+
'event_tags.tag_name = ? AND event_tags.tag_value LIKE ?',
200+
[filterName[1], `${stripGeohashPrefixWildcard(criterion)}%`],
201+
bd,
202+
)
203+
}
204+
205+
return void orWhereRaw(
206+
'event_tags.tag_name = ? AND event_tags.tag_value = ?',
207+
[filterName[1], criterion],
208+
bd,
209+
)
210+
},
198211
),
199212
)(criteria)
200213
})

src/schemas/base-schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { z } from 'zod'
22

3+
import { GEOHASH_FILTER_PATTERN, GEOHASH_PATTERN } from '../utils/geohash'
4+
35
const lowerHexRegex = /^[0-9a-f]+$/
46

7+
// NIP-12 geohash schemas
8+
export const geohashSchema = z.string().regex(GEOHASH_PATTERN, 'Invalid geohash')
9+
export const geohashFilterValueSchema = z.string().regex(GEOHASH_FILTER_PATTERN, 'Invalid geohash filter')
10+
511
export const prefixSchema = z.string().regex(lowerHexRegex).min(4).max(64)
612

713
export const idSchema = z.string().regex(lowerHexRegex).length(64)

0 commit comments

Comments
 (0)