Skip to content

fix bugs - #1443

Open
alexcos20 wants to merge 2 commits into
next-4from
bug/fix_negative_runMaxWaitTime
Open

fix bugs#1443
alexcos20 wants to merge 2 commits into
next-4from
bug/fix_negative_runMaxWaitTime

Conversation

@alexcos20

@alexcos20 alexcos20 commented Aug 5, 2026

Copy link
Copy Markdown
Member

Closes #1439

Fix negative runMaxWaitTime, queued-job resource under-counting, and silent getJobs filters

Why

A live node returned this from GET compute environments:

{
  "runningJobs": 15, "runningfreeJobs": 15, "queuedJobs": 0,
  "maxJobDuration": 3600,
  "runMaxWaitTime": -26786355914.46,
  "runMaxWaitTimeFree": -26786355914.46
}

That value is exactly 15 × (3600 − now_in_seconds):

-26786355914.46 / 15 = -1785757060.96  →  + 3600 = 1785760660.96   ← a valid epoch, the fetch time

Every one of the 15 jobs was measuring its elapsed runtime from the Unix epoch. Consumers
(ocean.js, the frontend env picker, schedulers) read these fields to estimate when capacity frees
up, so the environment reads as either permanently unavailable or instantly available depending on
which way the client compares. Worse, the same function feeds the strict resource-availability
gate, so a second defect in it lets the engine overcommit the host.

Three independent defects came out of that one payload, plus a fourth found in the neighbouring
job-listing path.

1. '0' is truthy, so every pull-path job measures from 1970

buildStartTimestamp and algoStartTimestamp are initialized to the string '0', and
buildStartTimestamp is only ever overwritten inside buildImage. The old code branched on
truthiness:

const timeElapsed = job.buildStartTimestamp
  ? now - Number.parseFloat(job.buildStartTimestamp)   // parseFloat('0') === 0 → start = epoch
  : now - Number.parseFloat(job.algoStartTimestamp)
maxRunningTime += job.maxJobDuration - timeElapsed

'0' is truthy, so the first branch is taken for every job that did not build an image from a
Dockerfile — including jobs mid-algorithm holding a perfectly valid algoStartTimestamp
. The
else branch is effectively dead for those jobs. Only Dockerfile-build jobs ever reported a
correct remaining time. That matches the payload: all 15 jobs were free, and
env.free.allowImageBuild gates builds for free jobs, so all 15 were pull-path.

A genuine overrun cannot reach this magnitude — it would need a job running since 1970. But
overruns do make the expression legitimately negative by seconds-to-minutes, between the moment
a job exceeds its budget and the next cron sweep that kills it, so the sentinel and the missing
clamp are two separate fixes.

2. queueMaxWaitTime is never reset, so released jobs stay "queued" forever

The running/queued discriminator was job.queueMaxWaitTime === 0. But queueMaxWaitTime is the
caller's requested maximum queue wait — assigned once at job creation and never written again.
The queue-release path flips status to BuildImage/PullImage and starts the container, but
leaves queueMaxWaitTime at its original positive value.

So for the whole life of any job that was ever queued:

Consequence Severity
counted in queuedJobs / queMaxWaitTime forever, even while its container runs cosmetic
never counted in runningJobs / runningfreeJobs cosmetic
its cpu/ram/disk/gpu are never added to usedResources overcommit

The last row is the real hazard. checkIfResourcesAreAvailable and
checkGlobalResourceAvailability both gate on envResource.inUse, so under-counting a running
job's resources lets the engine admit work the host cannot serve — including via the queue-release
path itself, which calls checkIfResourcesAreAvailable and can therefore release several queued
jobs against the same phantom-free capacity. It also skews runningJobs + 1 > maxJobs, so a node
can exceed its own maxJobs.

The queued branch also added a runtime budget (job.maxJobDuration) to a queue-wait metric.

3. Free-tier availability gate silently passes on NaN

Gate 1 defends itself (envResource.total - (envResource.inUse ?? 0)), but the free-tier gate did
not: envResource.total - envResource.inUse. Both fields are optional on ComputeResource, and a
sparse config (or an unresolved free-resource ref) makes that NaN. NaN < amount is false, so
the free gate passes — unlimited free allocation.

