Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,40 @@ npm install @platformatic/leader

## Usage

### Election only

```js
'use strict'

const pg = require('pg')
const createLeaderElector = require('@platformatic/leader')

const pool = new pg.Pool({
connectionString: 'postgres://localhost/mydb'
})

const leader = createLeaderElector({
pool,
lock: 4242,
onLeadershipChange: (isLeader) => {
if (isLeader) {
console.log('I am the leader — starting work')
} else {
console.log('No longer the leader — stopping work')
}
}
})

leader.start()

console.log(leader.isLeader()) // true or false

await leader.stop()
await pool.end()
```

### With notification channels

```js
'use strict'

Expand Down Expand Up @@ -65,7 +99,7 @@ Returns an object with `{ start, stop, notify, isLeader }`.
| `pool` | `pg.Pool` | Yes | - | A [node-postgres](https://node-postgres.com/) pool instance |
| `lock` | `number` | Yes | - | PostgreSQL advisory lock ID |
| `poll` | `number` | No | `10000` | Polling interval in milliseconds |
| `channels` | `Array` | Yes | - | Notification channel configurations |
| `channels` | `Array` | No | - | Notification channel configurations (omit for election-only mode) |
| `log` | `object` | No | `pino()` | Logger with `info`, `debug`, `warn`, `error` methods |
| `onLeadershipChange` | `function` | No | `null` | Callback invoked with `(isLeader: boolean)` when leadership status changes |

Expand Down
81 changes: 42 additions & 39 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,21 @@ function createLeaderElector (options) {

log.info('Acquiring advisory lock %d', lock)

if (!channels || !Array.isArray(channels) || channels.length === 0) {
throw new Error('channels array is required')
}

for (const ch of channels) {
if (!ch.channel) {
throw new Error('channel is required for each notification channel')
if (channels) {
if (!Array.isArray(channels)) {
throw new Error('channels must be an array')
}
if (!ch.onNotification) {
throw new Error('onNotification is required for each notification channel')
for (const ch of channels) {
if (!ch.channel) {
throw new Error('channel is required for each notification channel')
}
if (!ch.onNotification) {
throw new Error('onNotification is required for each notification channel')
}
}
}

const notificationChannels = channels
const notificationChannels = channels || []

let elected = false
const abortController = new AbortController()
Expand All @@ -63,37 +64,39 @@ function createLeaderElector (options) {
if (leader && !elected) {
log.info('This instance is the leader')
updateLeadershipStatus(true)
;(async () => {
for (const ch of notificationChannels) {
await client.query(`LISTEN "${ch.channel}"`)
log.info({ channel: ch.channel }, 'Listening to notification channel')
}

for await (const notification of on(client, 'notification', { signal: abortController.signal })) {
log.debug({ notification }, 'Received notification')
try {
const msg = notification[0]
const payload = JSON.parse(msg.payload)
const channelName = msg.channel

const channelConfig = notificationChannels.find(ch => ch.channel === channelName)
if (channelConfig) {
await channelConfig.onNotification(payload)
} else {
log.warn({ channel: channelName }, 'No handler found for notification channel')
}
} catch (err) {
log.warn({ err }, 'error while processing notification')
if (notificationChannels.length > 0) {
;(async () => {
for (const ch of notificationChannels) {
await client.query(`LISTEN "${ch.channel}"`)
log.info({ channel: ch.channel }, 'Listening to notification channel')
}
}
})()
.catch((err) => {
if (err.name !== 'AbortError') {
log.error({ err }, 'Error in notification')
} else {
abortController.abort()

for await (const notification of on(client, 'notification', { signal: abortController.signal })) {
log.debug({ notification }, 'Received notification')
try {
const msg = notification[0]
const payload = JSON.parse(msg.payload)
const channelName = msg.channel

const channelConfig = notificationChannels.find(ch => ch.channel === channelName)
if (channelConfig) {
await channelConfig.onNotification(payload)
} else {
log.warn({ channel: channelName }, 'No handler found for notification channel')
}
} catch (err) {
log.warn({ err }, 'error while processing notification')
}
}
})
})()
.catch((err) => {
if (err.name !== 'AbortError') {
log.error({ err }, 'Error in notification')
} else {
abortController.abort()
}
})
}
} else if (leader && elected) {
log.debug('This instance is still the leader')
} else if (!leader && elected) {
Expand Down
79 changes: 77 additions & 2 deletions test/leader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,10 @@ test('should throw error when required parameters are missing', async () => {
createLeaderElector({ pool: {} })
}, { message: 'lock is required' })

assert.throws(() => {
// channels is optional — no throw without it
assert.doesNotThrow(() => {
createLeaderElector({ pool: {}, lock: 123 })
}, { message: 'channels array is required' })
})
})

test('should trigger onLeadershipChange callback when leadership changes', async (t) => {
Expand Down Expand Up @@ -493,4 +494,78 @@ test('should throw error when channels array has invalid entries', async () => {
]
})
}, { message: 'channel is required for each notification channel' })

assert.throws(() => {
createLeaderElector({
pool: {},
lock: 123,
channels: 'not-an-array'
})
}, { message: 'channels must be an array' })
})

test('election-only mode: works without channels', async (t) => {
const pool = createPool()
const leadershipChanges = []

t.after(async () => {
await leader.stop()
await pool.end()
})

const leader = createLeaderElector({
pool,
lock: 70001,
poll: 200,
log: silentLogger,
onLeadershipChange: (isLeader) => {
leadershipChanges.push(isLeader)
}
})

leader.start()
await sleep(500)

assert.ok(leader.isLeader())
assert.deepStrictEqual(leadershipChanges, [true])
})

test('election-only mode: two instances, only one becomes leader', async (t) => {
const pool1 = createPool()
const pool2 = createPool()
const changes1 = []
const changes2 = []

t.after(async () => {
await leader1.stop()
await leader2.stop()
await pool1.end()
await pool2.end()
})

const leader1 = createLeaderElector({
pool: pool1,
lock: 70002,
poll: 200,
log: silentLogger,
onLeadershipChange: (isLeader) => { changes1.push(isLeader) }
})

const leader2 = createLeaderElector({
pool: pool2,
lock: 70002,
poll: 200,
log: silentLogger,
onLeadershipChange: (isLeader) => { changes2.push(isLeader) }
})

leader1.start()
await sleep(500)
leader2.start()
await sleep(500)

assert.ok(leader1.isLeader())
assert.ok(!leader2.isLeader())
assert.deepStrictEqual(changes1, [true])
assert.deepStrictEqual(changes2, [])
})
Loading
Loading