-
Notifications
You must be signed in to change notification settings - Fork 3
add: Round service #48
Changes from all commits
d29133e
11eee5a
7e0a4e2
91fa889
bf300bf
538a697
0617456
fb6591a
85b80e0
c581ec7
348e3bb
639783f
f1329c2
2cac5a0
f073bc6
e9f6bfe
c5d9c11
1a37ac2
5cfe64e
5a3085d
4b15395
a93dd4c
3cf95d7
1ff9a62
8a1fe17
a37eb0b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
/** @typedef {{id: number; startTime: string; endTime: string; isExpired: Boolean; }} Round */ | ||
/** @typedef {{ roundDurationMs: number; checkRoundExpirationIntervalMs: number }} RoundConfig */ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can this be inferred from |
||
|
||
export class RoundService { | ||
/** | ||
* @type {NodeJS.Timeout | null} | ||
*/ | ||
checkRoundExpirationInterval = null | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this key not private as well? |
||
#db | ||
#config | ||
#taskingService | ||
|
||
/** | ||
* @param {import('./typings.js').PgPool} db | ||
* @param {import('./tasking-service.js').TaskingService} taskingService | ||
* @param {RoundConfig} config | ||
*/ | ||
constructor (db, taskingService, config) { | ||
this.#db = db | ||
this.#config = config | ||
this.#taskingService = taskingService | ||
} | ||
|
||
/** | ||
* Start the round service | ||
*/ | ||
async start () { | ||
try { | ||
await this.#initializeRound() | ||
this.#scheduleRoundExpirationCheck() | ||
console.log(`Round service started. Round duration: ${this.#config.roundDurationMs / 60000} minutes`) | ||
} catch (error) { | ||
console.error('Failed to start round service:', error) | ||
pyropy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
throw error | ||
} | ||
} | ||
|
||
/** | ||
* Stop the round service | ||
*/ | ||
stop () { | ||
if (this.checkRoundExpirationInterval) clearInterval(this.checkRoundExpirationInterval) | ||
console.log('Round service stopped') | ||
} | ||
|
||
/** | ||
* Initialize the current round | ||
*/ | ||
async #initializeRound () { | ||
const currentRound = await this.#getCurrentActiveRound() | ||
|
||
if (currentRound && !currentRound.isExpired) { | ||
console.log(`Resuming active round #${currentRound.id}`) | ||
} else { | ||
await this.#startNewRound() | ||
} | ||
} | ||
|
||
/** | ||
* Schedule periodic checks for round expiration | ||
*/ | ||
#scheduleRoundExpirationCheck () { | ||
this.checkRoundExpirationInterval = setInterval(async () => { | ||
try { | ||
const currentRound = await this.#getCurrentActiveRound() | ||
if (currentRound && !currentRound.isExpired) { | ||
return | ||
} | ||
|
||
await this.#startNewRound() | ||
Comment on lines
+66
to
+70
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code is repeated in |
||
} catch (error) { | ||
console.error('Error handling round end:', error) | ||
} | ||
}, this.#config.checkRoundExpirationIntervalMs) | ||
} | ||
|
||
/** | ||
* Start a new round | ||
*/ | ||
async #startNewRound () { | ||
const currentRound = await this.#getCurrentActiveRound() | ||
const newRound = await this.#createNewRound() | ||
|
||
await this.#taskingService.generateTasksForRound(newRound.id) | ||
if (currentRound) { | ||
await this.#changeRoundActive({ roundId: currentRound.id, active: false }) | ||
} | ||
|
||
await this.#changeRoundActive({ roundId: newRound.id, active: true }) | ||
} | ||
|
||
/** | ||
* Get the current active round from the database | ||
* @returns {Promise<Round | null>} | ||
*/ | ||
async #getCurrentActiveRound () { | ||
try { | ||
const { rows } = await this.#db.query(` | ||
SELECT | ||
cr.*, | ||
cr.end_time <= NOW() AS is_expired | ||
FROM checker_rounds cr | ||
WHERE active = true | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the purpose of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Motivation behind the Thing that I'm questioning is should we or should we not leave the last round as active in case the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Got it, you can only consider the round active once tasks have been sampled, but you can also only sample tasks once the round exists, right? Can we then rename |
||
ORDER BY start_time DESC | ||
LIMIT 1 | ||
`) | ||
|
||
if (!rows.length) { | ||
return null | ||
} | ||
|
||
const [round] = rows | ||
return { | ||
id: round.id, | ||
startTime: round.start_time, | ||
endTime: round.end_time, | ||
isExpired: round.is_expired | ||
} | ||
} catch (error) { | ||
console.error('Error getting active round:', error) | ||
pyropy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
throw error | ||
} | ||
} | ||
|
||
/** | ||
* Create a new round | ||
* | ||
* @returns {Promise<Round>} | ||
* @throws {Error} if the round creation fails | ||
*/ | ||
async #createNewRound () { | ||
try { | ||
const { rows } = await this.#db.query(` | ||
INSERT INTO checker_rounds (start_time, end_time, active) | ||
VALUES ( | ||
NOW(), | ||
NOW() + ($1 || ' milliseconds')::INTERVAL, | ||
$2 | ||
) | ||
RETURNING *, end_time <= NOW() AS is_expired | ||
`, [this.#config.roundDurationMs, false]) | ||
|
||
const [round] = rows | ||
console.log(`Created new round #${round.id} starting at ${round.startTime}`) | ||
return { | ||
id: round.id, | ||
startTime: round.start_time, | ||
endTime: round.end_time, | ||
isExpired: round.is_expired | ||
} | ||
} catch (error) { | ||
console.error('Error creating new round:', error) | ||
throw error | ||
} | ||
} | ||
|
||
/** | ||
* Change the status of a round using a transaction | ||
* @param {object} args | ||
* @param {number} args.roundId | ||
* @param {Boolean} args.active | ||
*/ | ||
async #changeRoundActive ({ roundId, active }) { | ||
const client = await this.#db.connect() | ||
|
||
try { | ||
await client.query('BEGIN') | ||
await client.query(` | ||
UPDATE checker_rounds | ||
SET active = $1 | ||
WHERE id = $2`, | ||
[active, roundId]) | ||
await client.query('COMMIT') | ||
|
||
console.log(`Round #${roundId} active: ${active}`) | ||
} catch (error) { | ||
await client.query('ROLLBACK') | ||
console.error('Error changing round status:', error) | ||
throw error | ||
} finally { | ||
client.release() | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
/** @typedef {any} Task */ | ||
/** @typedef {() => Promise<Task[]>} TaskSamplingFn */ | ||
|
||
export class TaskingService { | ||
/** | ||
* Register a task sampler for a specific subnet | ||
* @param {string} subnet - The subnet identifier | ||
* @param {TaskSamplingFn} sampleFn - Function that generates tasks for a subnet | ||
*/ | ||
registerTaskSampler (subnet, sampleFn) { | ||
console.warn('Registering task sampler is not implemented.') | ||
} | ||
|
||
/** | ||
* Generate tasks for all registered subnets for a specific round | ||
* @param {number} roundId | ||
*/ | ||
async generateTasksForRound (roundId) { | ||
// TODO: Implement the logic to generate tasks for all registered subnets | ||
console.warn('Tasking service is not implemented.') | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
CREATE TABLE IF NOT EXISTS checker_rounds ( | ||
id BIGSERIAL PRIMARY KEY, | ||
start_time TIMESTAMPTZ NOT NULL, | ||
end_time TIMESTAMPTZ NOT NULL, | ||
active BOOLEAN NOT NULL DEFAULT FALSE | ||
); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import assert from 'assert' | ||
import { after, before, beforeEach, describe, it } from 'node:test' | ||
import { createPgPool } from '../lib/pool.js' | ||
import { migrateWithPgClient } from '../lib/migrate.js' | ||
import { DATABASE_URL } from '../lib/config.js' | ||
import { RoundService } from '../lib/round-service.js' | ||
import { TaskingService } from '../lib/tasking-service.js' | ||
import { withRound } from './test-helpers.js' | ||
|
||
const DEFAULT_CONFIG = { | ||
roundDurationMs: 1000, | ||
checkRoundExpirationIntervalMs: 200 | ||
} | ||
|
||
describe('RoundService', () => { | ||
/** @type {import('pg').Pool} */ | ||
let pgPool | ||
/** @type {TaskingService} */ | ||
let taskingService | ||
|
||
before(async () => { | ||
pgPool = await createPgPool(DATABASE_URL) | ||
await migrateWithPgClient(pgPool) | ||
taskingService = new TaskingService() | ||
}) | ||
|
||
after(async () => { | ||
await pgPool.end() | ||
}) | ||
|
||
beforeEach(async () => { | ||
// Reset the database state before each test | ||
await pgPool.query('DELETE FROM checker_rounds') | ||
await pgPool.query('ALTER SEQUENCE checker_rounds_id_seq RESTART WITH 1') | ||
}) | ||
|
||
describe('rounds', () => { | ||
it('should create a new round if no active round exists', async () => { | ||
const roundService = new RoundService(pgPool, taskingService, DEFAULT_CONFIG) | ||
|
||
await roundService.start() | ||
roundService.stop() | ||
|
||
const { rows: rounds } = await pgPool.query('SELECT * FROM checker_rounds WHERE active = true') | ||
assert.strictEqual(rounds.length, 1) | ||
assert.ok(new Date(rounds[0].end_time) > new Date()) | ||
}) | ||
|
||
it('should resume an active round if one exists', async () => { | ||
await withRound({ | ||
pgPool, | ||
roundDurationMs: DEFAULT_CONFIG.roundDurationMs, | ||
active: true | ||
}) | ||
|
||
const roundService = new RoundService(pgPool, taskingService, DEFAULT_CONFIG) | ||
|
||
await roundService.start() | ||
roundService.stop() | ||
|
||
const { rows: rounds } = await pgPool.query('SELECT * FROM checker_rounds WHERE active = true') | ||
assert.strictEqual(rounds.length, 1) | ||
}) | ||
|
||
it('should stop the round service and prevent further round checks', async () => { | ||
const roundService = new RoundService(pgPool, taskingService, DEFAULT_CONFIG) | ||
|
||
await roundService.start() | ||
roundService.stop() | ||
|
||
const { rows: rounds } = await pgPool.query('SELECT * FROM checker_rounds WHERE active = true') | ||
assert.strictEqual(rounds.length, 1) | ||
|
||
// Wait for the check interval to pass and ensure no new rounds are created | ||
await new Promise(resolve => setTimeout(resolve, DEFAULT_CONFIG.checkRoundExpirationIntervalMs + 1000)) | ||
|
||
const { rows: newRounds } = await pgPool.query('SELECT * FROM checker_rounds') | ||
assert.strictEqual(newRounds.length, 1) | ||
}) | ||
}) | ||
|
||
describe('round transitions', () => { | ||
it('should deactivate the old round and create a new one when the current round ends', async () => { | ||
await withRound({ | ||
pgPool, | ||
roundDurationMs: 1000, // 1 second duration | ||
active: true | ||
}) | ||
|
||
const roundService = new RoundService(pgPool, taskingService, DEFAULT_CONFIG) | ||
|
||
await roundService.start() | ||
// Wait for the current round to end | ||
await new Promise(resolve => setTimeout(resolve, 2000)) | ||
|
||
roundService.stop() | ||
|
||
const { rows: activeRounds } = await pgPool.query('SELECT * FROM checker_rounds WHERE active = true') | ||
const { rows: allRounds } = await pgPool.query('SELECT * FROM checker_rounds') | ||
assert.strictEqual(activeRounds.length, 1) | ||
assert.strictEqual(allRounds.length, 2) | ||
}) | ||
}) | ||
}) |
Uh oh!
There was an error while loading. Please reload this page.