Skip to content

Commit 78fbc3b

Browse files
authored
services updates (#1422)
* Service lifecycle hardening
1 parent 7bcf7f0 commit 78fbc3b

26 files changed

Lines changed: 4972 additions & 1513 deletions

docs/API.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,39 @@ Array of `ServiceJob` (with `userData` stripped).
20152015

20162016
---
20172017

2018+
### `HTTP` GET /api/services/serviceList
2019+
2020+
### `P2P` command: serviceList
2021+
2022+
#### Description
2023+
2024+
Node-wide service listing. **Authenticated but NOT owner-scoped** — any authenticated
2025+
consumer identity sees every owner's services. By default it returns only the services
2026+
**currently holding a resource reservation** (exactly what the engines count against the
2027+
shared pools): `Running`/`Restarting`/`Stopping`, the mid-start pipeline states, paid
2028+
`Error` (container died, restartable), and explicitly `Stopped` within the paid window.
2029+
`Expired` and never-paid jobs hold nothing and are not listed by default.
2030+
2031+
#### Query Parameters
2032+
2033+
| name | type | required | description |
2034+
| ----------------- | ------- | -------- | ----------- |
2035+
| consumerAddress | string | v | caller identity (any consumer) |
2036+
| nonce | string | v | request nonce |
2037+
| signature | string | v | signed message (or use an `Authorization` auth-token header) |
2038+
| status | number | | filter to ONE specific `ServiceStatusNumber` (any status, incl. `75` Expired); takes precedence over `includeAllStatuses` |
2039+
| includeAllStatuses | boolean | | `true` returns services in every status instead of only the resource-holding set |
2040+
| fromTimestamp | string | | only services created at/after this moment — ISO date (`2026-01-15T00:00:00Z`) or Unix timestamp (seconds or milliseconds) |
2041+
2042+
#### Response (200)
2043+
2044+
Array of `ServiceJob`, **listing-sanitized**: `userData`, `dockerCmd`, `dockerEntrypoint`,
2045+
`dockerfile` and `additionalDockerFiles` are stripped (identity, status, resources,
2046+
endpoints and payment metadata are kept). Use the owner-scoped `serviceStatus` to see a
2047+
service's own configuration.
2048+
2049+
---
2050+
20182051
### `HTTP` POST /api/services/serviceExtend
20192052

20202053
### `P2P` command: serviceExtend

docs/Ocean Node.postman_collection.json

Lines changed: 2425 additions & 1205 deletions
Large diffs are not rendered by default.

docs/services.md

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ and `signature` as query parameters (or an auth-token `Authorization` header).
2828
| --- | --- | --- | --- |
2929
| `SERVICE_START` | `/api/services/serviceStart` | POST | Validate, persist a `Starting` record, and return the `serviceId` immediately (escrow + image + container happen in the background) |
3030
| `SERVICE_GET_STATUS` | `/api/services/serviceStatus` | GET | Read job status / endpoints — authenticated, owner-scoped (see notice below); poll this to follow a starting service |
31+
| `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) |
3132
| `SERVICE_EXTEND` | `/api/services/serviceExtend` | POST | Pay to push the expiry further out |
32-
| `SERVICE_RESTART` | `/api/services/serviceRestart` | POST | Recreate the container (no extra charge) |
33-
| `SERVICE_STOP` | `/api/services/serviceStop` | POST | Tear down the container and release resources |
33+
| `SERVICE_RESTART` | `/api/services/serviceRestart` | POST | Recreate the container (no extra charge); asynchronous like start — returns once the job is `Restarting`, poll `serviceStatus` |
34+
| `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 |
3435
| `SERVICE_GET_TEMPLATES` | `/api/services/serviceTemplates` | GET | List operator-published service templates |
3536
| `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 |
3637

@@ -42,8 +43,11 @@ terminal `*Failed` / `Error`).
4243

4344
**Handler (synchronous, before responding):** signature check → environment + access-list +
4445
`features.services` check → `userData` decrypt (validity check) → duration cap → resource
45-
resolution & availability → cost computed from **server-side** environment pricing → persist the
46-
job as `Starting` (which also reserves its resources) → respond `200` with the `serviceId`.
46+
resolution & availability → cost computed from **server-side** environment pricing → escrow
47+
funds pre-check (fail fast with `400 Insufficient escrow funds` when the consumer's available
48+
escrow visibly can't cover the cost; best-effort — an RPC hiccup skips it and the background
49+
Locking step remains the authoritative check) → persist the job as `Starting` (which also
50+
reserves its resources) → respond `200` with the `serviceId`.
4751

4852
**Background pipeline (per the start statuses below):**
4953
`Starting (10)`**locking** `Locking (20)`: escrow `createLock` (+ wait for it to mine) →
@@ -57,16 +61,38 @@ container creation fails before the claim, the lock is **cancelled (refunded)**
5761
in a `*Failed` / `Error` status. This is a change from the previous synchronous flow, which
5862
locked-then-claimed up front.
5963

64+
**Restart is asynchronous too.** `serviceRestart` performs only the fast validations
65+
(ownership, environment/access, not expired, payment not refunded), persists the job as
66+
`Restarting (45)` and responds immediately — the teardown, image re-pull/rebuild and new
67+
container happen in the background under the same per-service lock. Poll `serviceStatus`
68+
and watch `Restarting``PullImage`/`BuildImage``Running` (or `Error` with the failure
69+
reason in `statusText`). A service whose start payment was **never claimed** — the escrow
70+
lock failed outright (e.g. insufficient funds) or was refunded before being claimed —
71+
cannot be restarted: it was never paid for, so restarting it would run the service for
72+
free. Start a new service instead.
73+
74+
**The reservation lasts the whole paid window — only `Expired` releases it.** The consumer
75+
paid for the resources for a time interval and may use them as they please within it:
76+
running the service, stopping it, restarting it. An explicit `SERVICE_STOP` therefore tears
77+
down the container/network but **keeps** the resource amounts (cpu/ram/gpu) counted and the
78+
host ports reserved — another consumer cannot take them, and a restart resumes on the same
79+
endpoints. The reservation is tied to **payment**: an `Error`/`Stopped` job whose payment
80+
was never claimed (lock failed or refunded) does not reserve anything — otherwise anyone
81+
could squat a node's GPU for free by starting services against an empty escrow account.
82+
Once `expiresAt` passes, the expiry sweep tears down whatever is left, marks the
83+
job `Expired`, and only then releases everything. The sweep refuses to mark `Expired` while
84+
teardown fails (e.g. Docker unreachable) — the job stays `Error` and is retried every tick,
85+
so a resource release is never silently skipped.
86+
6087
**`Running` is monitored too.** The same background loop that advances a starting service also
6188
checks every `Running` service's container on each tick (~every few seconds). If the container
6289
exits on its own — crash, OOM, or the Docker daemon itself becoming unreachable — the job is
6390
moved to `Error` immediately instead of waiting for `expiresAt`. This health check does **not**
6491
release the service's reserved host ports/network/container record, since the consumer already
6592
paid for them; use `SERVICE_RESTART` to bring the service back on the same endpoints. `Error`
66-
counts as an active/resource-reserving status just like `Running` does — it still occupies its
67-
cpu/ram/gpu allocation and keeps its host ports held — until it is restarted, explicitly
68-
stopped, or swept by the same expiry check once `expiresAt` passes (which then fully releases
69-
everything, same as a normal expiry).
93+
counts as an active/resource-reserving status just like `Running` and `Stopped` do — it still
94+
occupies its cpu/ram/gpu allocation and keeps its host ports held — until it is restarted or
95+
swept by the expiry check once `expiresAt` passes (which then fully releases everything).
7096

7197
**Restart is self-healing with respect to leftover Docker state.** Each service gets a Docker
7298
network with the deterministic name `ocean-svc-<serviceId>`. Teardown (restart, stop, expiry
@@ -84,6 +110,24 @@ the network always reflects the current code's configuration instead of silently
84110
whatever options a previous node version created it with, and it matches restart's overall
85111
tear-down-and-rebuild semantics (the container is never reused either).
86112

113+
**Lifecycle operations are exclusive per service.** At most one lifecycle operation — the
114+
background start pipeline, `SERVICE_RESTART`, `SERVICE_STOP`, or the expiry sweep — runs per
115+
service at a time. A restart or stop issued while another operation is in flight (e.g. a
116+
restart still pulling the image) is rejected with
117+
`Service <id> has a start/stop/restart operation in progress — retry shortly`; simply retry
118+
once the in-flight operation settles. Without this exclusivity, the background loop's
119+
crash-orphan recovery could tear down the `ocean-svc-<serviceId>` network in the middle of a
120+
restart that had just created it, failing the restart with
121+
`network ocean-svc-<id> not found`. If a service expires while such an operation is in
122+
flight, the expiry sweep simply retries on a later tick.
123+
124+
Exclusivity holds **across node processes** too, not just within one: each operation also
125+
takes a lease row in the SQLite `service_locks` table, so two processes sharing the same
126+
`databases/` directory and Docker daemon (e.g. an old container still running during a
127+
redeploy) cannot run conflicting operations on the same service. Leases are heartbeated
128+
every 30 s while the operation runs; a lease not refreshed for 2 minutes belongs to a
129+
crashed process and is stolen automatically, so no manual cleanup is ever needed.
130+
87131
## Configuration
88132

89133
Service-on-demand is configured per Docker connection under `serviceOnDemand`:

src/@types/C2D/ServiceOnDemand.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export enum ServiceStatusNumber {
7878
Locking = 20, // escrow createLock in progress (funds locked, not yet claimed)
7979
Claiming = 30, // payment phase: claimLock on success, or cancelLock if the image step failed
8080
Running = 40,
81+
Restarting = 45, // SERVICE_RESTART accepted; teardown + re-pull/build + new container in progress
8182
Stopping = 50,
8283
Stopped = 70,
8384
Expired = 75,
@@ -95,12 +96,27 @@ export const ServiceStatusText: Record<ServiceStatusNumber, string> = {
9596
[ServiceStatusNumber.Locking]: 'Locking',
9697
[ServiceStatusNumber.Claiming]: 'Claiming',
9798
[ServiceStatusNumber.Running]: 'Running',
99+
[ServiceStatusNumber.Restarting]: 'Restarting',
98100
[ServiceStatusNumber.Stopping]: 'Stopping',
99101
[ServiceStatusNumber.Stopped]: 'Stopped',
100102
[ServiceStatusNumber.Expired]: 'Expired',
101103
[ServiceStatusNumber.Error]: 'Error'
102104
}
103105

106+
// Statuses of a service job that is mid-start/restart and owned by an exclusive
107+
// lifecycle operation. Single source of truth for getPendingServiceStarts (DB query) and
108+
// the pipeline's staleness guard — the two MUST agree or a job can be picked up and then
109+
// ignored (or vice versa). Restarting is included so a job orphaned by a crash
110+
// mid-restart is recovered at boot exactly like a crash mid-start.
111+
export const SERVICE_START_PENDING_STATUSES: readonly ServiceStatusNumber[] = [
112+
ServiceStatusNumber.Starting,
113+
ServiceStatusNumber.Locking,
114+
ServiceStatusNumber.PullImage,
115+
ServiceStatusNumber.BuildImage,
116+
ServiceStatusNumber.Claiming,
117+
ServiceStatusNumber.Restarting
118+
]
119+
104120
export interface ServiceJob {
105121
serviceId: string // unique id for a running service — distinct from a compute jobId
106122
clusterHash: string

src/@types/commands.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,24 @@ export interface ServiceGetStatusCommand extends Command {
450450
serviceId?: string
451451
}
452452

453+
// Node-wide service listing (SERVICE_LIST), shaped like GetJobsCommand. Authenticated
454+
// like every other service command (consumerAddress + signature/token), but NOT
455+
// owner-scoped: it returns every consumer's services. Default (no filters): only the
456+
// services currently holding a resource reservation.
457+
export interface GetServicesCommand extends Command {
458+
consumerAddress: string
459+
nonce: string
460+
signature: string
461+
// filter to ONE specific status (any ServiceStatusNumber, incl. Expired); takes
462+
// precedence over includeAllStatuses
463+
status?: number
464+
// return services in EVERY status instead of only the resource-holding set
465+
includeAllStatuses?: boolean
466+
// only services created at/after this moment: ISO date string, or a Unix timestamp
467+
// (seconds or milliseconds) as a string
468+
fromTimestamp?: string
469+
}
470+
453471
export interface ServiceRestartCommand extends Command {
454472
consumerAddress: string
455473
nonce: string

src/components/c2d/compute_engine_base.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,25 @@ export abstract class C2DEngine {
110110
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-await
111111
public async processServiceStart(job: ServiceJob): Promise<void> {}
112112

113+
// onlyIfExpired: expiry-sweep mode — re-validate expiresAt on the fresh row under the
114+
// lifecycle lock and skip the teardown when the service was extended in the meantime.
113115
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-await
114-
public async stopService(serviceId: string, owner: string): Promise<ServiceJob | null> {
116+
public async stopService(
117+
serviceId: string,
118+
owner: string,
119+
onlyIfExpired?: boolean
120+
): Promise<ServiceJob | null> {
115121
return null
116122
}
117123

124+
// Runs fn serialized with the engine's per-service lifecycle operations (start
125+
// pipeline, restart, stop, expiry sweep). Engines without a lock implementation run
126+
// fn directly; C2DEngineDocker overrides this with its lifecycle lock + DB lease.
127+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
128+
public async runExclusive<T>(serviceId: string, fn: () => Promise<T>): Promise<T> {
129+
return await fn()
130+
}
131+
118132
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-await
119133
public async restartService(
120134
serviceId: string,

0 commit comments

Comments
 (0)