Skip to content

Commit ceb3a0f

Browse files
SteRicciomergify[bot]claude
authored
Support auto scaling (#171)
* added heroku autoscaling plan * prepare auto-scaling * solved sonarcloud issues * fixed tests * allow storing logs to S3 bucket * Revert "allow storing logs to S3 bucket" This reverts commit a16dfc1. * fixed log files upload to s3 in multi dyno environment * add missing db migrations * refactored job class * feat: export JobRepository, RecordSocketAssociationRepository, ConnectedSocketRepository from package entry point - Add exports for JobRepository, RecordSocketAssociationRepository, and ConnectedSocketRepository to the main package entry point - Add type export for JobRow to support job-queue-persistence and record-concurrency dependent tasks Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat: make job.survey_id nullable so global jobs can be persisted * solved potential issue in tests * fixed issues reported by Copilot * feat: bump minor version --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Stefano Ricci <SteRiccio@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 7299205 commit ceb3a0f

64 files changed

Lines changed: 1884 additions & 32 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.template

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ LOG_RETENTION_DAYS=30
2929
LOG_UPLOAD_INTERVAL_MS=60000
3030
LOG_S3_PREFIX=logs
3131
## Optional S3-backed log shipping (requires LOG_S3_ENABLED=true and file storage S3 to be enabled)
32+
## Log files are uploaded under a per-instance S3 prefix (LOG_S3_PREFIX/<instanceId>/...) to avoid
33+
## different instances overwriting each other's logs. The instance id is taken from DYNO (set
34+
## automatically by Heroku) or HOSTNAME, falling back to the OS hostname; no manual setup is usually needed.
3235
LOG_S3_ENABLED=false
3336
FILE_STORAGE_AWS_ACCESS_KEY=
3437
FILE_STORAGE_AWS_SECRET_ACCESS_KEY=

.yarn/install-state.gz

-96 Bytes
Binary file not shown.

docs/superpowers/specs/2026-08-06-heroku-horizontal-autoscaling-design.md

Lines changed: 250 additions & 0 deletions
Large diffs are not rendered by default.

src/clusterBus/clusterBus.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { DB } from '../db'
2+
import { Logger } from '../log'
3+
import { WsRelayMessageRepository } from '../repository'
4+
import { runWithClusterLock } from './clusterLock'
5+
import { ClusterEvent, ClusterEventHandler } from './types'
6+
7+
type ListenConnection = Awaited<ReturnType<typeof DB.connect>>
8+
9+
// Postgres caps NOTIFY payloads at 8000 bytes; stay safely under that before spilling to the DB.
10+
const NOTIFY_PAYLOAD_SAFE_THRESHOLD_BYTES = 7000
11+
12+
const CHANNEL = 'arena_cluster_event'
13+
14+
// Spilled-over payloads are only ever read moments after insert, by the NOTIFY that points at
15+
// them - a generous TTL here just bounds ws_relay_message's size if a dyno never claims one.
16+
const RELAY_MESSAGE_TTL_MS = 10 * 60_000
17+
const RELAY_MESSAGE_SWEEP_INTERVAL_MS = 5 * 60_000
18+
const RELAY_MESSAGE_SWEEP_LOCK_NAME = 'ws-relay-message-ttl-sweep'
19+
20+
type InlineEnvelope = { inline: true; event: ClusterEvent }
21+
type RelayedEnvelope = { inline: false; relayMessageId: string }
22+
type Envelope = InlineEnvelope | RelayedEnvelope
23+
24+
/**
25+
* Postgres-backed cluster bus: a single LISTEN/NOTIFY channel shared by every dyno.
26+
* `publish` never throws - delivery is best-effort, matching the pre-existing
27+
* "check before emitting, self-heal otherwise" behavior of WebSocketServer.
28+
*/
29+
export class ClusterBus {
30+
private static readonly logger: Logger = new Logger('ClusterBus')
31+
private static listenConnection: ListenConnection | null = null
32+
private static readonly handlers: ClusterEventHandler[] = []
33+
private static sweepInterval: NodeJS.Timeout | null = null
34+
35+
static async init(): Promise<void> {
36+
if (ClusterBus.listenConnection) return
37+
38+
const connection = await DB.connect({ direct: true })
39+
ClusterBus.listenConnection = connection
40+
41+
connection.client.on('notification', (msg: { payload?: string }) => {
42+
ClusterBus.onNotification(msg).catch((error) => ClusterBus.logger.error(`error handling notification: ${error}`))
43+
})
44+
45+
await connection.none(`LISTEN ${CHANNEL}`)
46+
ClusterBus.logger.info(`listening on channel "${CHANNEL}"`)
47+
48+
ClusterBus.sweepInterval = setInterval(() => {
49+
runWithClusterLock({
50+
lockName: RELAY_MESSAGE_SWEEP_LOCK_NAME,
51+
fn: async () => {
52+
await WsRelayMessageRepository.deleteExpired(RELAY_MESSAGE_TTL_MS)
53+
},
54+
}).catch((error) => ClusterBus.logger.error(`error running ws_relay_message TTL sweep: ${error}`))
55+
}, RELAY_MESSAGE_SWEEP_INTERVAL_MS)
56+
ClusterBus.sweepInterval.unref()
57+
}
58+
59+
static async shutdown(): Promise<void> {
60+
if (ClusterBus.sweepInterval) clearInterval(ClusterBus.sweepInterval)
61+
ClusterBus.sweepInterval = null
62+
63+
const connection = ClusterBus.listenConnection
64+
if (!connection) return
65+
66+
ClusterBus.listenConnection = null
67+
try {
68+
await connection.none(`UNLISTEN ${CHANNEL}`)
69+
} catch (error) {
70+
ClusterBus.logger.error(`error unlistening: ${error}`)
71+
} finally {
72+
await connection.done()
73+
}
74+
}
75+
76+
/**
77+
* Registers a handler invoked for every cluster event received, including this dyno's own
78+
* publications (Postgres delivers NOTIFY to every session listening on the channel).
79+
* Handlers are expected to no-op when the event doesn't target something they own locally.
80+
*/
81+
static onEvent(handler: ClusterEventHandler): void {
82+
ClusterBus.handlers.push(handler)
83+
}
84+
85+
static async publish(event: ClusterEvent): Promise<void> {
86+
try {
87+
const serializedEvent = JSON.stringify(event)
88+
89+
const envelope: Envelope =
90+
Buffer.byteLength(serializedEvent, 'utf8') <= NOTIFY_PAYLOAD_SAFE_THRESHOLD_BYTES
91+
? { inline: true, event }
92+
: { inline: false, relayMessageId: await WsRelayMessageRepository.insert(event) }
93+
94+
await DB.query('SELECT pg_notify($1, $2)', [CHANNEL, JSON.stringify(envelope)])
95+
} catch (error) {
96+
ClusterBus.logger.error(`error publishing event: ${error}`)
97+
}
98+
}
99+
100+
private static async onNotification(msg: { payload?: string }): Promise<void> {
101+
if (!msg.payload) return
102+
103+
const envelope: Envelope = JSON.parse(msg.payload)
104+
const event = envelope.inline ? envelope.event : await WsRelayMessageRepository.getById(envelope.relayMessageId)
105+
if (!event) return
106+
107+
ClusterBus.handlers.forEach((handler) => handler(event as ClusterEvent))
108+
}
109+
}

src/clusterBus/clusterLock.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { DB } from '../db'
2+
import { Logger } from '../log'
3+
4+
const logger: Logger = new Logger('ClusterLock')
5+
6+
/**
7+
* Runs `fn` while holding a cluster-wide Postgres advisory lock named `lockName`.
8+
* Non-blocking: if another dyno already holds the lock, `fn` is skipped and `false` is returned.
9+
* The lock is session-scoped, acquired and released on the same pooled connection via `DB.task`,
10+
* so it is always released even if `fn` throws.
11+
*
12+
* @param params - Lock name and the function to run while holding it
13+
*/
14+
export const runWithClusterLock = async (params: { lockName: string; fn: () => Promise<void> }): Promise<boolean> => {
15+
const { lockName, fn } = params
16+
17+
return DB.task(async (task) => {
18+
const { locked } = await task.one<{ locked: boolean }>('SELECT pg_try_advisory_lock(hashtext($1)) AS locked', [
19+
lockName,
20+
])
21+
if (!locked) return false
22+
23+
try {
24+
await fn()
25+
return true
26+
} catch (error) {
27+
logger.error(`error running task under cluster lock "${lockName}": ${error}`)
28+
throw error
29+
} finally {
30+
await task.one('SELECT pg_advisory_unlock(hashtext($1))', [lockName])
31+
}
32+
})
33+
}

src/clusterBus/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { ClusterBus } from './clusterBus'
2+
export { runWithClusterLock } from './clusterLock'
3+
export type { ClusterEvent, ClusterEventHandler } from './types'
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { DB } from '../../db'
2+
import { DBMigrator } from '../../db/dbMigrator'
3+
import { ClusterBus } from '../clusterBus'
4+
import { runWithClusterLock } from '../clusterLock'
5+
import { ClusterEvent } from '../types'
6+
7+
let received: ClusterEvent[] = []
8+
9+
const waitFor = async (predicate: () => boolean, timeoutMs = 2000): Promise<void> => {
10+
const start = Date.now()
11+
while (!predicate()) {
12+
if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for cluster event')
13+
await new Promise((resolve) => setTimeout(resolve, 20))
14+
}
15+
}
16+
17+
// Top-level (not per-describe) hooks: ClusterBus/DB are file-wide singletons, so init and
18+
// pool teardown must each run exactly once for the whole file, regardless of how many
19+
// describe blocks use them.
20+
beforeAll(async () => {
21+
await DBMigrator.migrateSchema()
22+
await ClusterBus.init()
23+
// A single handler collecting every event: ClusterBus.onEvent has no matching "off", so
24+
// registering per-test would leak handlers across tests within this file.
25+
ClusterBus.onEvent((event) => received.push(event))
26+
})
27+
28+
afterEach(() => {
29+
received = []
30+
})
31+
32+
afterAll(async () => {
33+
await ClusterBus.shutdown()
34+
await DB.$pool.end()
35+
})
36+
37+
describe('ClusterBus', () => {
38+
test('publish delivers an inline event back to this dyno through its own listener', async () => {
39+
await ClusterBus.publish({ targetType: 'test', targetId: 'inline-1', eventType: 'ping', message: { n: 1 } })
40+
41+
await waitFor(() => received.length === 1)
42+
expect(received[0]).toEqual({ targetType: 'test', targetId: 'inline-1', eventType: 'ping', message: { n: 1 } })
43+
})
44+
45+
test('publish spills oversized payloads through ws_relay_message and still delivers them', async () => {
46+
const bigMessage = { blob: 'x'.repeat(8000) }
47+
48+
await ClusterBus.publish({ targetType: 'test', targetId: 'big-1', eventType: 'ping-big', message: bigMessage })
49+
50+
await waitFor(() => received.length === 1)
51+
expect(received[0].message).toEqual(bigMessage)
52+
})
53+
54+
test('publish never throws, even if the event cannot be delivered', async () => {
55+
const circular: Record<string, unknown> = {}
56+
circular.self = circular // not JSON-serializable
57+
58+
await expect(
59+
ClusterBus.publish({ targetType: 'test', targetId: 'bad-1', eventType: 'ping', message: circular })
60+
).resolves.toBeUndefined()
61+
})
62+
})
63+
64+
describe('runWithClusterLock', () => {
65+
test('only one of two concurrent callers runs, and the lock is released afterwards', async () => {
66+
const lockName = `test-lock-${Date.now()}`
67+
let runs = 0
68+
const runFn = async (): Promise<void> => {
69+
runs++
70+
await new Promise((resolve) => setTimeout(resolve, 200))
71+
}
72+
73+
const [a, b] = await Promise.all([
74+
runWithClusterLock({ lockName, fn: runFn }),
75+
runWithClusterLock({ lockName, fn: runFn }),
76+
])
77+
78+
expect([a, b].filter(Boolean)).toHaveLength(1)
79+
expect(runs).toBe(1)
80+
81+
// the lock was released after the first run completed, so a later caller can acquire it
82+
const c = await runWithClusterLock({ lockName, fn: runFn })
83+
expect(c).toBe(true)
84+
expect(runs).toBe(2)
85+
})
86+
})

src/clusterBus/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Generic "invalidate/deliver this on every dyno" envelope. `targetType`/`targetId` are opaque
3+
* to ClusterBus - each subsystem (WebSocketServer, record/survey cache invalidation, ...)
4+
* defines its own vocabulary and is expected to no-op on events it doesn't recognize.
5+
*/
6+
export interface ClusterEvent {
7+
targetType: string
8+
targetId: string
9+
eventType: string
10+
message: any
11+
}
12+
13+
export type ClusterEventHandler = (event: ClusterEvent) => void
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use strict'
2+
3+
var dbm
4+
var type
5+
var seed
6+
var fs = require('fs')
7+
var path = require('path')
8+
var Promise
9+
10+
/**
11+
* We receive the dbmigrate dependency from dbmigrate initially.
12+
* This enables us to not have to rely on NODE_PATH.
13+
*/
14+
exports.setup = function (options, seedLink) {
15+
dbm = options.dbmigrate
16+
type = dbm.dataType
17+
seed = seedLink
18+
Promise = options.Promise
19+
}
20+
21+
exports.up = function (db) {
22+
var filePath = path.join(__dirname, 'sqls', '20260806120000-add-tables-cluster-bus-up.sql')
23+
return new Promise(function (resolve, reject) {
24+
fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) {
25+
if (err) return reject(err)
26+
console.log('received data: ' + data)
27+
28+
resolve(data)
29+
})
30+
}).then(function (data) {
31+
return db.runSql(data)
32+
})
33+
}
34+
35+
exports.down = function (db) {
36+
var filePath = path.join(__dirname, 'sqls', '20260806120000-add-tables-cluster-bus-down.sql')
37+
return new Promise(function (resolve, reject) {
38+
fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) {
39+
if (err) return reject(err)
40+
console.log('received data: ' + data)
41+
42+
resolve(data)
43+
})
44+
}).then(function (data) {
45+
return db.runSql(data)
46+
})
47+
}
48+
49+
exports._meta = {
50+
version: 1,
51+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use strict'
2+
3+
var dbm
4+
var type
5+
var seed
6+
var fs = require('fs')
7+
var path = require('path')
8+
var Promise
9+
10+
/**
11+
* We receive the dbmigrate dependency from dbmigrate initially.
12+
* This enables us to not have to rely on NODE_PATH.
13+
*/
14+
exports.setup = function (options, seedLink) {
15+
dbm = options.dbmigrate
16+
type = dbm.dataType
17+
seed = seedLink
18+
Promise = options.Promise
19+
}
20+
21+
exports.up = function (db) {
22+
var filePath = path.join(__dirname, 'sqls', '20260819100000-create-table-job-up.sql')
23+
return new Promise(function (resolve, reject) {
24+
fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) {
25+
if (err) return reject(err)
26+
console.log('received data: ' + data)
27+
28+
resolve(data)
29+
})
30+
}).then(function (data) {
31+
return db.runSql(data)
32+
})
33+
}
34+
35+
exports.down = function (db) {
36+
var filePath = path.join(__dirname, 'sqls', '20260819100000-create-table-job-down.sql')
37+
return new Promise(function (resolve, reject) {
38+
fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) {
39+
if (err) return reject(err)
40+
console.log('received data: ' + data)
41+
42+
resolve(data)
43+
})
44+
}).then(function (data) {
45+
return db.runSql(data)
46+
})
47+
}
48+
49+
exports._meta = {
50+
version: 1,
51+
}

0 commit comments

Comments
 (0)