diff --git a/docs/API.md b/docs/API.md index 83804a3f5..bd7386756 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2173,7 +2173,9 @@ without `image`, or more than one of `tag`/`checksum`/`dockerfile` was provided. #### Description -Tear down the service container and network and release its resources. Owner-gated. +Tear down the service container and network. Owner-gated. The paid reservation is kept until +`expiresAt`; optional `release: true` ends the paid window now so the expiry sweep frees it +instead — no refund, no restart. #### Request Body @@ -2182,7 +2184,8 @@ Tear down the service container and network and release its resources. Owner-gat "consumerAddress": "0x...", "nonce": "123", "signature": "0x...", - "serviceId": "0x..." + "serviceId": "0x...", + "release": false } ``` diff --git a/docs/Ocean Node.postman_collection.json b/docs/Ocean Node.postman_collection.json index c2a91cf70..b9775715d 100644 --- a/docs/Ocean Node.postman_collection.json +++ b/docs/Ocean Node.postman_collection.json @@ -1098,7 +1098,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"consumerAddress\": \"{{consumerAddress}}\",\n \"nonce\": \"{{nonce}}\",\n \"signature\": \"{{signature}}\",\n \"serviceId\": \"{{serviceId}}\"\n}" + "raw": "{\n \"consumerAddress\": \"{{consumerAddress}}\",\n \"nonce\": \"{{nonce}}\",\n \"signature\": \"{{signature}}\",\n \"serviceId\": \"{{serviceId}}\",\n \"release\": false\n}" }, "url": { "raw": "{{baseUrl}}/api/services/serviceStop", @@ -1111,7 +1111,7 @@ "serviceStop" ] }, - "description": "Tear down the service container and network and release resources. Owner-gated." + "description": "Tear down the service container and network. Owner-gated. The paid reservation is kept until expiresAt; release=true frees it now (no refund, no restart)." } }, { diff --git a/docs/services.md b/docs/services.md index fb62b9c79..c29983188 100644 --- a/docs/services.md +++ b/docs/services.md @@ -31,7 +31,7 @@ and `signature` as query parameters (or an auth-token `Authorization` header). | `SERVICE_LIST` | `/api/services/serviceList` | GET | Node-wide service listing — authenticated, **not** owner-scoped. Default: only services currently holding a resource reservation; `status=` filters to one specific status, `includeAllStatuses=true` returns everything, `fromTimestamp` keeps services created at/after that moment. Output is listing-sanitized (no `userData`, no `dockerCmd`/`dockerEntrypoint`, no Dockerfile) | | `SERVICE_EXTEND` | `/api/services/serviceExtend` | POST | Pay to push the expiry further out | | `SERVICE_RESTART` | `/api/services/serviceRestart` | POST | Recreate the container (no extra charge); asynchronous like start — returns once the job is `Restarting`, poll `serviceStatus`. Optionally restart on a **new image spec** (bug-fix flow) — see below | -| `SERVICE_STOP` | `/api/services/serviceStop` | POST | Tear down the container; the paid resource reservation (cpu/ram/gpu + host ports) is kept until `expiresAt`, so the service can be restarted anytime on the same endpoints | +| `SERVICE_STOP` | `/api/services/serviceStop` | POST | Tear down the container; the paid resource reservation (cpu/ram/gpu + host ports) is kept until `expiresAt`, so the service can be restarted anytime on the same endpoints. `release: true` ends the paid window now and frees it instead (no refund, no restart) | | `SERVICE_GET_TEMPLATES` | `/api/services/serviceTemplates` | GET | List operator-published service templates | | `SERVICE_GET_STREAMABLE_LOGS` | `/api/services/serviceStreamableLogs` | GET | Stream the container's live stdout/stderr logs — authenticated, owner-scoped; available while `Running` or `Error`; optional `since` to skip history | diff --git a/src/@types/commands.ts b/src/@types/commands.ts index 4bc087c39..358cab0f0 100644 --- a/src/@types/commands.ts +++ b/src/@types/commands.ts @@ -430,6 +430,8 @@ export interface ServiceStopCommand extends Command { nonce: string signature: string serviceId: string + // give up the rest of the paid window so the reservation is freed (no refund, no restart) + release?: boolean } export interface ServiceGetStreamableLogsCommand extends Command { diff --git a/src/components/c2d/compute_engine_base.ts b/src/components/c2d/compute_engine_base.ts index d6cf534f4..977ceb126 100644 --- a/src/components/c2d/compute_engine_base.ts +++ b/src/components/c2d/compute_engine_base.ts @@ -112,11 +112,13 @@ export abstract class C2DEngine { // onlyIfExpired: expiry-sweep mode — re-validate expiresAt on the fresh row under the // lifecycle lock and skip the teardown when the service was extended in the meantime. + // release: end the paid window now so the expiry sweep frees the reservation. // eslint-disable-next-line @typescript-eslint/no-unused-vars, require-await public async stopService( serviceId: string, owner: string, - onlyIfExpired?: boolean + onlyIfExpired?: boolean, + release?: boolean ): Promise { return null } diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index 49e7d4a2e..0c250addb 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -3967,16 +3967,12 @@ export class C2DEngineDocker extends C2DEngine { public override async stopService( serviceId: string, owner: string, - onlyIfExpired: boolean = false + onlyIfExpired: boolean = false, + release: boolean = false ): Promise { - await this.acquireServiceLifecycleLock(serviceId) - // Tracked like loop-launched starts so engine stop() drains an in-flight stop too. - const op = this.doStopService(serviceId, owner, onlyIfExpired).finally(() => { - this.serviceOpPromises.delete(op) - return this.releaseServiceLifecycleLock(serviceId) - }) - this.serviceOpPromises.add(op) - return await op + return await this.runExclusive(serviceId, () => + this.doStopService(serviceId, owner, onlyIfExpired, release) + ) } // Runs fn under the per-service lifecycle lock (in-memory + cross-process DB lease), @@ -4001,7 +3997,8 @@ export class C2DEngineDocker extends C2DEngine { private async doStopService( serviceId: string, owner: string, - onlyIfExpired: boolean = false + onlyIfExpired: boolean = false, + release: boolean = false ): Promise { const [job] = await this.db.getServiceJob(serviceId, owner) if (!job) { @@ -4019,6 +4016,11 @@ export class C2DEngineDocker extends C2DEngine { ) return job } + // Early release: end the paid window so the expiry sweep frees the reservation + // (→ Expired, ports released, resources stop counting) on its next tick. + const released = release && job.status !== ServiceStatusNumber.Expired + if (released) job.expiresAt = Date.now() + if ( job.status === ServiceStatusNumber.Stopped || job.status === ServiceStatusNumber.Expired @@ -4026,6 +4028,7 @@ export class C2DEngineDocker extends C2DEngine { CORE_LOGGER.debug( `stopService ${serviceId}: already "${job.statusText}" — nothing to tear down` ) + if (released) await this.db.updateServiceJob(job) return job } diff --git a/src/components/core/service/stopService.ts b/src/components/core/service/stopService.ts index 52330ae1a..161423569 100644 --- a/src/components/core/service/stopService.ts +++ b/src/components/core/service/stopService.ts @@ -58,7 +58,12 @@ export class ServiceStopHandler extends CommandHandler { return { stream: null, status: { httpStatus: 401, error: 'Not the service owner' } } try { - const stopped = await engine.stopService(task.serviceId, task.consumerAddress) + const stopped = await engine.stopService( + task.serviceId, + task.consumerAddress, + false, // onlyIfExpired + task.release === true + ) return { stream: Readable.from(JSON.stringify([toPublicServiceJob(stopped)])), status: { httpStatus: 200 } diff --git a/src/components/httpRoutes/compute.ts b/src/components/httpRoutes/compute.ts index 3ca98d085..b22cd82b7 100644 --- a/src/components/httpRoutes/compute.ts +++ b/src/components/httpRoutes/compute.ts @@ -423,6 +423,7 @@ computeRoutes.post(`${SERVICES_API_BASE_PATH}/serviceStop`, async (req, res) => nonce: (req.body.nonce as string) || null, signature: (req.body.signature as string) || null, serviceId: (req.body.serviceId as string) || null, + release: req.body.release, authorization: req.headers?.authorization, caller: req.caller } diff --git a/src/test/unit/service/serviceNetworkCleanup.test.ts b/src/test/unit/service/serviceNetworkCleanup.test.ts index db1d59e95..e3e41d4b1 100644 --- a/src/test/unit/service/serviceNetworkCleanup.test.ts +++ b/src/test/unit/service/serviceNetworkCleanup.test.ts @@ -62,6 +62,16 @@ function fakeNetwork(opts: { } } +// acquireServiceLock is fail-closed: stopService rejects without the lifecycle lease. +function makeDb(job: ServiceJob): any { + return { + getServiceJob: sinon.stub().resolves([job]), + updateServiceJob: sinon.stub().resolves(1), + acquireServiceLock: sinon.stub().resolves(true), + releaseServiceLock: sinon.stub().resolves(undefined) + } +} + // Bypass the Docker-specific constructor while retaining the prototype chain (same // pattern as compute.test.ts) so private methods can be exercised with a stubbed docker. function makeEngine(): any { @@ -207,14 +217,7 @@ describe('service network cleanup (leaked ocean-svc- networks)', () => { describe('stopService() with a leaked network (empty networkId)', () => { it('removes the network by deterministic name and ends Stopped', async () => { const engine = makeEngine() - const job = makeJob({ containerId: '', networkId: '' }) - engine.db = { - getServiceJob: sinon.stub().resolves([job]), - updateServiceJob: sinon.stub().resolves(1), - // lifecycle lease (fail-closed: stopService rejects without it) - acquireServiceLock: sinon.stub().resolves(true), - releaseServiceLock: sinon.stub().resolves(undefined) - } + engine.db = makeDb(makeJob({ containerId: '', networkId: '' })) const network = fakeNetwork({}) engine.docker.getNetwork.returns(network) @@ -225,4 +228,42 @@ describe('service network cleanup (leaked ocean-svc- networks)', () => { expect(result.status).to.equal(ServiceStatusNumber.Stopped) }) }) + + // Ending the paid window is what makes the expiry sweep free the reservation. + describe('stopService(release)', () => { + function setup(job: ServiceJob) { + const engine = makeEngine() + engine.db = makeDb(job) + engine.docker.getContainer.returns({ + stop: sinon.stub().resolves(undefined), + remove: sinon.stub().resolves(undefined) + }) + engine.docker.getNetwork.returns(fakeNetwork({})) + return engine + } + + it('ends the paid window on a running service', async () => { + const engine = setup(makeJob()) + const result = await engine.stopService(SERVICE_ID, OWNER, false, true) + expect(result.status).to.equal(ServiceStatusNumber.Stopped) + expect(result.expiresAt).to.be.at.most(Date.now()) + }) + + it('ends the paid window on an already-stopped service', async () => { + const engine = setup( + makeJob({ status: ServiceStatusNumber.Stopped, statusText: 'Stopped' }) + ) + const result = await engine.stopService(SERVICE_ID, OWNER, false, true) + expect(result.expiresAt).to.be.at.most(Date.now()) + expect(engine.db.updateServiceJob.calledOnce).to.equal(true) + }) + + it('without release the reservation is kept until the original expiresAt', async () => { + const job = makeJob() + const originalExpiry = job.expiresAt + const engine = setup(job) + const result = await engine.stopService(SERVICE_ID, OWNER) + expect(result.expiresAt).to.equal(originalExpiry) + }) + }) })