Skip to content
This repository was archived by the owner on Jun 2, 2025. It is now read-only.

add: Round service #48

Closed
wants to merge 26 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d29133e
Add round service
pyropy Apr 11, 2025
11eee5a
Separate round and tasking service
pyropy Apr 14, 2025
7e0a4e2
Remove tasking implementation
pyropy Apr 14, 2025
91fa889
Remove checker tasks table
pyropy Apr 14, 2025
bf300bf
Register round service
pyropy Apr 14, 2025
538a697
Add round and tasking configs
pyropy Apr 14, 2025
0617456
Fix config.js
pyropy Apr 14, 2025
fb6591a
Update lib/tasking-service.js
pyropy Apr 14, 2025
85b80e0
Fix config
pyropy Apr 14, 2025
c581ec7
Remove unused round property
pyropy Apr 14, 2025
348e3bb
Remove unused properties
pyropy Apr 14, 2025
639783f
Remove unused config
pyropy Apr 14, 2025
f1329c2
Fix setting expired round as current active round
pyropy Apr 14, 2025
2cac5a0
Use test helpers to create round
pyropy Apr 15, 2025
f073bc6
Update bin/simple-subnet-api.js
pyropy Apr 15, 2025
e9f6bfe
Refactor round service
pyropy Apr 15, 2025
c5d9c11
Merge branch 'add/round-service' of github.com:CheckerNetwork/simple-…
pyropy Apr 15, 2025
1a37ac2
Test round expiry on database level
pyropy Apr 15, 2025
5cfe64e
Update lib/round-service.js
pyropy Apr 15, 2025
5a3085d
Refactor round service to use database level time
pyropy Apr 15, 2025
4b15395
Merge branch 'add/round-service' of github.com:CheckerNetwork/simple-…
pyropy Apr 15, 2025
a93dd4c
Fix throwing error
pyropy Apr 15, 2025
3cf95d7
Rename checkRoundIntervalMs to checkRoundExpirationIntervalMs
pyropy Apr 15, 2025
1ff9a62
Use camel case
pyropy Apr 15, 2025
8a1fe17
Remove logs
pyropy Apr 15, 2025
a37eb0b
Refactor round service
pyropy Apr 15, 2025
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
33 changes: 32 additions & 1 deletion bin/simple-subnet-api.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
import '../lib/instrument.js'
import { createApp } from '../lib/app.js'
import { DATABASE_URL, HOST, PORT, REQUEST_LOGGING, poolConfig } from '../lib/config.js'
import { DATABASE_URL, HOST, PORT, REQUEST_LOGGING, poolConfig, roundServiceConfig, taskingServiceConfig } from '../lib/config.js'
import { TaskingService } from '../lib/tasking-service.js'
import { RoundService } from '../lib/round-service.js'
import { createPgPool } from '../lib/pool.js'

const pool = await createPgPool(DATABASE_URL)
const taskingService = new TaskingService(
pool,
taskingServiceConfig
)
const roundService = new RoundService(
pool,
taskingService,
roundServiceConfig
)

roundService.start().catch((error) => {
console.error('Failed to start round service:', error)
process.exit(1)
})

process.on('SIGINT', async () => {
console.log('Stopping round service...')
roundService.stop()
process.exit(0)
})

process.on('SIGTERM', async () => {
console.log('Stopping round service...')
roundService.stop()
process.exit(0)
})

