Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
}
```

Expand Down
4 changes: 2 additions & 2 deletions docs/Ocean Node.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)."
}
},
{
Expand Down
2 changes: 1 addition & 1 deletion docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<n>` 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 |

Expand Down
2 changes: 2 additions & 0 deletions src/@types/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion src/components/c2d/compute_engine_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServiceJob | null> {
return null
}
Expand Down
23 changes: 13 additions & 10 deletions src/components/c2d/compute_engine_docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServiceJob | null> {
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),
Expand All @@ -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<ServiceJob | null> {
const [job] = await this.db.getServiceJob(serviceId, owner)
if (!job) {
Expand All @@ -4019,13 +4016,19 @@ 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
) {
CORE_LOGGER.debug(
`stopService ${serviceId}: already "${job.statusText}" — nothing to tear down`
)
if (released) await this.db.updateServiceJob(job)
return job
}

Expand Down
7 changes: 6 additions & 1 deletion src/components/core/service/stopService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions src/components/httpRoutes/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the trailing commas from both changed TypeScript sites.

As per coding guidelines: TypeScript formatting requires no trailing commas.

  • src/components/httpRoutes/compute.ts#L426-L426: remove the comma after release: req.body.release.
  • src/components/core/service/stopService.ts#L65-L65: remove the comma after task.release === true.
📍 Affects 2 files
  • src/components/httpRoutes/compute.ts#L426-L426 (this comment)
  • src/components/core/service/stopService.ts#L65-L65
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/httpRoutes/compute.ts` at line 426, Remove the trailing commas
at both affected sites: after release: req.body.release in
src/components/httpRoutes/compute.ts lines 426-426 and after task.release ===
true in src/components/core/service/stopService.ts lines 65-65, preserving the
surrounding TypeScript syntax.

Source: Coding guidelines

authorization: req.headers?.authorization,
caller: req.caller
}
Expand Down
57 changes: 49 additions & 8 deletions src/test/unit/service/serviceNetworkCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -207,14 +217,7 @@ describe('service network cleanup (leaked ocean-svc-<id> 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)

Expand All @@ -225,4 +228,42 @@ describe('service network cleanup (leaked ocean-svc-<id> 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)
})
})
})
Loading