Skip to content
Draft
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
323 changes: 323 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Environmental variables are also tracked in `ENVIRONMENT_VARIABLES` within `src/
- `VALIDATE_UNSIGNED_DDO`: If set to `false`, the node will not validate unsigned DDOs and will request a signed message with the publisher address, nonce and signature. Default is `true`. Example: `false`
- `JWT_SECRET`: Secret used to sign JWT tokens. Default is `ocean-node-secret`. Example: `"my-secret-jwt-token"`
- `PERSISTENT_STORAGE`: Persistent storage config. See [persistent storage](persistentStorage.md).
- `SERVICE_BUCKET_QUOTA_BYTES`: Quota, in bytes, of the output bucket `serviceStart` creates when the request has no `outputBucketId`. The quota is stored on the bucket when it is created, so a change only applies to new buckets. Must be an integer `>= 1`. Default is `5368709120` (5 GB). Example: `10737418240`
- `SERVICE_BUCKET_RETENTION_SECONDS`: How long, in seconds, such a bucket is kept after its service's paid window ends. It is applied when the bucket is created and again whenever `serviceExtend` or another service start pushes the date out. Must be an integer `>= 0`. Default is `604800` (1 week). Example: `259200`

## Database

Expand Down
19 changes: 19 additions & 0 deletions docs/persistentStorage.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,25 @@ Because results are regular bucket files, they can feed the next compute job wit

---

## Service default buckets

When `serviceStart` has no `outputBucketId`, a `localfs` node creates a bucket for the service
(see [Results bucket](services.md#results-bucket)). These buckets differ from ones made with
`createBucket` in three ways, all shown by `getBuckets`:

- `serviceId` — the service the bucket was created for.
- `quotaBytes` — `SERVICE_BUCKET_QUOTA_BYTES` at creation time, 5 GB by default. Uploads that
would go over it fail. The quota is soft: the service using the bucket is never stopped, and
its container's own writes are not capped.
- `expiresAt` — unix seconds, `SERVICE_BUCKET_RETENTION_SECONDS` (one week by default) after the
service's paid window ends (pushed out by
`serviceExtend` or by another service starting into the bucket). An hourly sweep then deletes
the bucket and its files.

Buckets created with `createBucket` have `null` for all three: no quota, never deleted.

---

## Limitations and notes

- The bucket registry is local to the node (SQLite file). If you run multiple nodes, each node’s registry is independent unless you externalize/replicate it.
Expand Down
36 changes: 36 additions & 0 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,42 @@ redeploy) cannot run conflicting operations on the same service. Leases are hear
every 30 s while the operation runs; a lease not refreshed for 2 minutes belongs to a
crashed process and is stolen automatically, so no manual cleanup is ever needed.

## Results bucket

A service writes durable results to `/data/outputs`, which is bind-mounted from a
[persistent storage](persistentStorage.md) bucket:

- **Your own bucket.** Pass `outputBucketId` in `serviceStart`. You must own the bucket or be on
its access list (`403` otherwise).
- **A default bucket.** Without `outputBucketId`, and when the node's persistent storage is
`localfs`, the node creates a bucket for the service: owned by the consumer, no access list,
a quota of `SERVICE_BUCKET_QUOTA_BYTES` (**5 GB** by default). Its id is returned as `outputBucketId` in the `serviceStart` response (and in
`serviceStatus`). It is created only after every other check has passed, so a refused start
leaves no bucket behind, and there is at most one per `serviceId`. Restarts keep the same
bucket. To relaunch into it — e.g. after editing a service — pass that id as `outputBucketId`
to the new `serviceStart`.
- If persistent storage is disabled (or not `localfs`), the service runs without a bucket, as
before, and nothing it writes outlives the container.

**Quota.** The quota is soft and never stops a service. Once the bucket is full, uploads through
the persistent storage API that would go over the quota are rejected. The service keeps
running, and starts and restarts into the bucket are allowed. `serviceStatus` reports the fill
level of any bucket that has a quota:

```json
"outputBucketUsage": { "quotaBytes": 5368709120, "usedBytes": 5368709120, "full": true }
```

The reading can be up to 30 s old. Uploads and deletes through the storage API refresh it at
once. To make room, delete files from the bucket.
A bind mount can't be size-capped, so writes the service container makes to `/data/outputs`
are **not** blocked and can take the bucket past its quota.

**Expiry.** A default bucket is deleted, contents included, `SERVICE_BUCKET_RETENTION_SECONDS`
after its service's paid window ends (**one week** by default: `expiresAt` + 7 days). `serviceExtend` pushes that date out, and so does starting
another service into the bucket. An hourly sweep deletes buckets past their date. Buckets you
created with `createBucket` never expire and have no quota.

## Configuration

Service-on-demand is configured per Docker connection under `serviceOnDemand`:
Expand Down
6 changes: 6 additions & 0 deletions src/@types/C2D/ServiceOnDemand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ export const SERVICE_START_PENDING_STATUSES: readonly ServiceStatusNumber[] = [
ServiceStatusNumber.Restarting
]

export interface ServiceOutputBucketUsage {
quotaBytes: number
usedBytes: number
full: boolean // usedBytes >= quotaBytes
}

export interface ServiceJob {
serviceId: string // unique id for a running service — distinct from a compute jobId
clusterHash: string
Expand Down
3 changes: 3 additions & 0 deletions src/@types/OceanNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ export interface OceanNodeConfig {
httpKeyPath?: string
enableBenchmark?: boolean
persistentStorage?: PersistentStorageConfig
// default SERVICE_START output bucket: quota (bytes) and retention past expiresAt (seconds)
serviceBucketQuotaBytes?: number
serviceBucketRetentionSeconds?: number
}

export interface P2PStatusResponse {
Expand Down
13 changes: 13 additions & 0 deletions src/components/core/service/extendService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,19 @@ export class ServiceExtendHandler extends CommandHandler {
freshJob.expiresAt += task.additionalDuration * 1000
freshJob.duration += task.additionalDuration
await engine.db.updateServiceJob(freshJob)
// The results bucket must outlive the longer window too. Best-effort: the
// extension is already paid for, so a storage hiccup must not fail it.
if (freshJob.outputBucketId) {
await this.getOceanNode()
.getPersistentStorage()
?.extendBucketRetention(freshJob.outputBucketId, freshJob.expiresAt)
.catch((e: any) =>
CORE_LOGGER.error(
`Service ${task.serviceId}: could not extend retention of bucket ` +
`${freshJob.outputBucketId}: ${e.message}`
)
)
}

CORE_LOGGER.logMessage(
`Service ${task.serviceId} extended by ${task.additionalDuration}s, new expiresAt: ${freshJob.expiresAt}`,
Expand Down
50 changes: 42 additions & 8 deletions src/components/core/service/getStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,39 @@ import {
ValidateParams,
validateCommandParameters
} from '../../httpRoutes/validateCommands.js'
import type { ServiceJob } from '../../../@types/C2D/ServiceOnDemand.js'
import type {
ServiceJob,
ServiceOutputBucketUsage
} from '../../../@types/C2D/ServiceOnDemand.js'
import type { PersistentStorageFactory } from '../../persistentStorage/PersistentStorageFactory.js'
import { CORE_LOGGER } from '../../../utils/logging/common.js'
import { toPublicServiceJob } from './utils.js'

// Sizing a bucket walks its folder, and clients poll status every few seconds, so a
// reading may be this old. Uploads and deletes through the storage API refresh it.
const BUCKET_USAGE_MAX_AGE_MS = 30_000

// Best-effort: a bucket that is gone or can't be sized just leaves the field off.
async function getOutputBucketUsage(
storage: PersistentStorageFactory | null,
job: ServiceJob
): Promise<ServiceOutputBucketUsage | undefined> {
if (!storage || !job.outputBucketId) return undefined
try {
const usage = await storage.getBucketQuotaUsage(
job.outputBucketId,
BUCKET_USAGE_MAX_AGE_MS
)
if (!usage) return undefined
return { ...usage, full: usage.usedBytes >= usage.quotaBytes }
} catch (e: any) {
CORE_LOGGER.debug(
`Service ${job.serviceId}: could not size bucket ${job.outputBucketId}: ${e.message}`
)
return undefined
}
}

export class ServiceGetStatusHandler extends CommandHandler {
validate(command: ServiceGetStatusCommand): ValidateParams {
// consumerAddress is required: it is the owner scope AND the identity the
Expand Down Expand Up @@ -47,14 +77,18 @@ export class ServiceGetStatusHandler extends CommandHandler {

// Ownership is already proven above (this command is always authenticated), so runtime
// metrics are included BY DEFAULT here — only an explicit includeMetrics=false opts out.
const storage = this.getOceanNode().getPersistentStorage()
const out = await Promise.all(
jobs.map(async (job) => {
const pub = toPublicServiceJob(job, {
includeMetrics: task.includeMetrics !== false
})
const outputBucketUsage = await getOutputBucketUsage(storage, job)
return outputBucketUsage ? { ...pub, outputBucketUsage } : pub
})
)
return {
stream: Readable.from(
JSON.stringify(
jobs.map((job) =>
toPublicServiceJob(job, { includeMetrics: task.includeMetrics !== false })
)
)
),
stream: Readable.from(JSON.stringify(out)),
status: { httpStatus: 200 }
}
}
Expand Down
19 changes: 17 additions & 2 deletions src/components/core/service/startService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import type {
import { generateUniqueID, validateOutputBucket } from '../compute/utils.js'
import { validateAccess } from '../compute/startCompute.js'
import { isJobMetadataSizeValid, INVALID_JOB_METADATA_MESSAGE } from '../../c2d/index.js'
import { decryptUserData, toPublicServiceJob } from './utils.js'
import {
decryptUserData,
resolveServiceOutputBucket,
toPublicServiceJob
} from './utils.js'

export class ServiceStartHandler extends CommandHandler {
validate(command: ServiceStartCommand): ValidateParams {
Expand Down Expand Up @@ -247,6 +251,17 @@ export class ServiceStartHandler extends CommandHandler {
cost
}

// 6c. Results bucket, resolved last so a refused start never leaves one behind. Its
// id is on the returned job (outputBucketId), for the consumer to fetch results
// or hand to a later SERVICE_START.
const outputBucketId = await resolveServiceOutputBucket(
node,
task.consumerAddress,
serviceId,
Date.now() + task.duration * 1000,
task.outputBucketId
)

// 7. Persist the Starting record and return immediately with the serviceId. The
// engine's background loop (processServiceStart) then performs escrow lock → image
// pull/build → claim/cancel → container start. Clients poll SERVICE_GET_STATUS to
Expand All @@ -268,7 +283,7 @@ export class ServiceStartHandler extends CommandHandler {
serviceId,
task.userData,
task.metadata,
task.outputBucketId
outputBucketId
)

return {
Expand Down
27 changes: 27 additions & 0 deletions src/components/core/service/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { C2DDatabase } from '../../database/C2DDatabase.js'
import type { C2DEngine } from '../../c2d/compute_engine_base.js'
import type { C2DEngines } from '../../c2d/compute_engines.js'
import { sanitizePublicMetrics } from '../../c2d/index.js'
import type { OceanNode } from '../../../OceanNode.js'

// Looks up a service job and resolves the engine that OWNS it (by clusterHash). Every
// engine shares the same C2DDatabase, so any engine's db returns the job — taking the
Expand All @@ -26,6 +27,32 @@ export async function findServiceJobAndEngine(
return { job, engine }
}

// Picks the bucket a new service writes its results (/data/outputs) to. A bucket the
// consumer named is kept, with its retention (if it expires at all) pushed out to cover
// this service. Without one, the node creates a default bucket for the service — only on
// localfs, the one backend that works today; otherwise the service runs without a bucket,
// exactly as before.
export async function resolveServiceOutputBucket(
node: OceanNode,
owner: string,
serviceId: string,
serviceExpiresAtMs: number,
requestedBucketId?: string
): Promise<string | undefined> {
const storage = node.getPersistentStorage()
if (requestedBucketId) {
await storage.extendBucketRetention(requestedBucketId, serviceExpiresAtMs)
return requestedBucketId
}
if (!storage || node.getConfig().persistentStorage?.type !== 'localfs') return undefined
const bucket = await storage.getOrCreateServiceBucket(
owner,
serviceId,
serviceExpiresAtMs
)
return bucket.bucketId
}

// Converts the decrypted userData object into a flat container env-var map (stringified values).
export function userDataToEnv(userData: Record<string, unknown>): Record<string, string> {
const env: Record<string, string> = {}
Expand Down
Loading
Loading