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..c5fdb3e4c 100644 --- a/src/components/core/service/getServices.ts +++ b/src/components/core/service/getServices.ts @@ -53,13 +53,23 @@ 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` ) } + if (command.updatedSince !== undefined) { + if (typeof command.updatedSince !== 'string') + return buildInvalidRequestMessage( + 'Parameter "updatedSince" is not a valid string' + ) + 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` + ) + } 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..0866c9b51 100644 --- a/src/test/unit/service/serviceHandlers.test.ts +++ b/src/test/unit/service/serviceHandlers.test.ts @@ -613,6 +613,71 @@ describe('Service handlers', () => { const out = await body(res) expect(out).to.deep.equal([]) }) + + it('400 on an updatedSince that is unparseable, empty, or overflows', async () => { + const { node } = buildFakes() + // 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 () => { + // 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 })