This is the same failure mode as #2 (under-counted usage → overcommit) in the same function, and
#2 changes exactly what feeds inUse, so the two belong together.

Side note for whoever owns the reporting node: in that payload the free.resources entries carry
max/inUse but no total, which current HEAD cannot produce (resolveEnvironmentResources
spreads the pool resource through). That node is probably running a build predating free-resource
pool resolution — on which this NaN gate would have been live, explaining 15 concurrent free
jobs saturating the box. Worth checking its commit; the ?? 0 hardening lands regardless so a
sparse config can never reintroduce it.

4. getJobs filters return the wrong rows, silently

Separate subsystem, found while tracing the above. dateCreated/dateFinished are TEXT holding
decimal seconds ("1785760660.961"), and getJobs bound the caller's fromTimestamp straight
through as a string — so SQLite did a memcmp, not a numeric comparison:

Input vs "1785760660.961" Result
seconds "1785760660" equal prefix, shorter is less works — by digit-count accident only
ms "1785760660961" pos 10: '9' (0x39) > '.' (0x2E) wrong rows
ISO "2026-08-04T…" pos 0: '2' > '1' wrong rows
garbage "abc" 'a' (0x61) > '1' wrong rows

Every failure was [] (or a wrong subset) with HTTP 200 and no log line — indistinguishable from
"no jobs in that window". The handler validated only typeof === 'string'. ORDER BY dateCreated DESC was lexicographic for the same reason. And if (status) dropped the predicate entirely for
C2DStatusNumber.JobStarted, which is 0 — filtering for JobStarted returned every status.

How

Capacity reporting — compute_engine_base.ts

One shared numeric guard, not a truthiness fix. Changing the initializer from '0' to
null would fix this instance while leaving the identical trap for the next field or caller, and
would not repair rows already in the DB carrying '0'. So:

export function parseJobTimestamp(raw?: string): number {
  if (!raw) return 0
  const parsed = Number.parseFloat(raw)
  if (!Number.isFinite(parsed) || parsed <= 0) return 0
  return parsed
}

Callers only ever test > 0. Two helpers build on it:

  • getJobRemainingRuntimeSeconds(job, nowSec)buildStartTimestamp wins when both are set
    (build time counts against maxJobDuration, consistent with the runtime-expiry check in the
    docker engine); clamped to [0, maxJobDuration]; maxJobDuration itself guarded with
    Number.isFinite. A job with no usable timestamp on either field reports its full budget,
    not now — it is allocated but not yet ticking (PullImage/BuildImage/ConfiguringVolumes).
  • getJobRemainingQueueWaitSeconds(job, nowSec) — mirrors the queue-expiry check
    (queueMaxWaitTime - (now - dateCreated)), same clamp, and the same "no usable dateCreated
    report the full requested wait" rule.

Discriminate on status, do not mutate queueMaxWaitTime. Zeroing the stored value on release
would be wrong: it is a contract value, reused for the queue-expiry check and for the escrow lock
duration (maxJobDuration + queueMaxWaitTime), and it would need a DB migration. status is
already an exact, persisted, migration-free signal — C2DStatusNumber.JobQueued is set at creation
iff queueMaxWaitTime > 0 and is replaced on release, and it is the only pre-allocation
state:

const isQueued = job.status === C2DStatusNumber.JobQueued

The two branches are inverted accordingly, and the resource tally is now guarded by !isQueued, so
every post-queue state contributes its resources. Date.now() is hoisted into a single nowSec so
all jobs in one response are measured against the same instant.

Free gate hardened to (envResource.total ?? 0) - (envResource.inUse ?? 0), matching gate 1 —
a missing value now denies instead of admitting.

*MaxWaitTime semantics: sum kept, documented

All four accumulators still use +=. Chosen deliberately over Math.max to avoid any contract
change: the only difference now is that each term is non-negative and derived from a valid start
timestamp. Since the sum makes the field names misleading — 15 jobs with 1 s left each report the
same 15 as one job with 15 s left — the four fields in src/@types/C2D/C2D.ts now carry doc
comments stating the unit, the summing, and explicitly that a client cannot derive
time-to-free-capacity from these fields
. A unit test pins the sum so a later refactor cannot
switch it silently.

