From ea9e14937613671f62c52c32935e3d49f0f324fd Mon Sep 17 00:00:00 2001 From: giurgiur99 Date: Thu, 23 Jul 2026 09:40:17 +0300 Subject: [PATCH 1/3] add updatedAt --- src/@types/C2D/ServiceOnDemand.ts | 1 + src/@types/commands.ts | 1 + src/components/core/service/getServices.ts | 33 ++++++++--- src/components/database/sqliteCompute.ts | 4 +- src/test/unit/service/serviceHandlers.test.ts | 58 +++++++++++++++++++ .../unit/service/serviceJobsDatabase.test.ts | 16 +++++ 6 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/@types/C2D/ServiceOnDemand.ts b/src/@types/C2D/ServiceOnDemand.ts index f692862b1..f859530b5 100644 --- a/src/@types/C2D/ServiceOnDemand.ts +++ b/src/@types/C2D/ServiceOnDemand.ts @@ -135,6 +135,7 @@ export interface ServiceJob { status: ServiceStatusNumber statusText: string dateCreated: string // ISO timestamp + updatedAt?: number expiresAt: number // Unix ms timestamp duration: number // requested seconds exposedPorts: number[] diff --git a/src/@types/commands.ts b/src/@types/commands.ts index 9f87a7e3f..21ae04436 100644 --- a/src/@types/commands.ts +++ b/src/@types/commands.ts @@ -466,6 +466,7 @@ export interface GetServicesCommand extends Command { // only services created at/after this moment: ISO date string, or a Unix timestamp // (seconds or milliseconds) as a string fromTimestamp?: string + updatedSince?: string } export interface ServiceRestartCommand extends Command { diff --git a/src/components/core/service/getServices.ts b/src/components/core/service/getServices.ts index e2c762afa..5aebdf8ca 100644 --- a/src/components/core/service/getServices.ts +++ b/src/components/core/service/getServices.ts @@ -60,6 +60,16 @@ export class GetServicesHandler extends CommandHandler { `Parameter "fromTimestamp" is not a valid date: "${command.fromTimestamp}" — use an ISO date or a Unix timestamp` ) } + if (command.updatedSince !== undefined) { + if (typeof command.updatedSince !== 'string') + return buildInvalidRequestMessage( + 'Parameter : "updatedSince" is not a valid string' + ) + if (parseFromTimestamp(command.updatedSince) === null) + return buildInvalidRequestMessage( + `Parameter "updatedSince" is not a valid date: "${command.updatedSince}" — use an ISO date or a Unix timestamp` + ) + } return validation } @@ -86,10 +96,11 @@ export class GetServicesHandler extends CommandHandler { const jobs: ServiceJob[] = [] for (const eng of engines.getAllEngines()) { const { hash } = eng.getC2DConfig() - if (task.status !== undefined || task.includeAllStatuses) { - // status-explicit listing: read ALL of this cluster's jobs (getServiceJob has no - // cluster column filter, so scope by clusterHash here), then narrow to the one - // status when given — `status` takes precedence over `includeAllStatuses` + if ( + task.updatedSince !== undefined || + task.status !== undefined || + task.includeAllStatuses + ) { const all = (await eng.db.getServiceJob()).filter( (j: ServiceJob) => j.clusterHash === hash ) @@ -105,10 +116,16 @@ export class GetServicesHandler extends CommandHandler { } const fromMs = parseFromTimestamp(task.fromTimestamp) - const filtered = - fromMs === undefined || fromMs === null - ? jobs - : jobs.filter((j) => Date.parse(j.dateCreated) >= fromMs) + const updatedSinceMs = parseFromTimestamp(task.updatedSince) + const filtered = jobs.filter( + (j) => + (fromMs === undefined || fromMs === null + ? true + : Date.parse(j.dateCreated) >= fromMs) && + (updatedSinceMs === undefined || updatedSinceMs === null + ? true + : (j.updatedAt ?? 0) >= updatedSinceMs) + ) return { stream: Readable.from(JSON.stringify(filtered.map(toListedServiceJob))), diff --git a/src/components/database/sqliteCompute.ts b/src/components/database/sqliteCompute.ts index e3c4e775d..7511350b5 100644 --- a/src/components/database/sqliteCompute.ts +++ b/src/components/database/sqliteCompute.ts @@ -280,7 +280,7 @@ export class SQLiteCompute implements ComputeDatabaseProvider { job.status, job.expiresAt, job.dateCreated, - Buffer.from(JSON.stringify(job)) + Buffer.from(JSON.stringify({ ...job, updatedAt: Date.now() })) ]) } catch (err) { DATABASE_LOGGER.error('Could not insert service job on DB: ' + err.message) @@ -301,7 +301,7 @@ export class SQLiteCompute implements ComputeDatabaseProvider { job.clusterHash, job.status, job.expiresAt, - Buffer.from(JSON.stringify(job)), + Buffer.from(JSON.stringify({ ...job, updatedAt: Date.now() })), job.serviceId ]) return changes diff --git a/src/test/unit/service/serviceHandlers.test.ts b/src/test/unit/service/serviceHandlers.test.ts index 2a4f8812e..ba5c15271 100644 --- a/src/test/unit/service/serviceHandlers.test.ts +++ b/src/test/unit/service/serviceHandlers.test.ts @@ -613,6 +613,64 @@ describe('Service handlers', () => { const out = await body(res) expect(out).to.deep.equal([]) }) + + it('400 on an unparseable updatedSince', async () => { + const { node } = buildFakes() + const res = await new GetServicesHandler(node).handle({ + ...baseTask, + updatedSince: 'not-a-date' + } as any) + expect(res.status.httpStatus).to.equal(400) + }) + + it('updatedSince: returns every status changed at/after the cursor, from the all-jobs read', async () => { + // realistic epoch-ms so parseFromTimestamp treats the values as ms, not seconds + const base = 1_700_000_000_000 + const older = makeJob({ + serviceId: 'svc-old', + status: ServiceStatusNumber.Running, + updatedAt: base + }) + const failed = makeJob({ + serviceId: 'svc-fail', + status: ServiceStatusNumber.PullImageFailed, + updatedAt: base + 5000 + }) + const expired = makeJob({ + serviceId: 'svc-exp', + status: ServiceStatusNumber.Expired, + updatedAt: base + 6000 + }) + const { node, engine } = buildFakes() + engine.db.getServiceJob.resolves([older, failed, expired]) + + const res = await new GetServicesHandler(node).handle({ + ...baseTask, + updatedSince: String(base + 3000) + } as any) + expect(res.status.httpStatus).to.equal(200) + // incremental read uses the all-jobs path, not the resource-holding query + expect(engine.db.getServiceJob.called).to.equal(true) + expect(engine.db.getRunningServiceJobs.called).to.equal(false) + + const out = await body(res) + const ids = out.map((s: any) => s.serviceId) + // older is before the cursor → excluded; both changed-since are returned, + // proving all statuses (incl. the resource-set-hidden PullImageFailed/Expired) pass + expect(ids).to.have.members(['svc-fail', 'svc-exp']) + expect(ids).to.not.include('svc-old') + }) + + it('updatedSince: a record with no updatedAt is treated as 0 and excluded once the cursor advances', async () => { + const legacy = makeJob({ serviceId: 'svc-legacy', updatedAt: undefined }) + const { node, engine } = buildFakes() + engine.db.getServiceJob.resolves([legacy]) + const res = await new GetServicesHandler(node).handle({ + ...baseTask, + updatedSince: String(1_700_000_000_000) + } as any) + expect(await body(res)).to.deep.equal([]) + }) }) describe('ServiceStartHandler', () => { diff --git a/src/test/unit/service/serviceJobsDatabase.test.ts b/src/test/unit/service/serviceJobsDatabase.test.ts index ba71738d5..2eaf55eab 100644 --- a/src/test/unit/service/serviceJobsDatabase.test.ts +++ b/src/test/unit/service/serviceJobsDatabase.test.ts @@ -225,6 +225,22 @@ describe('Service Jobs Database', () => { expect(found.expiresAt).to.equal(123456) }) + it('stamps updatedAt on insert and bumps it on every status change', async () => { + const before = Date.now() + const job = makeServiceJob() + await db.newServiceJob(job) + const [inserted] = await db.getServiceJob(job.serviceId) + expect(inserted.updatedAt).to.be.a('number') + expect(inserted.updatedAt).to.be.at.least(before) + + // a later transition must advance updatedAt — this is the incremental-sync cursor + await new Promise((resolve) => setTimeout(resolve, 5)) + job.status = ServiceStatusNumber.Stopped + await db.updateServiceJob(job) + const [updated] = await db.getServiceJob(job.serviceId) + expect(updated.updatedAt).to.be.greaterThan(inserted.updatedAt) + }) + it('getRunningServiceJobs returns only active statuses for the cluster', async () => { const running = makeServiceJob({ status: ServiceStatusNumber.Running }) const starting = makeServiceJob({ status: ServiceStatusNumber.Starting }) From 1cf68578988d88913f381b5d106964a8e116d09e Mon Sep 17 00:00:00 2001 From: giurgiur99 Date: Thu, 23 Jul 2026 10:06:17 +0300 Subject: [PATCH 2/3] fix review comments --- src/components/core/service/getServices.ts | 8 ++++---- src/test/unit/service/serviceHandlers.test.ts | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/core/service/getServices.ts b/src/components/core/service/getServices.ts index 5aebdf8ca..c5fdb3e4c 100644 --- a/src/components/core/service/getServices.ts +++ b/src/components/core/service/getServices.ts @@ -53,9 +53,9 @@ export class GetServicesHandler extends CommandHandler { if (command.fromTimestamp !== undefined) { if (typeof command.fromTimestamp !== 'string') return buildInvalidRequestMessage( - 'Parameter : "fromTimestamp" is not a valid string' + 'Parameter "fromTimestamp" is not a valid string' ) - if (parseFromTimestamp(command.fromTimestamp) === null) + if (!Number.isFinite(parseFromTimestamp(command.fromTimestamp))) return buildInvalidRequestMessage( `Parameter "fromTimestamp" is not a valid date: "${command.fromTimestamp}" — use an ISO date or a Unix timestamp` ) @@ -63,9 +63,9 @@ export class GetServicesHandler extends CommandHandler { if (command.updatedSince !== undefined) { if (typeof command.updatedSince !== 'string') return buildInvalidRequestMessage( - 'Parameter : "updatedSince" is not a valid string' + 'Parameter "updatedSince" is not a valid string' ) - if (parseFromTimestamp(command.updatedSince) === null) + if (!Number.isFinite(parseFromTimestamp(command.updatedSince))) return buildInvalidRequestMessage( `Parameter "updatedSince" is not a valid date: "${command.updatedSince}" — use an ISO date or a Unix timestamp` ) diff --git a/src/test/unit/service/serviceHandlers.test.ts b/src/test/unit/service/serviceHandlers.test.ts index ba5c15271..71f956888 100644 --- a/src/test/unit/service/serviceHandlers.test.ts +++ b/src/test/unit/service/serviceHandlers.test.ts @@ -614,13 +614,19 @@ describe('Service handlers', () => { expect(out).to.deep.equal([]) }) - it('400 on an unparseable updatedSince', async () => { + it('400 on an updatedSince that is unparseable, empty, or overflows', async () => { const { node } = buildFakes() - const res = await new GetServicesHandler(node).handle({ - ...baseTask, - updatedSince: 'not-a-date' - } as any) - expect(res.status.httpStatus).to.equal(400) + // an explicit empty string and an overflowing digit-only value (→ Infinity) must + // be rejected, not fall through to an unfiltered all-statuses dump + for (const updatedSince of ['not-a-date', '', '9'.repeat(400)]) { + const res = await new GetServicesHandler(node).handle({ + ...baseTask, + updatedSince + } as any) + expect(res.status.httpStatus, `updatedSince=${JSON.stringify(updatedSince)}`).to.equal( + 400 + ) + } }) it('updatedSince: returns every status changed at/after the cursor, from the all-jobs read', async () => { From 38357366b0f503d514a5f3c8758e4e8864585ef9 Mon Sep 17 00:00:00 2001 From: giurgiur99 Date: Thu, 23 Jul 2026 10:10:46 +0300 Subject: [PATCH 3/3] lint --- src/test/unit/service/serviceHandlers.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/unit/service/serviceHandlers.test.ts b/src/test/unit/service/serviceHandlers.test.ts index 71f956888..0866c9b51 100644 --- a/src/test/unit/service/serviceHandlers.test.ts +++ b/src/test/unit/service/serviceHandlers.test.ts @@ -623,9 +623,10 @@ describe('Service handlers', () => { ...baseTask, updatedSince } as any) - expect(res.status.httpStatus, `updatedSince=${JSON.stringify(updatedSince)}`).to.equal( - 400 - ) + expect( + res.status.httpStatus, + `updatedSince=${JSON.stringify(updatedSince)}` + ).to.equal(400) } })