feat: clustering

This commit is contained in:
Ricardo Arturo Cabral Mejia 2022-08-31 12:52:21 +00:00
parent fc38d9a472
commit 58c8a1371a
No known key found for this signature in database
GPG Key ID: 5931EBF43A650245
15 changed files with 185 additions and 59 deletions

2
.nvmrc
View File

@ -1 +1 @@
v16.16.0 v18.8.0

View File

@ -33,7 +33,8 @@
], ],
"exclude": [ "exclude": [
"src/@types", "src/@types",
"src/constants" "src/constants",
"src/database"
], ],
"require": [ "require": [
"ts-node/register", "ts-node/register",

View File

@ -7,7 +7,7 @@ module.exports = {
password: process.env.DB_PASSWORD ?? 'postgres', password: process.env.DB_PASSWORD ?? 'postgres',
database: process.env.DB_NAME ?? 'nostr-ts-relay', database: process.env.DB_NAME ?? 'nostr-ts-relay',
}, },
pool: { min: 0, max: 7 }, pool: { min: 4, max: 16 },
seeds: { seeds: {
directory: './seeds', directory: './seeds',
}, },

View File

@ -4,6 +4,7 @@ import { WebSocket } from 'ws'
export interface IWebSocketServerAdapter extends EventEmitter { export interface IWebSocketServerAdapter extends EventEmitter {
getConnectedClients(): number getConnectedClients(): number
getClients(): Set<WebSocket> getClients(): Set<WebSocket>
terminate(): Promise<void>
} }
export interface IWebServerAdapter extends EventEmitter { export interface IWebServerAdapter extends EventEmitter {

14
src/@types/runes.ts Normal file
View File

@ -0,0 +1,14 @@
export interface IAlternative {
test(values: Record<string, any>): string | undefined
encode(): string
}
export interface IRestriction {
test(values: Record<string, any>): string | undefined
encode(): string
}
export interface IRuneLike {
test(values: Record<string, unknown>): [boolean, string]
encode(): string
}

View File

@ -6,13 +6,13 @@ import { IAbortable, IMessageHandler } from '../@types/message-handlers'
import { IncomingMessage, OutgoingMessage } from '../@types/messages' import { IncomingMessage, OutgoingMessage } from '../@types/messages'
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters' import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter'
import { attemptValidation } from '../utils/validation' import { attemptValidation } from '../utils/validation'
import { createOutgoingEventMessage } from '../utils/messages' import { createOutgoingEventMessage } from '../utils/messages'
import { Event } from '../@types/event' import { Event } from '../@types/event'
import { Factory } from '../@types/base' import { Factory } from '../@types/base'
import { isEventMatchingFilter } from '../utils/event' import { isEventMatchingFilter } from '../utils/event'
import { messageSchema } from '../schemas/message-schema' import { messageSchema } from '../schemas/message-schema'
import { WebSocketAdapterEvent } from '../constants/adapter'
export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter { export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter {
private id: string private id: string
@ -39,11 +39,12 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
.on('pong', this.onClientPong.bind(this)) .on('pong', this.onClientPong.bind(this))
this this
.on('heartbeat', this.onHeartbeat.bind(this)) .on(WebSocketAdapterEvent.Heartbeat, this.onHeartbeat.bind(this))
.on('subscribe', this.onSubscribed.bind(this)) .on(WebSocketAdapterEvent.Subscribe, this.onSubscribed.bind(this))
.on('unsubscribe', this.onUnsubscribed.bind(this)) .on(WebSocketAdapterEvent.Unsubscribe, this.onUnsubscribed.bind(this))
.on(WebSocketAdapterEvent.Send, this.onSend.bind(this)) .on(WebSocketAdapterEvent.Event, this.onSendEvent.bind(this))
.on(WebSocketAdapterEvent.Broadcast, this.onBroadcast.bind(this)) .on(WebSocketAdapterEvent.Broadcast, this.onBroadcast.bind(this))
.on(WebSocketAdapterEvent.Message, this.onSendMessage.bind(this))
} }
public onUnsubscribed(subscriptionId: string): void { public onUnsubscribed(subscriptionId: string): void {
@ -55,10 +56,10 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
} }
public onBroadcast(event: Event): void { public onBroadcast(event: Event): void {
this.webSocketServer.emit('broadcast', event) this.webSocketServer.emit(WebSocketServerAdapterEvent.Broadcast, event)
} }
public onSend(event: Event): void { public onSendEvent(event: Event): void {
this.subscriptions.forEach((filters, subscriptionId) => { this.subscriptions.forEach((filters, subscriptionId) => {
if ( if (
Array.from(filters).map(isEventMatchingFilter).some((Matches) => Matches(event)) Array.from(filters).map(isEventMatchingFilter).some((Matches) => Matches(event))
@ -68,10 +69,14 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
}) })
} }
public sendMessage(message: OutgoingMessage): void { private sendMessage(message: OutgoingMessage): void {
this.client.send(JSON.stringify(message)) this.client.send(JSON.stringify(message))
} }
private onSendMessage(message: OutgoingMessage): void {
this.sendMessage(message)
}
public onHeartbeat(): void { public onHeartbeat(): void {
if (!this.alive) { if (!this.alive) {
this.terminate() this.terminate()

View File

@ -36,13 +36,23 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
this.heartbeatInterval = setInterval(this.onHeartbeat.bind(this), WSS_CLIENT_HEALTH_PROBE_INTERVAL) this.heartbeatInterval = setInterval(this.onHeartbeat.bind(this), WSS_CLIENT_HEALTH_PROBE_INTERVAL)
} }
public async terminate(): Promise<void> {
return void Promise.all(
[
...Array.from(this.webSocketServer.clients).map((webSocket: WebSocket) =>
webSocket.terminate()
),
],
)
}
private onBroadcast(event: Event) { private onBroadcast(event: Event) {
this.webSocketServer.clients.forEach((webSocket: WebSocket) => { this.webSocketServer.clients.forEach((webSocket: WebSocket) => {
if (!propEq('readyState', OPEN)(webSocket)) { if (!propEq('readyState', OPEN)(webSocket)) {
return return
} }
this.webSocketsAdapters.get(webSocket).emit(WebSocketAdapterEvent.Send, event) this.webSocketsAdapters.get(webSocket).emit(WebSocketAdapterEvent.Event, event)
}) })
} }
@ -62,14 +72,19 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
private onHeartbeat() { private onHeartbeat() {
console.debug(`heartbeat - ${this.getConnectedClients()} connected / ${this.webSocketServer.clients.size} total`) console.debug(`heartbeat - ${this.getConnectedClients()} connected / ${this.webSocketServer.clients.size} total`)
this.webSocketServer.clients.forEach((webSocket) => this.webSocketsAdapters.get(webSocket).emit('heartbeat')) this.webSocketServer.clients.forEach((webSocket) =>
this.webSocketsAdapters.get(webSocket).emit(WebSocketAdapterEvent.Heartbeat)
)
} }
protected onClose() { protected onClose() {
this.webSocketServer.clients.forEach((webSocket: WebSocket) =>
webSocket.terminate()
)
console.debug('websocket server closing') console.debug('websocket server closing')
clearInterval(this.heartbeatInterval) clearInterval(this.heartbeatInterval)
this.webSocketServer.removeAllListeners() this.webSocketServer.removeAllListeners()
super.onClose() super.onClose()
} }
} }

View File

@ -1,6 +1,10 @@
export enum WebSocketAdapterEvent { export enum WebSocketAdapterEvent {
Send = 'send', Event = 'event',
Broadcast = 'broadcast' Message = 'message',
Broadcast = 'broadcast',
Subscribe = 'subscribe',
Unsubscribe = 'unsubscribe',
Heartbeat = 'heartbeat'
} }
export enum WebSocketServerAdapterEvent { export enum WebSocketServerAdapterEvent {

View File

@ -10,6 +10,7 @@ import { Event } from '../@types/event'
import { IEventRepository } from '../@types/repositories' import { IEventRepository } from '../@types/repositories'
import { IWebSocketAdapter } from '../@types/adapters' import { IWebSocketAdapter } from '../@types/adapters'
import { SubscribeMessage } from '../@types/messages' import { SubscribeMessage } from '../@types/messages'
import { WebSocketAdapterEvent } from '../constants/adapter'
export class SubscribeMessageHandler implements IMessageHandler, IAbortable { export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
@ -30,10 +31,12 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
const subscriptionId = message[1] as SubscriptionId const subscriptionId = message[1] as SubscriptionId
const filters = message.slice(2) as SubscriptionFilter[] const filters = message.slice(2) as SubscriptionFilter[]
this.webSocket.emit('subscribe', subscriptionId, new Set(filters)) this.webSocket.emit(WebSocketAdapterEvent.Subscribe, subscriptionId, new Set(filters))
const sendEvent = (event: Event) => this.webSocket.sendMessage(createOutgoingEventMessage(subscriptionId, event)) const sendEvent = (event: Event) =>
const sendEOSE = () => this.webSocket.sendMessage(createEndOfStoredEventsNoticeMessage(subscriptionId)) this.webSocket.emit(WebSocketAdapterEvent.Message, createOutgoingEventMessage(subscriptionId, event))
const sendEOSE = () =>
this.webSocket.emit(WebSocketAdapterEvent.Message, createEndOfStoredEventsNoticeMessage(subscriptionId))
const findEvents = this.eventRepository.findByFilters(filters).stream() const findEvents = this.eventRepository.findByFilters(filters).stream()
try { try {

View File

@ -2,6 +2,7 @@ import { IWebSocketAdapter } from '../@types/adapters'
import { IMessageHandler } from '../@types/message-handlers' import { IMessageHandler } from '../@types/message-handlers'
import { UnsubscribeMessage } from '../@types/messages' import { UnsubscribeMessage } from '../@types/messages'
import { WebSocketAdapterEvent } from '../constants/adapter'
export class UnsubscribeMessageHandler implements IMessageHandler { export class UnsubscribeMessageHandler implements IMessageHandler {
@ -10,6 +11,6 @@ export class UnsubscribeMessageHandler implements IMessageHandler {
) { } ) { }
public async handleMessage(message: UnsubscribeMessage): Promise<void> { public async handleMessage(message: UnsubscribeMessage): Promise<void> {
this.webSocket.emit('unsubscribe', message[1]) this.webSocket.emit(WebSocketAdapterEvent.Unsubscribe, message[1])
} }
} }

View File

@ -1,4 +1,7 @@
import * as http from 'http' import cluster, { Worker } from 'cluster'
import { cpus } from 'os'
import http from 'http'
import process from 'process'
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws'
import { EventRepository } from './repositories/event-repository' import { EventRepository } from './repositories/event-repository'
@ -6,29 +9,109 @@ import { getDbClient } from './database/client'
import { webSocketAdapterFactory } from './factories/websocket-adapter-factory' import { webSocketAdapterFactory } from './factories/websocket-adapter-factory'
import { WebSocketServerAdapter } from './adapters/web-socket-server-adapter' import { WebSocketServerAdapter } from './adapters/web-socket-server-adapter'
const server = http.createServer()
const wss = new WebSocketServer({ server, maxPayload: 1024 * 1024 })
const dbClient = getDbClient() const dbClient = getDbClient()
const eventRepository = new EventRepository(dbClient) const eventRepository = new EventRepository(dbClient)
const adapter = new WebSocketServerAdapter( const numCpus = cpus().length
server,
wss,
webSocketAdapterFactory(eventRepository)
)
const port = Number(process.env.SERVER_PORT) || 8008 const port = Number(process.env.SERVER_PORT) || 8008
adapter.listen(port)
process.on('SIGINT', async function () {
console.log('\rCaught interrupt signal') const newWorker = (): Worker => {
wss.clients.forEach((client) => client.terminate()) let timeout
await new Promise((resolve, reject) => const worker = cluster.fork()
wss.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined)) worker
.on('listening', () => {
console.log(`worker ${worker.process.pid} listening`)
// worker.send('shutdown')
// worker.disconnect()
// timeout = setTimeout(() => {
// worker.kill()
// }, 5000)
})
.on('disconnect', () => {
console.log(`worker ${worker.process.pid} disconnect`)
clearTimeout(timeout)
})
.on('exit', (code, signal) => {
console.log(`worker ${worker.process.pid} died with code ${code} and signal ${signal}`)
})
return worker
}
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`)
for (let i = 0; i < numCpus; i++) {
newWorker()
}
cluster.on('exit', (deadWorker) => {
const worker = newWorker()
// Note the process IDs
const newPID = worker.process.pid
const oldPID = deadWorker.process.pid
// Log the event
console.log('worker ' + oldPID + ' died.')
console.log('worker ' + newPID + ' born.')
})
process.on('SIGINT', function () {
console.log('\rCaught interrupt signal')
//await Promise.all(apps.map((app) => app.terminate()))
// for (const id in cluster.workers) {
// apps.get(cluster.workers[id])
// }
// await new Promise((resolve, reject) =>
// wss.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined))
// )
// await new Promise((resolve, reject) =>
// server.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined))
// )
for (const id in cluster.workers) {
console.log('id', id)
console.log(`shutting down worker ${cluster.workers[id].process.pid}`)
cluster.workers[id].send('shutdown')
}
console.log('Disconnecting from db')
dbClient.destroy(() => {
console.log('Exiting')
process.exit()
})
})
} else if (cluster.isWorker) {
const server = http.createServer()
const wss = new WebSocketServer({ server, maxPayload: 1024 * 1024 })
const adapter = new WebSocketServerAdapter(
server,
wss,
webSocketAdapterFactory(eventRepository)
) )
await new Promise((resolve, reject) =>
server.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined)) adapter.listen(port)
)
dbClient.destroy() process.on('message', async (msg) => {
process.exit() console.log('worker received', msg)
}) if (msg === 'shutdown') {
console.log('disconnecting all clients')
wss.clients.forEach((client) => client.terminate())
wss.close()
// server.close()
// await new Promise((resolve, reject) =>
// wss.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined))
// )
// await new Promise((resolve, reject) =>
// server.close((error?: Error) => void (error instanceof Error) ? reject(error) : resolve(undefined))
// )
}
})
console.log(`Worker ${process.pid} started and listening on port ${port}`)
}

View File

@ -172,10 +172,6 @@ export const isEphemeralEvent = (event: Event): boolean => {
return event.kind >= 20000 && event.kind < 30000 return event.kind >= 20000 && event.kind < 30000
} }
export const isNullEvent = (event: Event): boolean => {
return event.kind === Number.MAX_SAFE_INTEGER
}
export const isDeleteEvent = (event: Event): boolean => { export const isDeleteEvent = (event: Event): boolean => {
return event.kind === EventKinds.DELETE return event.kind === EventKinds.DELETE
} }

View File

@ -1,3 +1,4 @@
import { IAlternative } from '../../@types/runes'
const punctuations = /[!"#\$%&'()*+-.\/:;<=>?@\[\\\]^`{|}~]/ const punctuations = /[!"#\$%&'()*+-.\/:;<=>?@\[\\\]^`{|}~]/
@ -5,7 +6,7 @@ const hasPunctuation = (input) => punctuations.test(input)
// Reference: https://github.com/rustyrussell/runes/blob/master/runes/runes.py // Reference: https://github.com/rustyrussell/runes/blob/master/runes/runes.py
export class Alternative { export class Alternative implements IAlternative {
public constructor( public constructor(
private readonly field: string, private readonly field: string,
private readonly cond: string, private readonly cond: string,
@ -78,7 +79,7 @@ export class Alternative {
return `${this.field}${this.cond}${this.value.replace(/[\\|&]/g, '\\$&')}` return `${this.field}${this.cond}${this.value.replace(/[\\|&]/g, '\\$&')}`
} }
public static decode(encodedStr: string): [Alternative, string] { public static decode(encodedStr: string): [IAlternative, string] {
let cond = undefined let cond = undefined
let endOff = 0 let endOff = 0
@ -118,7 +119,7 @@ export class Alternative {
return [new Alternative(field, cond, value), encodedStr.slice(endOff)] return [new Alternative(field, cond, value), encodedStr.slice(endOff)]
} }
public static from(encodedStr: string): Alternative { public static from(encodedStr: string): IAlternative {
const [field, cond, value] = encodedStr.replace(/\s+/g, '').split(new RegExp(`(${punctuations.source})`, 'g')) const [field, cond, value] = encodedStr.replace(/\s+/g, '').split(new RegExp(`(${punctuations.source})`, 'g'))
return new Alternative(field, cond, value) return new Alternative(field, cond, value)

View File

@ -1,9 +1,10 @@
import { IAlternative, IRestriction } from '../../@types/runes'
import { Alternative } from './alternative' import { Alternative } from './alternative'
export class Restriction { export class Restriction implements IRestriction {
public constructor( public constructor(
private readonly alternatives: Alternative[] private readonly alternatives: IAlternative[]
) { ) {
if (!alternatives.length) { if (!alternatives.length) {
throw new Error('Restriction must have some alternatives') throw new Error('Restriction must have some alternatives')
@ -27,10 +28,10 @@ export class Restriction {
return this.alternatives.map((alternative) => alternative.encode()).join('|') return this.alternatives.map((alternative) => alternative.encode()).join('|')
} }
public static decode(encodedStr: string): [Restriction, string] { public static decode(encodedStr: string): [IRestriction, string] {
let encStr = encodedStr let encStr = encodedStr
let alternative: Alternative let alternative: IAlternative
const alternatives: Alternative[] = [] const alternatives: IAlternative[] = []
while (encStr.length) { while (encStr.length) {
if (encStr.startsWith('&')) { if (encStr.startsWith('&')) {
encStr = encStr.slice(1) encStr = encStr.slice(1)

View File

@ -1,9 +1,10 @@
import { IRestriction, IRuneLike } from '../../@types/runes'
import { Restriction } from './restriction' import { Restriction } from './restriction'
export class RuneLike { export class RuneLike implements IRuneLike {
public constructor( public constructor(
private readonly restrictions: Restriction[] = [] private readonly restrictions: IRestriction[]
) { } ) { }
public test(values: Record<string, unknown>): [boolean, string] { public test(values: Record<string, unknown>): [boolean, string] {
@ -17,13 +18,13 @@ export class RuneLike {
return [true, ''] return [true, '']
} }
public encode() { public encode(): string {
return this.restrictions.map((restriction) => restriction.encode()).join('&') return this.restrictions.map((restriction) => restriction.encode()).join('&')
} }
public static from(encodedStr: string): RuneLike { public static from(encodedStr: string): IRuneLike {
const restrictions: Restriction[] = [] const restrictions: IRestriction[] = []
let restriction: Restriction let restriction: IRestriction
let encStr = encodedStr.replace(/\s+/g, '') let encStr = encodedStr.replace(/\s+/g, '')
while (encStr.length) { while (encStr.length) {