Docker engine

getValidBuildDurationSeconds refactored onto parseJobTimestamp (it already had the correct
start <= 0 check inline — now one shared rule instead of a second copy, across its three call
sites). The runtime-expiry check no longer does parseFloat(job.algoStartTimestamp) raw: a '0'
sentinel there would place the start at the epoch and make a healthy container look instantly
expired, so it falls back to timeNow and lets the next sweep see the real timestamp.

getJobs — normalize at the boundary, compare numerically in SQL

  • New src/components/core/utils/timestamps.tsparseFromTimestamp moved here from
    getServices.ts (which re-exports it, so the service handler and its tests are untouched), plus
    a parseFromTimestampSeconds() wrapper. The seconds-vs-ms choice is explicit at the call site
    rather than implicit: the compute-jobs table stores seconds, the service path works in ms.
    A header comment separates this from parseJobTimestamp — that one reads stored timestamps and
    treats '0' as unset; this one normalizes query parameters and must distinguish "no filter"
    from "garbage".
  • GetJobsHandler.validate rejects an unparseable fromTimestamp with 400 and the same
    message shape as GetServicesHandler, instead of 200 []. Absent/empty still means "no filter",
    as before.
  • DB layer takes number (seconds), not a string — type-safety over convenience, so a raw
    string can never reach the SQL again. Threaded through SQLiteCompute.getJobs and the
    C2DDatabase pass-through, and the stale ComputeDatabaseProvider.getJobs declaration
    (3 of 5 params; not constraining anything today because C2DDatabase.provider is typed as the
    concrete class) fixed to the real shape while here.
  • CAST(dateCreated AS REAL) >= ? / CAST(dateFinished AS REAL) >= ?, and
    ORDER BY CAST(dateCreated AS REAL) DESC.
  • if (status !== undefined && status !== null) so status = 0 filters.

No schema or migration change — only how existing TEXT columns are compared.

A comment on the ORDER BY records why this CAST must not be copied to the service_jobs
table: its dateCreated is toISOString(), which is fixed-width UTC and sorts correctly as text,
where CAST("2026-08-04T…" AS REAL) would be 2026. The two tables genuinely disagree on
timestamp format.

Summary by CodeRabbit

  • New Features
    • Added flexible job filtering by Unix timestamps, milliseconds, and ISO-formatted dates.
    • Added validation with clear errors for invalid timestamp filters.
  • Bug Fixes
    • Improved compute wait-time and runtime calculations for queued, running, completed, and pending jobs.
    • Corrected handling of missing or invalid timestamps to prevent inaccurate expiry and duration results.
    • Improved free-resource capacity checks when usage or capacity information is incomplete.
  • Documentation
    • Clarified how queue and runtime wait totals are calculated across jobs, including free-job aggregates.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 98e7724d-54a3-4b49-ab6a-06f73af9259c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@alexcos20

Copy link
Copy Markdown
Member Author

/run-security-scan

@alexcos20 alexcos20 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AI automated code review (Gemini 3).

Overall risk: low

Summary:
This is an excellent PR that resolves multiple subtle bugs. It corrects negative runMaxWaitTime by correctly identifying unset ('0') timestamps, patches an unbound free-resource allocation flaw caused by NaN comparison evaluation, and correctly handles SQLite filtering and sorting semantics (0-index status drops, TEXT vs REAL comparison). The code is well-structured, carefully documented, and accompanied by exhaustive unit tests. LGTM!

Comments:
• [INFO][performance] Using CAST(dateCreated AS REAL) in the WHERE clause (and later in the ORDER BY clause) accurately resolves the string vs. numeric sorting issue. However, please note that applying a CAST (or any function) to a column in SQLite usually prevents the query planner from using an index on that column, resulting in a full table scan. This is acceptable for now to ensure correctness, but if this table grows significantly, consider migrating the column data type to REAL or INTEGER natively in a future schema migration to restore index usage.
• [INFO][security] Excellent catch using the nullish coalescing operator ?? 0 here. A sparse configuration resulting in NaN < amount silently yielding false and bypassing the free allocation constraint is a subtle but critical logical vulnerability. This reliably patches it.
• [INFO][style] Instantiating const nowSec = Date.now() / 1000 once per loop iteration is a great practice. It guarantees a consistent reference time for every job in the list and prevents minor calculation drift during the loop execution.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/components/database/sqliteCompute.ts (1)