const app = createApp({
databaseUrl: DATABASE_URL,
Expand Down
12 changes: 11 additions & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,14 @@ const poolConfig = {
maxLifetimeSeconds: 60
}

export { DATABASE_URL, PORT, HOST, REQUEST_LOGGING, poolConfig }
const roundServiceConfig = {
roundDurationMs: 20 * 60 * 1000, // 20 minutes
maxTasksPerNode: 10,
checkRoundIntervalMs: 60_000 // 1 minute
}

const taskingServiceConfig = {
maxTasks: 100
}

export { DATABASE_URL, PORT, HOST, REQUEST_LOGGING, poolConfig, roundServiceConfig, taskingServiceConfig }
178 changes: 178 additions & 0 deletions lib/round-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/** @typedef {{id: number; start_time: string; end_time: string; max_tasks_per_node: number; }} Round */
/** @typedef {{ roundDurationMs: number; maxTasksPerNode: number; checkRoundIntervalMs: number }} RoundConfig */

export class RoundService {
/**
* @type {Round | null}
*/
#currentRound = null
#isInitializing = false
/**
* @type {NodeJS.Timeout | null}
*/
#checkRoundIntervalId = null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that this might be necessary for tests, but not production usage

#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 () {
if (this.#isInitializing) return
this.#isInitializing = true

try {
await this.#initializeRound()
this.#scheduleRoundCheck()
console.log(`Round service started. Round duration: ${this.#config.roundDurationMs / 60000} minutes`)
} catch (error) {
console.error('Failed to start round service:', error)
} finally {
this.#isInitializing = false
}
}

/**
* Stop the round service
*/
stop () {
if (this.#checkRoundIntervalId) clearInterval(this.#checkRoundIntervalId)
console.log('Round service stopped')
}

/**
* Initialize the current round
*/
async #initializeRound () {
const activeRound = await this.#getActiveRound()

if (activeRound) {
this.#currentRound = activeRound
console.log(`Resuming active round #${activeRound.id}`)
} else {
await this.#startNewRound()
}
}

/**
* Schedule periodic checks for round end
*/
#scheduleRoundCheck () {
this.#checkRoundIntervalId = setInterval(async () => {
if (!this.#currentRound) return

const now = new Date()
if (new Date(this.#currentRound.end_time) <= now) {
try {
await this.#startNewRound()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What shall we do when this fails?

} catch (error) {
console.error('Error handling round end:', error)
}
}
}, this.#config.checkRoundIntervalMs)
}

/**
* Start a new round
*/
async #startNewRound () {
const previousRound = await this.#getActiveRound()
this.#currentRound = await this.#createNewRound()
if (!this.#currentRound) {
throw new Error('Failed to start a new round')
}

if (this.#taskingService) {
await this.#taskingService.generateTasksForRound(this.#currentRound.id)
}

if (previousRound) {
await this.#changeRoundActive(previousRound.id, false)
}

await this.#changeRoundActive(this.#currentRound.id, true)
}

/**
* Get the current active round from the database
*/
async #getActiveRound () {
try {
const { rows } = await this.#db.query(`
SELECT * FROM checker_rounds
WHERE active = true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of the active field? Can we simplify this to querying what is the last round where end_time > NOW()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Motivation behind the active field was to switch on round to active after the task creation. Unlike the implementation in the spark-api where tasks are created during the round creation on the database level, here we call TaskingService to create tasks for the round.

Thing that I'm questioning is should we or should we not leave the last round as active in case the TaskingService fails to sample / insert tasks for the new round?

Copy link
Member

Choose a reason for hiding this comment

The 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 active to has_had_tasks_defined? This way the purpose to me is more clear. Another way is to JOIN on the tasks for that round, and simply consider any round as inactive if it has no matching tasks. This assumes we don't want to have empty rounds.

ORDER BY start_time DESC
LIMIT 1
`)
return rows[0] || null
} catch (error) {
console.error('Error getting active round:', error)
return null
}
}

/**
* Create a new round
*/
async #createNewRound () {
try {
const now = new Date()
const endTime = new Date(now.getTime() + this.#config.roundDurationMs)

const { rows } = await this.#db.query(`
INSERT INTO checker_rounds (start_time, end_time, max_tasks_per_node, active)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [now, endTime, this.#config.maxTasksPerNode, true])

const round = rows[0]
console.log(`Created new round #${round.id} starting at ${round.start_time}`)
return round
} catch (error) {
console.error('Error creating new round:', error)
throw error
}
}

/**
* Change the status of a round using a transaction
* @param {number} roundId
* @param {Boolean} active
*/
async #changeRoundActive (roundId, active) {
const client = await this.#db.connect()

try {
await client.query('BEGIN')
const { rows } = await client.query(`
UPDATE checker_rounds
SET active = $1
WHERE id = $2
RETURNING *
`, [active, roundId])
await client.query('COMMIT')

console.log(`Round #${rows[0].id} active: ${rows[0].active}`)
return rows[0]
} catch (error) {
await client.query('ROLLBACK')
console.error('Error changing round status:', error)
throw error
} finally {
client.release()
}
}
}
35 changes: 35 additions & 0 deletions lib/tasking-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/** @typedef {any} Task */
/** @typedef {() => Promise<Task[]>} TaskSamplingFn */
/** @typedef {{ maxTasks: number }} TaskingConfig */

export class TaskingService {
#db
#config

/**
* @param {import('./typings.js').PgPool} db
* @param {TaskingConfig} config
*/
constructor (db, config) {
this.#db = db
this.#config = config
}

/**
* 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.')
}
}
7 changes: 7 additions & 0 deletions migrations/003.do.checker-rounds.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
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,
max_tasks_per_node INT NOT NULL DEFAULT 360
);
112 changes: 112 additions & 0 deletions test/round-service.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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'

const DEFAULT_CONFIG = {
roundDurationMs: 1000,
maxTasks: 100,
maxTasksPerNode: 10,
checkRoundIntervalMs: 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(pgPool, {
maxTasks: DEFAULT_CONFIG.maxTasks
})
})

after(async () => {
await pgPool.end()
})

beforeEach(async () => {
// Reset the database state before each test
await pgPool.query('DELETE FROM checker_rounds')
})

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 () => {
const now = new Date()
const endTime = new Date(now.getTime() + DEFAULT_CONFIG.roundDurationMs)
await pgPool.query(`
INSERT INTO checker_rounds (start_time, end_time, max_tasks_per_node, active)
VALUES ($1, $2, $3, $4)
`, [now, endTime, DEFAULT_CONFIG.maxTasksPerNode, 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)
assert.strictEqual(new Date(rounds[0].start_time).toISOString(), now.toISOString())
})

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.checkRoundIntervalMs + 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 () => {
const now = new Date()
const endTime = new Date(now.getTime() + 1000) // 1 second duration
await pgPool.query(`
INSERT INTO checker_rounds (start_time, end_time, max_tasks_per_node, active)
VALUES ($1, $2, $3, $4)
`, [now, endTime, DEFAULT_CONFIG.maxTasksPerNode, 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')
assert.strictEqual(activeRounds.length, 1)
assert.ok(new Date(activeRounds[0].start_time) > endTime)

const { rows: allRounds } = await pgPool.query('SELECT * FROM checker_rounds')
assert.strictEqual(allRounds.length, 2)
})
})
})