775-778: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Apply the same numeric ordering to getJobsByStatus.

getJobs now sorts with CAST(dateCreated AS REAL) DESC. getJobsByStatus still sorts the same TEXT column lexicographically, so a 9-digit second value sorts above a 10-digit one there. Both methods read the same compute_jobs table, so the ordering contract differs between two paths.

♻️ Proposed change outside the selected range (line 807)
-    selectSQL += ` ORDER BY dateCreated DESC`
+    selectSQL += ` ORDER BY CAST(dateCreated AS REAL) DESC`
🤖 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/database/sqliteCompute.ts` around lines 775 - 778, Update the
SQL ordering in getJobsByStatus to use CAST(dateCreated AS REAL) DESC, matching
the ordering already applied in getJobs for the shared compute_jobs table.
Preserve the existing status filtering and other query behavior.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/components/c2d/compute_engine_base.ts`:
- Around line 801-806: Update the paid-resource gate immediately above the
free-resource check to default envResource.total to 0 before subtracting
envResource.inUse, matching the existing missing-value behavior in the free
gate. Preserve the existing request.amount comparison and other allocation
logic.

---

Nitpick comments:
In `@src/components/database/sqliteCompute.ts`:
- Around line 775-778: Update the SQL ordering in getJobsByStatus to use
CAST(dateCreated AS REAL) DESC, matching the ordering already applied in getJobs
for the shared compute_jobs table. Preserve the existing status filtering and
other query behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c3105cbe-e5dc-4156-b8fa-0397a5a26b56

📥 Commits

Reviewing files that changed from the base of the PR and between 879f495 and 9b0a689.

📒 Files selected for processing (10)
  • src/@types/C2D/C2D.ts
  • src/components/c2d/compute_engine_base.ts
  • src/components/c2d/compute_engine_docker.ts
  • src/components/core/handler/getJobs.ts
  • src/components/core/service/getServices.ts
  • src/components/core/utils/timestamps.ts
  • src/components/database/C2DDatabase.ts
  • src/components/database/sqliteCompute.ts
  • src/test/unit/c2d/usedResources.test.ts
  • src/test/unit/database/getJobsFilters.test.ts

Comment on lines +801 to +806
// `total` and `inUse` are both optional on ComputeResource: a sparse config (or an
// unresolved free-resource ref) makes the subtraction NaN, and `NaN < amount` is
// false, which would silently pass the gate and allow unlimited free allocation.
// Default both to 0 so a missing value denies instead of admitting — same shape as
// gate 1 above.
if ((envResource.total ?? 0) - (envResource.inUse ?? 0) < request.amount)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Harden gate 1 the same way; the comment claims parity that does not exist.

The comment states the free gate now has the "same shape as gate 1 above". Gate 1 at line 788 still reads envResource.total - (envResource.inUse ?? 0). If a fungible paid resource has no total, the subtraction is NaN, and NaN < request.amount is false, so the paid allocation passes unchecked. That is the same admit-on-missing-value hole this change closes for the free tier, and it applies to the paid path that guards shared CPU/RAM/disk.

🛡️ Proposed fix at line 788 (outside the selected range)
-      if (isFungible && envResource.total - (envResource.inUse ?? 0) < request.amount)
+      if (isFungible && (envResource.total ?? 0) - (envResource.inUse ?? 0) < request.amount)
         throw new Error(`Not enough available ${request.id} in this environment`)
🤖 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/c2d/compute_engine_base.ts` around lines 801 - 806, Update the
paid-resource gate immediately above the free-resource check to default
envResource.total to 0 before subtracting envResource.inUse, matching the
existing missing-value behavior in the free gate. Preserve the existing
request.amount comparison and other allocation logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant