@shipfox/api-runners 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +187 -0
- package/LICENSE +21 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +208 -0
- package/dist/config.js.map +1 -0
- package/dist/core/demand.d.ts +33 -0
- package/dist/core/demand.d.ts.map +1 -0
- package/dist/core/demand.js +169 -0
- package/dist/core/demand.js.map +1 -0
- package/dist/core/entities/ephemeral-registration-token.d.ts +14 -0
- package/dist/core/entities/ephemeral-registration-token.d.ts.map +1 -0
- package/dist/core/entities/ephemeral-registration-token.js +3 -0
- package/dist/core/entities/ephemeral-registration-token.js.map +1 -0
- package/dist/core/entities/manual-registration-token.d.ts +12 -0
- package/dist/core/entities/manual-registration-token.d.ts.map +1 -0
- package/dist/core/entities/manual-registration-token.js +3 -0
- package/dist/core/entities/manual-registration-token.js.map +1 -0
- package/dist/core/entities/provisioned-runner.d.ts +24 -0
- package/dist/core/entities/provisioned-runner.d.ts.map +1 -0
- package/dist/core/entities/provisioned-runner.js +3 -0
- package/dist/core/entities/provisioned-runner.js.map +1 -0
- package/dist/core/entities/provisioner-token.d.ts +18 -0
- package/dist/core/entities/provisioner-token.d.ts.map +1 -0
- package/dist/core/entities/provisioner-token.js +3 -0
- package/dist/core/entities/provisioner-token.js.map +1 -0
- package/dist/core/entities/reservation.d.ts +10 -0
- package/dist/core/entities/reservation.d.ts.map +1 -0
- package/dist/core/entities/reservation.js +3 -0
- package/dist/core/entities/reservation.js.map +1 -0
- package/dist/core/entities/runner-session.d.ts +18 -0
- package/dist/core/entities/runner-session.d.ts.map +1 -0
- package/dist/core/entities/runner-session.js +3 -0
- package/dist/core/entities/runner-session.js.map +1 -0
- package/dist/core/ephemeral-registration-tokens.d.ts +31 -0
- package/dist/core/ephemeral-registration-tokens.d.ts.map +1 -0
- package/dist/core/ephemeral-registration-tokens.js +61 -0
- package/dist/core/ephemeral-registration-tokens.js.map +1 -0
- package/dist/core/errors.d.ts +62 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +92 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +10 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/job-executions.d.ts +14 -0
- package/dist/core/job-executions.d.ts.map +1 -0
- package/dist/core/job-executions.js +37 -0
- package/dist/core/job-executions.js.map +1 -0
- package/dist/core/maintenance-policy.d.ts +2 -0
- package/dist/core/maintenance-policy.d.ts.map +1 -0
- package/dist/core/maintenance-policy.js +3 -0
- package/dist/core/maintenance-policy.js.map +1 -0
- package/dist/core/maintenance.d.ts +33 -0
- package/dist/core/maintenance.d.ts.map +1 -0
- package/dist/core/maintenance.js +55 -0
- package/dist/core/maintenance.js.map +1 -0
- package/dist/core/manual-registration-tokens.d.ts +17 -0
- package/dist/core/manual-registration-tokens.d.ts.map +1 -0
- package/dist/core/manual-registration-tokens.js +28 -0
- package/dist/core/manual-registration-tokens.js.map +1 -0
- package/dist/core/provisioned-runners.d.ts +67 -0
- package/dist/core/provisioned-runners.d.ts.map +1 -0
- package/dist/core/provisioned-runners.js +162 -0
- package/dist/core/provisioned-runners.js.map +1 -0
- package/dist/core/provisioner-tokens.d.ts +20 -0
- package/dist/core/provisioner-tokens.d.ts.map +1 -0
- package/dist/core/provisioner-tokens.js +36 -0
- package/dist/core/provisioner-tokens.js.map +1 -0
- package/dist/core/rate-limit.d.ts +35 -0
- package/dist/core/rate-limit.d.ts.map +1 -0
- package/dist/core/rate-limit.js +74 -0
- package/dist/core/rate-limit.js.map +1 -0
- package/dist/core/runner-sessions.d.ts +26 -0
- package/dist/core/runner-sessions.d.ts.map +1 -0
- package/dist/core/runner-sessions.js +41 -0
- package/dist/core/runner-sessions.js.map +1 -0
- package/dist/core/runner-tool-capabilities.d.ts +24 -0
- package/dist/core/runner-tool-capabilities.d.ts.map +1 -0
- package/dist/core/runner-tool-capabilities.js +43 -0
- package/dist/core/runner-tool-capabilities.js.map +1 -0
- package/dist/db/db.d.ts +4478 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +36 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/ephemeral-registration-tokens.d.ts +47 -0
- package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -0
- package/dist/db/ephemeral-registration-tokens.js +170 -0
- package/dist/db/ephemeral-registration-tokens.js.map +1 -0
- package/dist/db/index.d.ts +19 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +15 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/job-executions.d.ts +128 -0
- package/dist/db/job-executions.d.ts.map +1 -0
- package/dist/db/job-executions.js +354 -0
- package/dist/db/job-executions.js.map +1 -0
- package/dist/db/manual-registration-tokens.d.ts +16 -0
- package/dist/db/manual-registration-tokens.d.ts.map +1 -0
- package/dist/db/manual-registration-tokens.js +37 -0
- package/dist/db/manual-registration-tokens.js.map +1 -0
- package/dist/db/provisioned-runners.d.ts +79 -0
- package/dist/db/provisioned-runners.d.ts.map +1 -0
- package/dist/db/provisioned-runners.js +466 -0
- package/dist/db/provisioned-runners.js.map +1 -0
- package/dist/db/provisioner-tokens.d.ts +26 -0
- package/dist/db/provisioner-tokens.d.ts.map +1 -0
- package/dist/db/provisioner-tokens.js +54 -0
- package/dist/db/provisioner-tokens.js.map +1 -0
- package/dist/db/rate-limits.d.ts +18 -0
- package/dist/db/rate-limits.d.ts.map +1 -0
- package/dist/db/rate-limits.js +44 -0
- package/dist/db/rate-limits.js.map +1 -0
- package/dist/db/reservations.d.ts +48 -0
- package/dist/db/reservations.d.ts.map +1 -0
- package/dist/db/reservations.js +162 -0
- package/dist/db/reservations.js.map +1 -0
- package/dist/db/runner-sessions.d.ts +18 -0
- package/dist/db/runner-sessions.d.ts.map +1 -0
- package/dist/db/runner-sessions.js +38 -0
- package/dist/db/runner-sessions.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/ephemeral-registration-tokens.d.ts +199 -0
- package/dist/db/schema/ephemeral-registration-tokens.d.ts.map +1 -0
- package/dist/db/schema/ephemeral-registration-tokens.js +45 -0
- package/dist/db/schema/ephemeral-registration-tokens.js.map +1 -0
- package/dist/db/schema/manual-registration-tokens.d.ts +165 -0
- package/dist/db/schema/manual-registration-tokens.d.ts.map +1 -0
- package/dist/db/schema/manual-registration-tokens.js +40 -0
- package/dist/db/schema/manual-registration-tokens.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/schema/pending-job-executions.d.ts +189 -0
- package/dist/db/schema/pending-job-executions.d.ts.map +1 -0
- package/dist/db/schema/pending-job-executions.js +21 -0
- package/dist/db/schema/pending-job-executions.js.map +1 -0
- package/dist/db/schema/provisioned-runners.d.ts +379 -0
- package/dist/db/schema/provisioned-runners.d.ts.map +1 -0
- package/dist/db/schema/provisioned-runners.js +83 -0
- package/dist/db/schema/provisioned-runners.js.map +1 -0
- package/dist/db/schema/provisioner-tokens.d.ts +216 -0
- package/dist/db/schema/provisioner-tokens.d.ts.map +1 -0
- package/dist/db/schema/provisioner-tokens.js +48 -0
- package/dist/db/schema/provisioner-tokens.js.map +1 -0
- package/dist/db/schema/rate-limits.d.ts +145 -0
- package/dist/db/schema/rate-limits.d.ts.map +1 -0
- package/dist/db/schema/rate-limits.js +25 -0
- package/dist/db/schema/rate-limits.js.map +1 -0
- package/dist/db/schema/reservations.d.ts +157 -0
- package/dist/db/schema/reservations.d.ts.map +1 -0
- package/dist/db/schema/reservations.js +34 -0
- package/dist/db/schema/reservations.js.map +1 -0
- package/dist/db/schema/runner-sessions.d.ts +298 -0
- package/dist/db/schema/runner-sessions.d.ts.map +1 -0
- package/dist/db/schema/runner-sessions.js +61 -0
- package/dist/db/schema/runner-sessions.js.map +1 -0
- package/dist/db/schema/running-job-executions.d.ts +334 -0
- package/dist/db/schema/running-job-executions.d.ts.map +1 -0
- package/dist/db/schema/running-job-executions.js +41 -0
- package/dist/db/schema/running-job-executions.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +3 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +4 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +34 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +60 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/metrics/service.d.ts +2 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +21 -0
- package/dist/metrics/service.js.map +1 -0
- package/dist/presentation/auth/index.d.ts +3 -0
- package/dist/presentation/auth/index.d.ts.map +1 -0
- package/dist/presentation/auth/index.js +4 -0
- package/dist/presentation/auth/index.js.map +1 -0
- package/dist/presentation/auth/provisioner-token-auth.d.ts +3 -0
- package/dist/presentation/auth/provisioner-token-auth.d.ts.map +1 -0
- package/dist/presentation/auth/provisioner-token-auth.js +81 -0
- package/dist/presentation/auth/provisioner-token-auth.js.map +1 -0
- package/dist/presentation/auth/runner-registration-token-auth.d.ts +17 -0
- package/dist/presentation/auth/runner-registration-token-auth.d.ts.map +1 -0
- package/dist/presentation/auth/runner-registration-token-auth.js +77 -0
- package/dist/presentation/auth/runner-registration-token-auth.js.map +1 -0
- package/dist/presentation/dto/active-provisioner.d.ts +8 -0
- package/dist/presentation/dto/active-provisioner.d.ts.map +1 -0
- package/dist/presentation/dto/active-provisioner.js +10 -0
- package/dist/presentation/dto/active-provisioner.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +7 -0
- package/dist/presentation/dto/index.d.ts.map +1 -0
- package/dist/presentation/dto/index.js +8 -0
- package/dist/presentation/dto/index.js.map +1 -0
- package/dist/presentation/dto/manual-registration-token.d.ts +12 -0
- package/dist/presentation/dto/manual-registration-token.d.ts.map +1 -0
- package/dist/presentation/dto/manual-registration-token.js +14 -0
- package/dist/presentation/dto/manual-registration-token.js.map +1 -0
- package/dist/presentation/dto/mint-registration-tokens.d.ts +5 -0
- package/dist/presentation/dto/mint-registration-tokens.d.ts.map +1 -0
- package/dist/presentation/dto/mint-registration-tokens.js +19 -0
- package/dist/presentation/dto/mint-registration-tokens.js.map +1 -0
- package/dist/presentation/dto/poll-demand.d.ts +4 -0
- package/dist/presentation/dto/poll-demand.d.ts.map +1 -0
- package/dist/presentation/dto/poll-demand.js +19 -0
- package/dist/presentation/dto/poll-demand.js.map +1 -0
- package/dist/presentation/dto/provisioned-runners.d.ts +38 -0
- package/dist/presentation/dto/provisioned-runners.d.ts.map +1 -0
- package/dist/presentation/dto/provisioned-runners.js +44 -0
- package/dist/presentation/dto/provisioned-runners.js.map +1 -0
- package/dist/presentation/dto/provisioner-token.d.ts +15 -0
- package/dist/presentation/dto/provisioner-token.d.ts.map +1 -0
- package/dist/presentation/dto/provisioner-token.js +17 -0
- package/dist/presentation/dto/provisioner-token.js.map +1 -0
- package/dist/presentation/index.d.ts +4 -0
- package/dist/presentation/index.d.ts.map +1 -0
- package/dist/presentation/index.js +5 -0
- package/dist/presentation/index.js.map +1 -0
- package/dist/presentation/routes/create-manual-registration-token.d.ts +2 -0
- package/dist/presentation/routes/create-manual-registration-token.d.ts.map +1 -0
- package/dist/presentation/routes/create-manual-registration-token.js +44 -0
- package/dist/presentation/routes/create-manual-registration-token.js.map +1 -0
- package/dist/presentation/routes/create-provisioner-token.d.ts +2 -0
- package/dist/presentation/routes/create-provisioner-token.d.ts.map +1 -0
- package/dist/presentation/routes/create-provisioner-token.js +42 -0
- package/dist/presentation/routes/create-provisioner-token.js.map +1 -0
- package/dist/presentation/routes/get-provisioner-me.d.ts +2 -0
- package/dist/presentation/routes/get-provisioner-me.d.ts.map +1 -0
- package/dist/presentation/routes/get-provisioner-me.js +22 -0
- package/dist/presentation/routes/get-provisioner-me.js.map +1 -0
- package/dist/presentation/routes/heartbeat.d.ts +2 -0
- package/dist/presentation/routes/heartbeat.d.ts.map +1 -0
- package/dist/presentation/routes/heartbeat.js +64 -0
- package/dist/presentation/routes/heartbeat.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +4 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +97 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/list-active-provisioners.d.ts +2 -0
- package/dist/presentation/routes/list-active-provisioners.d.ts.map +1 -0
- package/dist/presentation/routes/list-active-provisioners.js +32 -0
- package/dist/presentation/routes/list-active-provisioners.js.map +1 -0
- package/dist/presentation/routes/list-active-runners.d.ts +2 -0
- package/dist/presentation/routes/list-active-runners.d.ts.map +1 -0
- package/dist/presentation/routes/list-active-runners.js +32 -0
- package/dist/presentation/routes/list-active-runners.js.map +1 -0
- package/dist/presentation/routes/list-manual-registration-tokens.d.ts +2 -0
- package/dist/presentation/routes/list-manual-registration-tokens.d.ts.map +1 -0
- package/dist/presentation/routes/list-manual-registration-tokens.js +32 -0
- package/dist/presentation/routes/list-manual-registration-tokens.js.map +1 -0
- package/dist/presentation/routes/list-provisioner-tokens.d.ts +2 -0
- package/dist/presentation/routes/list-provisioner-tokens.d.ts.map +1 -0
- package/dist/presentation/routes/list-provisioner-tokens.js +32 -0
- package/dist/presentation/routes/list-provisioner-tokens.js.map +1 -0
- package/dist/presentation/routes/mint-registration-tokens.d.ts +2 -0
- package/dist/presentation/routes/mint-registration-tokens.d.ts.map +1 -0
- package/dist/presentation/routes/mint-registration-tokens.js +74 -0
- package/dist/presentation/routes/mint-registration-tokens.js.map +1 -0
- package/dist/presentation/routes/poll-demand.d.ts +2 -0
- package/dist/presentation/routes/poll-demand.d.ts.map +1 -0
- package/dist/presentation/routes/poll-demand.js +53 -0
- package/dist/presentation/routes/poll-demand.js.map +1 -0
- package/dist/presentation/routes/rate-limit.d.ts +4 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
- package/dist/presentation/routes/rate-limit.js +94 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -0
- package/dist/presentation/routes/reconcile-provisioned-runners.d.ts +2 -0
- package/dist/presentation/routes/reconcile-provisioned-runners.d.ts.map +1 -0
- package/dist/presentation/routes/reconcile-provisioned-runners.js +27 -0
- package/dist/presentation/routes/reconcile-provisioned-runners.js.map +1 -0
- package/dist/presentation/routes/register.d.ts +2 -0
- package/dist/presentation/routes/register.d.ts.map +1 -0
- package/dist/presentation/routes/register.js +63 -0
- package/dist/presentation/routes/register.js.map +1 -0
- package/dist/presentation/routes/report-provisioned-runners.d.ts +2 -0
- package/dist/presentation/routes/report-provisioned-runners.d.ts.map +1 -0
- package/dist/presentation/routes/report-provisioned-runners.js +37 -0
- package/dist/presentation/routes/report-provisioned-runners.js.map +1 -0
- package/dist/presentation/routes/request-job.d.ts +2 -0
- package/dist/presentation/routes/request-job.d.ts.map +1 -0
- package/dist/presentation/routes/request-job.js +45 -0
- package/dist/presentation/routes/request-job.js.map +1 -0
- package/dist/presentation/routes/revoke-manual-registration-token.d.ts +2 -0
- package/dist/presentation/routes/revoke-manual-registration-token.d.ts.map +1 -0
- package/dist/presentation/routes/revoke-manual-registration-token.js +42 -0
- package/dist/presentation/routes/revoke-manual-registration-token.js.map +1 -0
- package/dist/presentation/routes/revoke-provisioner-token.d.ts +2 -0
- package/dist/presentation/routes/revoke-provisioner-token.d.ts.map +1 -0
- package/dist/presentation/routes/revoke-provisioner-token.js +44 -0
- package/dist/presentation/routes/revoke-provisioner-token.js.map +1 -0
- package/dist/presentation/routes/workspace-membership.d.ts +6 -0
- package/dist/presentation/routes/workspace-membership.d.ts.map +1 -0
- package/dist/presentation/routes/workspace-membership.js +6 -0
- package/dist/presentation/routes/workspace-membership.js.map +1 -0
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts +3 -0
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js +14 -0
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +9 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +12 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/maintenance-activities.d.ts +31 -0
- package/dist/temporal/activities/maintenance-activities.d.ts.map +1 -0
- package/dist/temporal/activities/maintenance-activities.js +18 -0
- package/dist/temporal/activities/maintenance-activities.js.map +1 -0
- package/dist/temporal/constants.d.ts +2 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +3 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/stuck-job-detector.d.ts +2 -0
- package/dist/temporal/workflows/stuck-job-detector.d.ts.map +1 -0
- package/dist/temporal/workflows/stuck-job-detector.js +61 -0
- package/dist/temporal/workflows/stuck-job-detector.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +185 -0
- package/drizzle/meta/0000_snapshot.json +1582 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +82 -0
- package/src/config.test.ts +374 -0
- package/src/config.ts +330 -0
- package/src/core/demand.test.ts +301 -0
- package/src/core/demand.ts +239 -0
- package/src/core/entities/ephemeral-registration-token.ts +13 -0
- package/src/core/entities/manual-registration-token.ts +11 -0
- package/src/core/entities/provisioned-runner.ts +30 -0
- package/src/core/entities/provisioner-token.ts +16 -0
- package/src/core/entities/reservation.ts +9 -0
- package/src/core/entities/runner-session.ts +18 -0
- package/src/core/ephemeral-registration-tokens.test.ts +108 -0
- package/src/core/ephemeral-registration-tokens.ts +112 -0
- package/src/core/errors.ts +128 -0
- package/src/core/index.ts +56 -0
- package/src/core/job-executions.ts +47 -0
- package/src/core/maintenance-policy.ts +1 -0
- package/src/core/maintenance.test.ts +251 -0
- package/src/core/maintenance.ts +74 -0
- package/src/core/manual-registration-tokens.test.ts +44 -0
- package/src/core/manual-registration-tokens.ts +51 -0
- package/src/core/provisioned-runners.test.ts +101 -0
- package/src/core/provisioned-runners.ts +301 -0
- package/src/core/provisioner-tokens.test.ts +61 -0
- package/src/core/provisioner-tokens.ts +61 -0
- package/src/core/rate-limit.test.ts +158 -0
- package/src/core/rate-limit.ts +121 -0
- package/src/core/runner-sessions.test.ts +209 -0
- package/src/core/runner-sessions.ts +66 -0
- package/src/core/runner-tool-capabilities.test.ts +141 -0
- package/src/core/runner-tool-capabilities.ts +75 -0
- package/src/db/db.ts +38 -0
- package/src/db/ephemeral-registration-tokens.test.ts +332 -0
- package/src/db/ephemeral-registration-tokens.ts +348 -0
- package/src/db/index.ts +90 -0
- package/src/db/job-executions.test.ts +1500 -0
- package/src/db/job-executions.ts +647 -0
- package/src/db/manual-registration-tokens.test.ts +99 -0
- package/src/db/manual-registration-tokens.ts +87 -0
- package/src/db/provisioned-runners.test.ts +1812 -0
- package/src/db/provisioned-runners.ts +886 -0
- package/src/db/provisioner-tokens.test.ts +133 -0
- package/src/db/provisioner-tokens.ts +142 -0
- package/src/db/rate-limits.test.ts +74 -0
- package/src/db/rate-limits.ts +69 -0
- package/src/db/reservations.test.ts +399 -0
- package/src/db/reservations.ts +296 -0
- package/src/db/runner-sessions.test.ts +153 -0
- package/src/db/runner-sessions.ts +97 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/ephemeral-registration-tokens.ts +58 -0
- package/src/db/schema/manual-registration-tokens.ts +40 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/schema/pending-job-executions.ts +25 -0
- package/src/db/schema/provisioned-runners.ts +89 -0
- package/src/db/schema/provisioner-tokens.ts +50 -0
- package/src/db/schema/rate-limits.ts +27 -0
- package/src/db/schema/reservations.ts +38 -0
- package/src/db/schema/runner-sessions.ts +77 -0
- package/src/db/schema/running-job-executions.ts +50 -0
- package/src/index.ts +63 -0
- package/src/metrics/index.ts +13 -0
- package/src/metrics/instance.ts +115 -0
- package/src/metrics/service.ts +22 -0
- package/src/presentation/auth/index.ts +6 -0
- package/src/presentation/auth/provisioner-token-auth.ts +76 -0
- package/src/presentation/auth/runner-registration-token-auth.ts +105 -0
- package/src/presentation/dto/active-provisioner.ts +15 -0
- package/src/presentation/dto/index.ts +13 -0
- package/src/presentation/dto/manual-registration-token.ts +23 -0
- package/src/presentation/dto/mint-registration-tokens.ts +35 -0
- package/src/presentation/dto/poll-demand.ts +20 -0
- package/src/presentation/dto/provisioned-runners.ts +88 -0
- package/src/presentation/dto/provisioner-token.ts +29 -0
- package/src/presentation/index.ts +6 -0
- package/src/presentation/routes/create-manual-registration-token.ts +43 -0
- package/src/presentation/routes/create-provisioner-token.ts +41 -0
- package/src/presentation/routes/get-provisioner-me.ts +22 -0
- package/src/presentation/routes/heartbeat.test.ts +325 -0
- package/src/presentation/routes/heartbeat.ts +80 -0
- package/src/presentation/routes/index.ts +86 -0
- package/src/presentation/routes/list-active-provisioners.ts +25 -0
- package/src/presentation/routes/list-active-runners.test.ts +366 -0
- package/src/presentation/routes/list-active-runners.ts +25 -0
- package/src/presentation/routes/list-manual-registration-tokens.ts +25 -0
- package/src/presentation/routes/list-provisioner-tokens.ts +25 -0
- package/src/presentation/routes/manual-registration-tokens.test.ts +213 -0
- package/src/presentation/routes/mint-registration-tokens.test.ts +453 -0
- package/src/presentation/routes/mint-registration-tokens.ts +82 -0
- package/src/presentation/routes/poll-demand-release.test.ts +80 -0
- package/src/presentation/routes/poll-demand.test.ts +323 -0
- package/src/presentation/routes/poll-demand.ts +56 -0
- package/src/presentation/routes/provisioner-me.test.ts +172 -0
- package/src/presentation/routes/provisioner-tokens.test.ts +219 -0
- package/src/presentation/routes/rate-limit.ts +115 -0
- package/src/presentation/routes/reconcile-provisioned-runners.test.ts +380 -0
- package/src/presentation/routes/reconcile-provisioned-runners.ts +30 -0
- package/src/presentation/routes/register.test.ts +453 -0
- package/src/presentation/routes/register.ts +75 -0
- package/src/presentation/routes/report-provisioned-runners.test.ts +249 -0
- package/src/presentation/routes/report-provisioned-runners.ts +40 -0
- package/src/presentation/routes/request-job.test.ts +222 -0
- package/src/presentation/routes/request-job.ts +47 -0
- package/src/presentation/routes/revoke-manual-registration-token.ts +35 -0
- package/src/presentation/routes/revoke-provisioner-token.ts +37 -0
- package/src/presentation/routes/workspace-membership.ts +9 -0
- package/src/presentation/subscribers/on-workflows-job-execution-timed-out.test.ts +147 -0
- package/src/presentation/subscribers/on-workflows-job-execution-timed-out.ts +17 -0
- package/src/temporal/activities/index.ts +17 -0
- package/src/temporal/activities/maintenance-activities.test.ts +104 -0
- package/src/temporal/activities/maintenance-activities.ts +41 -0
- package/src/temporal/constants.ts +1 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/stuck-job-detector.test.ts +93 -0
- package/src/temporal/workflows/stuck-job-detector.ts +67 -0
- package/test/env.ts +11 -0
- package/test/factories/ephemeral-registration-token.ts +41 -0
- package/test/factories/manual-registration-token.ts +37 -0
- package/test/factories/pending-job.ts +44 -0
- package/test/factories/provisioned-runner.ts +78 -0
- package/test/factories/provisioner-token.ts +41 -0
- package/test/factories/reservation.ts +33 -0
- package/test/factories/runner-session.ts +32 -0
- package/test/globalSetup.ts +23 -0
- package/test/index.ts +16 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,1500 @@
|
|
|
1
|
+
import {verifyJobLeaseToken} from '@shipfox/api-auth';
|
|
2
|
+
import {
|
|
3
|
+
RUNNER_JOB_CLAIMED,
|
|
4
|
+
RUNNER_JOB_LEASE_EXPIRED,
|
|
5
|
+
RUNNER_JOB_QUEUED,
|
|
6
|
+
} from '@shipfox/api-runners-dto';
|
|
7
|
+
import {eq, sql} from 'drizzle-orm';
|
|
8
|
+
import {EmptyRequiredLabelsError, RunnerSessionExhaustedError} from '#core/errors.js';
|
|
9
|
+
import {claimJobExecution} from '#core/job-executions.js';
|
|
10
|
+
import {detectAndExpireStuckJobs} from '#core/maintenance.js';
|
|
11
|
+
import {pendingJobFactory, runnerSessionFactory} from '#test/index.js';
|
|
12
|
+
import {db} from './db.js';
|
|
13
|
+
import {
|
|
14
|
+
cancelRunnerJobs,
|
|
15
|
+
claimPendingJobExecution as claimPendingJobExecutionDb,
|
|
16
|
+
enqueueJobExecution,
|
|
17
|
+
expireStuckJobExecutions,
|
|
18
|
+
getJobExecutionQueueDepth,
|
|
19
|
+
isJobLeaseActive,
|
|
20
|
+
recordHeartbeat,
|
|
21
|
+
releaseJobExecution,
|
|
22
|
+
requestJobExecutionCancellation,
|
|
23
|
+
} from './job-executions.js';
|
|
24
|
+
import {runnersOutbox} from './schema/outbox.js';
|
|
25
|
+
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
26
|
+
import {runnerSessions} from './schema/runner-sessions.js';
|
|
27
|
+
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
28
|
+
|
|
29
|
+
const sessionLabels = ['linux', 'x64'];
|
|
30
|
+
|
|
31
|
+
function claimPendingJobExecution(
|
|
32
|
+
params: Omit<
|
|
33
|
+
Parameters<typeof claimPendingJobExecutionDb>[0],
|
|
34
|
+
'maxClaims' | 'sessionLabels' | 'runnerSessionLivenessThrottleSeconds'
|
|
35
|
+
> & {
|
|
36
|
+
maxClaims?: number | null;
|
|
37
|
+
sessionLabels?: string[];
|
|
38
|
+
runnerSessionLivenessThrottleSeconds?: number;
|
|
39
|
+
},
|
|
40
|
+
) {
|
|
41
|
+
return claimPendingJobExecutionDb({
|
|
42
|
+
...params,
|
|
43
|
+
maxClaims: params.maxClaims ?? null,
|
|
44
|
+
sessionLabels: params.sessionLabels ?? sessionLabels,
|
|
45
|
+
runnerSessionLivenessThrottleSeconds: params.runnerSessionLivenessThrottleSeconds ?? 10,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function outboxEventsForJob(eventType: string, jobId: string) {
|
|
50
|
+
const rows = await db()
|
|
51
|
+
.select()
|
|
52
|
+
.from(runnersOutbox)
|
|
53
|
+
.where(eq(runnersOutbox.eventType, eventType));
|
|
54
|
+
return rows.filter((row) => (row.payload as {jobId?: string}).jobId === jobId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('enqueueJobExecution', () => {
|
|
58
|
+
it('stores a pending assignment row', async () => {
|
|
59
|
+
const jobId = crypto.randomUUID();
|
|
60
|
+
const jobExecutionId = crypto.randomUUID();
|
|
61
|
+
const workflowRunId = crypto.randomUUID();
|
|
62
|
+
const workflowRunAttemptId = crypto.randomUUID();
|
|
63
|
+
const workspaceId = crypto.randomUUID();
|
|
64
|
+
const projectId = crypto.randomUUID();
|
|
65
|
+
|
|
66
|
+
await enqueueJobExecution({
|
|
67
|
+
workspaceId,
|
|
68
|
+
workflowRunId,
|
|
69
|
+
jobId,
|
|
70
|
+
jobExecutionId,
|
|
71
|
+
workflowRunAttemptId,
|
|
72
|
+
projectId,
|
|
73
|
+
requiredLabels: ['linux'],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const rows = await db()
|
|
77
|
+
.select()
|
|
78
|
+
.from(pendingJobExecutions)
|
|
79
|
+
.where(eq(pendingJobExecutions.jobExecutionId, jobExecutionId));
|
|
80
|
+
expect(rows).toHaveLength(1);
|
|
81
|
+
expect(rows[0]?.jobId).toBe(jobId);
|
|
82
|
+
expect(rows[0]?.jobExecutionId).toBe(jobExecutionId);
|
|
83
|
+
expect(rows[0]?.workflowRunId).toBe(workflowRunId);
|
|
84
|
+
expect(rows[0]?.workflowRunAttemptId).toBe(workflowRunAttemptId);
|
|
85
|
+
expect(rows[0]?.projectId).toBe(projectId);
|
|
86
|
+
expect(rows[0]?.workspaceId).toBe(workspaceId);
|
|
87
|
+
expect(rows[0]?.requiredLabels).toEqual(['linux']);
|
|
88
|
+
expect(rows[0]).not.toHaveProperty('payload');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('stores canonical required labels', async () => {
|
|
92
|
+
const jobId = crypto.randomUUID();
|
|
93
|
+
const jobExecutionId = crypto.randomUUID();
|
|
94
|
+
|
|
95
|
+
await enqueueJobExecution({
|
|
96
|
+
workspaceId: crypto.randomUUID(),
|
|
97
|
+
jobId,
|
|
98
|
+
jobExecutionId,
|
|
99
|
+
workflowRunId: crypto.randomUUID(),
|
|
100
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
101
|
+
projectId: crypto.randomUUID(),
|
|
102
|
+
requiredLabels: ['Ubuntu22', ' ubuntu22 ', 'LINUX'],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const rows = await db()
|
|
106
|
+
.select()
|
|
107
|
+
.from(pendingJobExecutions)
|
|
108
|
+
.where(eq(pendingJobExecutions.jobId, jobId));
|
|
109
|
+
expect(rows[0]?.requiredLabels).toEqual(['linux', 'ubuntu22']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects empty required labels', async () => {
|
|
113
|
+
await expect(
|
|
114
|
+
enqueueJobExecution({
|
|
115
|
+
workspaceId: crypto.randomUUID(),
|
|
116
|
+
jobId: crypto.randomUUID(),
|
|
117
|
+
jobExecutionId: crypto.randomUUID(),
|
|
118
|
+
workflowRunId: crypto.randomUUID(),
|
|
119
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
120
|
+
projectId: crypto.randomUUID(),
|
|
121
|
+
requiredLabels: [],
|
|
122
|
+
}),
|
|
123
|
+
).rejects.toBeInstanceOf(EmptyRequiredLabelsError);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('is idempotent: scheduling the same jobId twice is a no-op', async () => {
|
|
127
|
+
const jobId = crypto.randomUUID();
|
|
128
|
+
const params = {
|
|
129
|
+
workspaceId: crypto.randomUUID(),
|
|
130
|
+
workflowRunId: crypto.randomUUID(),
|
|
131
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
132
|
+
projectId: crypto.randomUUID(),
|
|
133
|
+
jobId,
|
|
134
|
+
jobExecutionId: crypto.randomUUID(),
|
|
135
|
+
requiredLabels: ['linux'],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
await enqueueJobExecution(params);
|
|
139
|
+
await expect(enqueueJobExecution(params)).resolves.toBeUndefined();
|
|
140
|
+
|
|
141
|
+
const rows = await db()
|
|
142
|
+
.select()
|
|
143
|
+
.from(pendingJobExecutions)
|
|
144
|
+
.where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
|
|
145
|
+
expect(rows).toHaveLength(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('emits runners.job.queued carrying the pending row created_at', async () => {
|
|
149
|
+
const jobId = crypto.randomUUID();
|
|
150
|
+
const workflowRunId = crypto.randomUUID();
|
|
151
|
+
const workflowRunAttemptId = crypto.randomUUID();
|
|
152
|
+
|
|
153
|
+
await enqueueJobExecution({
|
|
154
|
+
workspaceId: crypto.randomUUID(),
|
|
155
|
+
workflowRunId,
|
|
156
|
+
jobId,
|
|
157
|
+
jobExecutionId: crypto.randomUUID(),
|
|
158
|
+
workflowRunAttemptId,
|
|
159
|
+
projectId: crypto.randomUUID(),
|
|
160
|
+
requiredLabels: ['linux'],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const [pending] = await db()
|
|
164
|
+
.select()
|
|
165
|
+
.from(pendingJobExecutions)
|
|
166
|
+
.where(eq(pendingJobExecutions.jobId, jobId));
|
|
167
|
+
const outbox = await outboxEventsForJob(RUNNER_JOB_QUEUED, jobId);
|
|
168
|
+
expect(outbox).toHaveLength(1);
|
|
169
|
+
expect(outbox[0]?.eventType).toBe(RUNNER_JOB_QUEUED);
|
|
170
|
+
const payload = outbox[0]?.payload as {
|
|
171
|
+
jobId: string;
|
|
172
|
+
workflowRunId: string;
|
|
173
|
+
workflowRunAttemptId: string;
|
|
174
|
+
queuedAt: string;
|
|
175
|
+
};
|
|
176
|
+
expect(payload.jobId).toBe(jobId);
|
|
177
|
+
expect(payload.workflowRunId).toBe(workflowRunId);
|
|
178
|
+
expect(payload.workflowRunAttemptId).toBe(workflowRunAttemptId);
|
|
179
|
+
expect(new Date(payload.queuedAt).getTime()).toBe(pending?.createdAt.getTime());
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('does not double-emit queued when the same jobId is re-enqueued (idempotency regression)', async () => {
|
|
183
|
+
const params = {
|
|
184
|
+
workspaceId: crypto.randomUUID(),
|
|
185
|
+
workflowRunId: crypto.randomUUID(),
|
|
186
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
187
|
+
projectId: crypto.randomUUID(),
|
|
188
|
+
jobId: crypto.randomUUID(),
|
|
189
|
+
jobExecutionId: crypto.randomUUID(),
|
|
190
|
+
requiredLabels: ['linux'],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
await enqueueJobExecution(params);
|
|
194
|
+
await enqueueJobExecution(params);
|
|
195
|
+
|
|
196
|
+
expect(
|
|
197
|
+
await db()
|
|
198
|
+
.select()
|
|
199
|
+
.from(pendingJobExecutions)
|
|
200
|
+
.where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId)),
|
|
201
|
+
).toHaveLength(1);
|
|
202
|
+
expect(await outboxEventsForJob(RUNNER_JOB_QUEUED, params.jobId)).toHaveLength(1);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('claimPendingJobExecution', () => {
|
|
207
|
+
let workspaceId: string;
|
|
208
|
+
let runnerSessionId: string;
|
|
209
|
+
|
|
210
|
+
beforeEach(async () => {
|
|
211
|
+
workspaceId = crypto.randomUUID();
|
|
212
|
+
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
213
|
+
runnerSessionId = runnerSession.id;
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('emits runners.job.claimed carrying the claim instant on a real claim', async () => {
|
|
217
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
218
|
+
|
|
219
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
220
|
+
|
|
221
|
+
const [running] = await db()
|
|
222
|
+
.select()
|
|
223
|
+
.from(runningJobExecutions)
|
|
224
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
225
|
+
const outbox = await outboxEventsForJob(RUNNER_JOB_CLAIMED, created.jobId);
|
|
226
|
+
expect(outbox).toHaveLength(1);
|
|
227
|
+
const payload = outbox[0]?.payload as {
|
|
228
|
+
jobId: string;
|
|
229
|
+
workflowRunId: string;
|
|
230
|
+
workflowRunAttemptId: string;
|
|
231
|
+
claimedAt: string;
|
|
232
|
+
};
|
|
233
|
+
expect(payload.jobId).toBe(created.jobId);
|
|
234
|
+
expect(payload.workflowRunId).toBe(created.workflowRunId);
|
|
235
|
+
expect(payload.workflowRunAttemptId).toBe(created.workflowRunAttemptId);
|
|
236
|
+
expect(new Date(payload.claimedAt).getTime()).toBe(running?.startedAt.getTime());
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('emits no claimed event when there is nothing to claim', async () => {
|
|
240
|
+
const before = await db()
|
|
241
|
+
.select()
|
|
242
|
+
.from(runnersOutbox)
|
|
243
|
+
.where(eq(runnersOutbox.eventType, RUNNER_JOB_CLAIMED));
|
|
244
|
+
|
|
245
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
246
|
+
|
|
247
|
+
const after = await db()
|
|
248
|
+
.select()
|
|
249
|
+
.from(runnersOutbox)
|
|
250
|
+
.where(eq(runnersOutbox.eventType, RUNNER_JOB_CLAIMED));
|
|
251
|
+
expect(claimed).toBeNull();
|
|
252
|
+
expect(after).toHaveLength(before.length);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('emits no claimed event when dropping an orphan pending row', async () => {
|
|
256
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
257
|
+
const first = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
258
|
+
if (!first) throw new Error('Expected pending job to be claimed');
|
|
259
|
+
await db().insert(pendingJobExecutions).values({
|
|
260
|
+
workspaceId,
|
|
261
|
+
workflowRunId: created.workflowRunId,
|
|
262
|
+
jobId: created.jobId,
|
|
263
|
+
jobExecutionId: first.jobExecutionId,
|
|
264
|
+
workflowRunAttemptId: created.workflowRunAttemptId,
|
|
265
|
+
projectId: created.projectId,
|
|
266
|
+
requiredLabels: created.requiredLabels,
|
|
267
|
+
});
|
|
268
|
+
// Clear the initial claim's events so this assertion only covers the orphan claim.
|
|
269
|
+
const beforeOrphanClaim = await outboxEventsForJob(RUNNER_JOB_CLAIMED, created.jobId);
|
|
270
|
+
|
|
271
|
+
const second = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
272
|
+
|
|
273
|
+
expect(second).toBeNull();
|
|
274
|
+
expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, created.jobId)).toHaveLength(
|
|
275
|
+
beforeOrphanClaim.length,
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('returns the job ids when a job is available', async () => {
|
|
280
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
281
|
+
|
|
282
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
283
|
+
|
|
284
|
+
expect(claimed).not.toBeNull();
|
|
285
|
+
expect(claimed?.jobId).toBe(created.jobId);
|
|
286
|
+
expect(claimed?.workflowRunAttemptId).toBe(created.workflowRunAttemptId);
|
|
287
|
+
expect(claimed?.projectId).toBe(created.projectId);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('starts a claimed job without a first heartbeat marker', async () => {
|
|
291
|
+
await pendingJobFactory.create({workspaceId});
|
|
292
|
+
|
|
293
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
294
|
+
|
|
295
|
+
const [running] = await db()
|
|
296
|
+
.select()
|
|
297
|
+
.from(runningJobExecutions)
|
|
298
|
+
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string));
|
|
299
|
+
expect(running?.firstHeartbeatAt).toBeNull();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('reports an active lease only for the session that claimed the job', async () => {
|
|
303
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
304
|
+
const otherRunnerSession = await runnerSessionFactory.create({workspaceId});
|
|
305
|
+
|
|
306
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
307
|
+
const active = await isJobLeaseActive({
|
|
308
|
+
jobId: created.jobId,
|
|
309
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
310
|
+
runnerSessionId,
|
|
311
|
+
});
|
|
312
|
+
const stale = await isJobLeaseActive({
|
|
313
|
+
jobId: created.jobId,
|
|
314
|
+
jobExecutionId: created.jobExecutionId,
|
|
315
|
+
runnerSessionId: otherRunnerSession.id,
|
|
316
|
+
});
|
|
317
|
+
const mismatchedJob = await isJobLeaseActive({
|
|
318
|
+
jobId: crypto.randomUUID(),
|
|
319
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
320
|
+
runnerSessionId,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(active).toBe(true);
|
|
324
|
+
expect(stale).toBe(false);
|
|
325
|
+
expect(mismatchedJob).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('returns null when no jobs are pending', async () => {
|
|
329
|
+
await db()
|
|
330
|
+
.update(runnerSessions)
|
|
331
|
+
.set({updatedAt: new Date('2025-01-01T00:00:00.000Z')})
|
|
332
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
333
|
+
|
|
334
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
335
|
+
|
|
336
|
+
const [session] = await db()
|
|
337
|
+
.select({updatedAt: runnerSessions.updatedAt})
|
|
338
|
+
.from(runnerSessions)
|
|
339
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
340
|
+
expect(claimed).toBeNull();
|
|
341
|
+
expect(session?.updatedAt.getTime()).toBeGreaterThan(
|
|
342
|
+
new Date('2025-01-01T00:00:00.000Z').getTime(),
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('touches runner session liveness when a job is claimed', async () => {
|
|
347
|
+
const staleUpdatedAt = new Date('2025-01-01T00:00:00.000Z');
|
|
348
|
+
await db()
|
|
349
|
+
.update(runnerSessions)
|
|
350
|
+
.set({updatedAt: staleUpdatedAt})
|
|
351
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
352
|
+
await pendingJobFactory.create({workspaceId});
|
|
353
|
+
|
|
354
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
355
|
+
|
|
356
|
+
const [session] = await db()
|
|
357
|
+
.select({updatedAt: runnerSessions.updatedAt})
|
|
358
|
+
.from(runnerSessions)
|
|
359
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
360
|
+
expect(claimed).not.toBeNull();
|
|
361
|
+
expect(session?.updatedAt.getTime()).toBeGreaterThan(staleUpdatedAt.getTime());
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('does not touch runner session liveness inside the throttle window', async () => {
|
|
365
|
+
const freshUpdatedAt = new Date();
|
|
366
|
+
await db()
|
|
367
|
+
.update(runnerSessions)
|
|
368
|
+
.set({updatedAt: freshUpdatedAt})
|
|
369
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
370
|
+
|
|
371
|
+
const claimed = await claimPendingJobExecution({
|
|
372
|
+
workspaceId,
|
|
373
|
+
runnerSessionId,
|
|
374
|
+
maxClaims: null,
|
|
375
|
+
runnerSessionLivenessThrottleSeconds: 10,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const [session] = await db()
|
|
379
|
+
.select({updatedAt: runnerSessions.updatedAt})
|
|
380
|
+
.from(runnerSessions)
|
|
381
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
382
|
+
expect(claimed).toBeNull();
|
|
383
|
+
expect(session?.updatedAt.getTime()).toBe(freshUpdatedAt.getTime());
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('enforces a non-null session claim cap from the database', async () => {
|
|
387
|
+
const provisionerId = crypto.randomUUID();
|
|
388
|
+
const provisionedRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
|
|
389
|
+
await db()
|
|
390
|
+
.update(runnerSessions)
|
|
391
|
+
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, provisionedRunnerId})
|
|
392
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
393
|
+
await pendingJobFactory.create({workspaceId});
|
|
394
|
+
await pendingJobFactory.create({workspaceId});
|
|
395
|
+
|
|
396
|
+
const first = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
397
|
+
|
|
398
|
+
expect(first).not.toBeNull();
|
|
399
|
+
await expect(
|
|
400
|
+
claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1}),
|
|
401
|
+
).rejects.toBeInstanceOf(RunnerSessionExhaustedError);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('does not spend a claim when a capped session polls an empty queue', async () => {
|
|
405
|
+
const provisionerId = crypto.randomUUID();
|
|
406
|
+
const provisionedRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
|
|
407
|
+
await db()
|
|
408
|
+
.update(runnerSessions)
|
|
409
|
+
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, provisionedRunnerId})
|
|
410
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
411
|
+
|
|
412
|
+
const empty = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
413
|
+
|
|
414
|
+
const [afterEmpty] = await db()
|
|
415
|
+
.select({claimsUsed: runnerSessions.claimsUsed})
|
|
416
|
+
.from(runnerSessions)
|
|
417
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
418
|
+
expect(empty).toBeNull();
|
|
419
|
+
expect(afterEmpty?.claimsUsed).toBe(0);
|
|
420
|
+
|
|
421
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
422
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
423
|
+
|
|
424
|
+
const [afterClaim] = await db()
|
|
425
|
+
.select({claimsUsed: runnerSessions.claimsUsed})
|
|
426
|
+
.from(runnerSessions)
|
|
427
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
428
|
+
expect(claimed?.jobId).toBe(created.jobId);
|
|
429
|
+
expect(afterClaim?.claimsUsed).toBe(1);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('allows a manual session to claim repeatedly', async () => {
|
|
433
|
+
const first = await pendingJobFactory.create({workspaceId});
|
|
434
|
+
const second = await pendingJobFactory.create({workspaceId});
|
|
435
|
+
|
|
436
|
+
const firstClaim = await claimPendingJobExecution({
|
|
437
|
+
workspaceId,
|
|
438
|
+
runnerSessionId,
|
|
439
|
+
maxClaims: null,
|
|
440
|
+
});
|
|
441
|
+
const secondClaim = await claimPendingJobExecution({
|
|
442
|
+
workspaceId,
|
|
443
|
+
runnerSessionId,
|
|
444
|
+
maxClaims: null,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(firstClaim?.jobId).toBe(first.jobId);
|
|
448
|
+
expect(secondClaim?.jobId).toBe(second.jobId);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('only one caller wins when two claim concurrently', async () => {
|
|
452
|
+
const otherRunnerSession = await runnerSessionFactory.create({workspaceId});
|
|
453
|
+
await pendingJobFactory.create({workspaceId});
|
|
454
|
+
|
|
455
|
+
const [claim1, claim2] = await Promise.all([
|
|
456
|
+
claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null}),
|
|
457
|
+
claimPendingJobExecution({
|
|
458
|
+
workspaceId,
|
|
459
|
+
runnerSessionId: otherRunnerSession.id,
|
|
460
|
+
maxClaims: null,
|
|
461
|
+
}),
|
|
462
|
+
]);
|
|
463
|
+
|
|
464
|
+
const claimed = [claim1, claim2].filter(Boolean);
|
|
465
|
+
expect(claimed).toHaveLength(1);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('claims the oldest job first', async () => {
|
|
469
|
+
const older = await pendingJobFactory.create({workspaceId});
|
|
470
|
+
await pendingJobFactory.create({workspaceId});
|
|
471
|
+
|
|
472
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
473
|
+
|
|
474
|
+
expect(claimed?.jobId).toBe(older.jobId);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('moves the job from pending to running', async () => {
|
|
478
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
479
|
+
|
|
480
|
+
await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
481
|
+
|
|
482
|
+
const pending = await db()
|
|
483
|
+
.select()
|
|
484
|
+
.from(pendingJobExecutions)
|
|
485
|
+
.where(eq(pendingJobExecutions.workspaceId, workspaceId));
|
|
486
|
+
const running = await db()
|
|
487
|
+
.select()
|
|
488
|
+
.from(runningJobExecutions)
|
|
489
|
+
.where(eq(runningJobExecutions.workspaceId, workspaceId));
|
|
490
|
+
expect(pending).toHaveLength(0);
|
|
491
|
+
expect(running).toHaveLength(1);
|
|
492
|
+
expect(running[0]?.runnerSessionId).toBe(runnerSessionId);
|
|
493
|
+
expect(running[0]?.projectId).toBe(created.projectId);
|
|
494
|
+
expect(running[0]?.requiredLabels).toEqual(created.requiredLabels);
|
|
495
|
+
expect(running[0]?.runnerLabels).toEqual(sessionLabels);
|
|
496
|
+
expect(running[0]?.provisionerId).toBeNull();
|
|
497
|
+
expect(running[0]?.provisionedRunnerId).toBeNull();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('copies an ephemeral session provisioned-runner link onto the running job', async () => {
|
|
501
|
+
const provisionerId = crypto.randomUUID();
|
|
502
|
+
const provisionedRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
|
|
503
|
+
await db()
|
|
504
|
+
.update(runnerSessions)
|
|
505
|
+
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, provisionedRunnerId})
|
|
506
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
507
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
508
|
+
|
|
509
|
+
await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
510
|
+
|
|
511
|
+
const [running] = await db()
|
|
512
|
+
.select()
|
|
513
|
+
.from(runningJobExecutions)
|
|
514
|
+
.where(eq(runningJobExecutions.jobId, created.jobId));
|
|
515
|
+
expect(running?.provisionerId).toBe(provisionerId);
|
|
516
|
+
expect(running?.provisionedRunnerId).toBe(provisionedRunnerId);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('rejects a running job row with a partial provisioned-runner link', async () => {
|
|
520
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
521
|
+
|
|
522
|
+
await expect(
|
|
523
|
+
db().insert(runningJobExecutions).values({
|
|
524
|
+
workspaceId,
|
|
525
|
+
workflowRunId: created.workflowRunId,
|
|
526
|
+
jobId: created.jobId,
|
|
527
|
+
jobExecutionId: created.jobExecutionId,
|
|
528
|
+
workflowRunAttemptId: created.workflowRunAttemptId,
|
|
529
|
+
projectId: created.projectId,
|
|
530
|
+
runnerSessionId,
|
|
531
|
+
provisionerId: crypto.randomUUID(),
|
|
532
|
+
requiredLabels: created.requiredLabels,
|
|
533
|
+
runnerLabels: sessionLabels,
|
|
534
|
+
}),
|
|
535
|
+
).rejects.toThrow();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('claims a job whose required labels are a subset of the session labels', async () => {
|
|
539
|
+
const created = await pendingJobFactory.create({
|
|
540
|
+
workspaceId,
|
|
541
|
+
requiredLabels: ['linux'],
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
545
|
+
|
|
546
|
+
expect(claimed?.jobId).toBe(created.jobId);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('claims a job whose required labels exactly match the session labels', async () => {
|
|
550
|
+
const created = await pendingJobFactory.create({
|
|
551
|
+
workspaceId,
|
|
552
|
+
requiredLabels: ['linux', 'x64'],
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
556
|
+
|
|
557
|
+
expect(claimed?.jobId).toBe(created.jobId);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('claims by labels only when runner tool capabilities differ', async () => {
|
|
561
|
+
const matchingRunner = await runnerSessionFactory.create({
|
|
562
|
+
workspaceId,
|
|
563
|
+
labels: sessionLabels,
|
|
564
|
+
toolCapabilities: {harnesses: {pi: {tools: ['read']}}},
|
|
565
|
+
});
|
|
566
|
+
const underProvisionedRunner = await runnerSessionFactory.create({
|
|
567
|
+
workspaceId,
|
|
568
|
+
labels: sessionLabels,
|
|
569
|
+
toolCapabilities: {harnesses: {pi: {tools: []}}},
|
|
570
|
+
});
|
|
571
|
+
const firstJob = await pendingJobFactory.create({
|
|
572
|
+
workspaceId,
|
|
573
|
+
requiredLabels: ['linux'],
|
|
574
|
+
});
|
|
575
|
+
const secondJob = await pendingJobFactory.create({
|
|
576
|
+
workspaceId,
|
|
577
|
+
requiredLabels: ['linux'],
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const firstClaim = await claimPendingJobExecution({
|
|
581
|
+
workspaceId,
|
|
582
|
+
runnerSessionId: underProvisionedRunner.id,
|
|
583
|
+
});
|
|
584
|
+
const secondClaim = await claimPendingJobExecution({
|
|
585
|
+
workspaceId,
|
|
586
|
+
runnerSessionId: matchingRunner.id,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
expect([firstClaim?.jobId, secondClaim?.jobId].sort()).toEqual(
|
|
590
|
+
[firstJob.jobId, secondJob.jobId].sort(),
|
|
591
|
+
);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('skips an older incompatible job and claims the oldest compatible job', async () => {
|
|
595
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['macos']});
|
|
596
|
+
const compatible = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
597
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
598
|
+
|
|
599
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
600
|
+
|
|
601
|
+
expect(claimed?.jobId).toBe(compatible.jobId);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('claims the older matching job before newer matching jobs', async () => {
|
|
605
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['macos']});
|
|
606
|
+
const olderMatching = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
607
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['x64']});
|
|
608
|
+
|
|
609
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
610
|
+
|
|
611
|
+
expect(claimed?.jobId).toBe(olderMatching.jobId);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('returns null when no compatible job is pending', async () => {
|
|
615
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['macos']});
|
|
616
|
+
|
|
617
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
618
|
+
|
|
619
|
+
expect(claimed).toBeNull();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('returns null for an empty session label set', async () => {
|
|
623
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
624
|
+
|
|
625
|
+
const claimed = await claimPendingJobExecution({
|
|
626
|
+
workspaceId,
|
|
627
|
+
runnerSessionId,
|
|
628
|
+
sessionLabels: [],
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
expect(claimed).toBeNull();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('claims the compatible row from a mixed-label queue', async () => {
|
|
635
|
+
for (let index = 0; index < 8; index += 1) {
|
|
636
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: [`gpu-${index}`]});
|
|
637
|
+
}
|
|
638
|
+
const compatible = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
639
|
+
|
|
640
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
641
|
+
|
|
642
|
+
expect(claimed?.jobId).toBe(compatible.jobId);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('does not claim jobs from another workspace', async () => {
|
|
646
|
+
await pendingJobFactory.create({workspaceId: crypto.randomUUID()});
|
|
647
|
+
|
|
648
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
649
|
+
|
|
650
|
+
expect(claimed).toBeNull();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('drops an orphan pending row whose job is already running, without a poison loop', async () => {
|
|
654
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
655
|
+
const first = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
656
|
+
if (!first) throw new Error('Expected pending job to be claimed');
|
|
657
|
+
expect(first.jobId).toBe(created.jobId);
|
|
658
|
+
|
|
659
|
+
// Simulate an enqueue retry that re-inserts a pending row after the claim.
|
|
660
|
+
await db().insert(pendingJobExecutions).values({
|
|
661
|
+
workspaceId,
|
|
662
|
+
workflowRunId: created.workflowRunId,
|
|
663
|
+
jobId: created.jobId,
|
|
664
|
+
jobExecutionId: first.jobExecutionId,
|
|
665
|
+
workflowRunAttemptId: created.workflowRunAttemptId,
|
|
666
|
+
projectId: created.projectId,
|
|
667
|
+
requiredLabels: created.requiredLabels,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const second = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
671
|
+
|
|
672
|
+
expect(second).toBeNull();
|
|
673
|
+
expect(
|
|
674
|
+
await db()
|
|
675
|
+
.select()
|
|
676
|
+
.from(pendingJobExecutions)
|
|
677
|
+
.where(eq(pendingJobExecutions.workspaceId, workspaceId)),
|
|
678
|
+
).toHaveLength(0);
|
|
679
|
+
const running = await db()
|
|
680
|
+
.select()
|
|
681
|
+
.from(runningJobExecutions)
|
|
682
|
+
.where(eq(runningJobExecutions.workspaceId, workspaceId));
|
|
683
|
+
expect(running).toHaveLength(1);
|
|
684
|
+
expect(running[0]?.jobId).toBe(created.jobId);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('leaves a non-matching orphan unclaimed until release sweeps it', async () => {
|
|
688
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
689
|
+
const first = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
690
|
+
if (!first) throw new Error('Expected pending job to be claimed');
|
|
691
|
+
expect(first.jobId).toBe(created.jobId);
|
|
692
|
+
await db().insert(pendingJobExecutions).values({
|
|
693
|
+
workspaceId,
|
|
694
|
+
workflowRunId: created.workflowRunId,
|
|
695
|
+
jobId: created.jobId,
|
|
696
|
+
jobExecutionId: first.jobExecutionId,
|
|
697
|
+
workflowRunAttemptId: created.workflowRunAttemptId,
|
|
698
|
+
projectId: created.projectId,
|
|
699
|
+
requiredLabels: created.requiredLabels,
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const second = await claimPendingJobExecution({
|
|
703
|
+
workspaceId,
|
|
704
|
+
runnerSessionId,
|
|
705
|
+
sessionLabels: ['macos'],
|
|
706
|
+
});
|
|
707
|
+
await releaseJobExecution({jobExecutionId: created.jobExecutionId});
|
|
708
|
+
|
|
709
|
+
expect(second).toBeNull();
|
|
710
|
+
expect(
|
|
711
|
+
await db()
|
|
712
|
+
.select()
|
|
713
|
+
.from(runningJobExecutions)
|
|
714
|
+
.where(eq(runningJobExecutions.workspaceId, workspaceId)),
|
|
715
|
+
).toHaveLength(0);
|
|
716
|
+
expect(
|
|
717
|
+
await db()
|
|
718
|
+
.select()
|
|
719
|
+
.from(pendingJobExecutions)
|
|
720
|
+
.where(eq(pendingJobExecutions.workspaceId, workspaceId)),
|
|
721
|
+
).toHaveLength(0);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('claims a real pending job ahead of a newer orphan', async () => {
|
|
725
|
+
const alreadyRunning = await pendingJobFactory.create({workspaceId});
|
|
726
|
+
const first = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
727
|
+
if (!first) throw new Error('Expected pending job to be claimed');
|
|
728
|
+
|
|
729
|
+
// A genuinely new pending job (older), then an orphan re-insert for the running job (newer).
|
|
730
|
+
const real = await pendingJobFactory.create({workspaceId});
|
|
731
|
+
await db().insert(pendingJobExecutions).values({
|
|
732
|
+
workspaceId,
|
|
733
|
+
workflowRunId: alreadyRunning.workflowRunId,
|
|
734
|
+
jobId: alreadyRunning.jobId,
|
|
735
|
+
jobExecutionId: first.jobExecutionId,
|
|
736
|
+
workflowRunAttemptId: alreadyRunning.workflowRunAttemptId,
|
|
737
|
+
projectId: alreadyRunning.projectId,
|
|
738
|
+
requiredLabels: alreadyRunning.requiredLabels,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
742
|
+
|
|
743
|
+
expect(claimed?.jobId).toBe(real.jobId);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
describe('claimJobExecution', () => {
|
|
748
|
+
let workspaceId: string;
|
|
749
|
+
let runnerSessionId: string;
|
|
750
|
+
|
|
751
|
+
beforeEach(async () => {
|
|
752
|
+
workspaceId = crypto.randomUUID();
|
|
753
|
+
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
754
|
+
runnerSessionId = runnerSession.id;
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('mints a lease token whose claims match the claimed job', async () => {
|
|
758
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
759
|
+
|
|
760
|
+
const claimed = await claimJobExecution({
|
|
761
|
+
workspaceId,
|
|
762
|
+
runnerSessionId,
|
|
763
|
+
sessionLabels,
|
|
764
|
+
maxClaims: null,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
expect(claimed).not.toBeNull();
|
|
768
|
+
expect(claimed?.jobId).toBe(created.jobId);
|
|
769
|
+
expect(claimed?.workflowRunAttemptId).toBe(created.workflowRunAttemptId);
|
|
770
|
+
expect(claimed).not.toHaveProperty('steps');
|
|
771
|
+
|
|
772
|
+
const claims = await verifyJobLeaseToken(claimed?.leaseToken as string);
|
|
773
|
+
expect(claims).toMatchObject({
|
|
774
|
+
jobId: created.jobId,
|
|
775
|
+
workflowRunAttemptId: created.workflowRunAttemptId,
|
|
776
|
+
projectId: created.projectId,
|
|
777
|
+
workspaceId,
|
|
778
|
+
runnerSessionId,
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('returns null and mints no token when the queue is empty', async () => {
|
|
783
|
+
const claimed = await claimJobExecution({
|
|
784
|
+
workspaceId,
|
|
785
|
+
runnerSessionId,
|
|
786
|
+
sessionLabels,
|
|
787
|
+
maxClaims: null,
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
expect(claimed).toBeNull();
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
describe('releaseJobExecution', () => {
|
|
795
|
+
let workspaceId: string;
|
|
796
|
+
let runnerSessionId: string;
|
|
797
|
+
|
|
798
|
+
beforeEach(async () => {
|
|
799
|
+
workspaceId = crypto.randomUUID();
|
|
800
|
+
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
801
|
+
runnerSessionId = runnerSession.id;
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('deletes the running row and writes no outbox event', async () => {
|
|
805
|
+
await pendingJobFactory.create({workspaceId});
|
|
806
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
807
|
+
const before = await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string);
|
|
808
|
+
|
|
809
|
+
await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
|
|
810
|
+
|
|
811
|
+
expect(
|
|
812
|
+
await db()
|
|
813
|
+
.select()
|
|
814
|
+
.from(runningJobExecutions)
|
|
815
|
+
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
816
|
+
).toHaveLength(0);
|
|
817
|
+
expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string)).toHaveLength(
|
|
818
|
+
before.length,
|
|
819
|
+
);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('is a no-op when the job is absent (idempotent)', async () => {
|
|
823
|
+
await expect(
|
|
824
|
+
releaseJobExecution({jobExecutionId: crypto.randomUUID()}),
|
|
825
|
+
).resolves.toBeUndefined();
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('releases regardless of which session holds the lease', async () => {
|
|
829
|
+
await pendingJobFactory.create({workspaceId});
|
|
830
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
831
|
+
|
|
832
|
+
// No token is passed: the workflow is authoritative over the lease.
|
|
833
|
+
await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
|
|
834
|
+
|
|
835
|
+
expect(
|
|
836
|
+
await db()
|
|
837
|
+
.select()
|
|
838
|
+
.from(runningJobExecutions)
|
|
839
|
+
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
840
|
+
).toHaveLength(0);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('also sweeps a lingering pending row for the same job', async () => {
|
|
844
|
+
await pendingJobFactory.create({workspaceId});
|
|
845
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
846
|
+
// An orphan pending row left by a post-claim enqueue retry.
|
|
847
|
+
await db()
|
|
848
|
+
.insert(pendingJobExecutions)
|
|
849
|
+
.values({
|
|
850
|
+
workspaceId,
|
|
851
|
+
workflowRunId: claimed?.workflowRunId as string,
|
|
852
|
+
jobId: claimed?.jobId as string,
|
|
853
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
854
|
+
workflowRunAttemptId: claimed?.workflowRunAttemptId as string,
|
|
855
|
+
projectId: claimed?.projectId as string,
|
|
856
|
+
requiredLabels: ['linux'],
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
|
|
860
|
+
|
|
861
|
+
expect(
|
|
862
|
+
await db()
|
|
863
|
+
.select()
|
|
864
|
+
.from(runningJobExecutions)
|
|
865
|
+
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
866
|
+
).toHaveLength(0);
|
|
867
|
+
expect(
|
|
868
|
+
await db()
|
|
869
|
+
.select()
|
|
870
|
+
.from(pendingJobExecutions)
|
|
871
|
+
.where(eq(pendingJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
872
|
+
).toHaveLength(0);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
describe('recordHeartbeat', () => {
|
|
877
|
+
let workspaceId: string;
|
|
878
|
+
let runnerSessionId: string;
|
|
879
|
+
|
|
880
|
+
beforeEach(async () => {
|
|
881
|
+
workspaceId = crypto.randomUUID();
|
|
882
|
+
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
883
|
+
runnerSessionId = runnerSession.id;
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('returns cancel:false on a fresh row and records the first heartbeat', async () => {
|
|
887
|
+
await pendingJobFactory.create({workspaceId});
|
|
888
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
889
|
+
|
|
890
|
+
const before = await db()
|
|
891
|
+
.select()
|
|
892
|
+
.from(runningJobExecutions)
|
|
893
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
894
|
+
// Force last_heartbeat_at into the past so we can observe the update.
|
|
895
|
+
await db()
|
|
896
|
+
.update(runningJobExecutions)
|
|
897
|
+
.set({lastHeartbeatAt: sql`now() - interval '1 hour'`})
|
|
898
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
899
|
+
|
|
900
|
+
const result = await recordHeartbeat({
|
|
901
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
902
|
+
runnerSessionId,
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
expect(result).toMatchObject({
|
|
906
|
+
cancellationRequested: false,
|
|
907
|
+
runningJobExecution: {
|
|
908
|
+
jobId: claimed?.jobId,
|
|
909
|
+
jobExecutionId: claimed?.jobExecutionId,
|
|
910
|
+
runnerSessionId,
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
const after = await db()
|
|
915
|
+
.select()
|
|
916
|
+
.from(runningJobExecutions)
|
|
917
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
918
|
+
expect(after[0]?.firstHeartbeatAt).toBeInstanceOf(Date);
|
|
919
|
+
expect(after[0]?.lastHeartbeatAt.getTime()).toBeGreaterThan(
|
|
920
|
+
(before[0]?.lastHeartbeatAt.getTime() ?? 0) - 1,
|
|
921
|
+
);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it('preserves first_heartbeat_at on later heartbeats', async () => {
|
|
925
|
+
await pendingJobFactory.create({workspaceId});
|
|
926
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
927
|
+
await recordHeartbeat({
|
|
928
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
929
|
+
runnerSessionId,
|
|
930
|
+
});
|
|
931
|
+
const [afterFirst] = await db()
|
|
932
|
+
.select()
|
|
933
|
+
.from(runningJobExecutions)
|
|
934
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
935
|
+
await db()
|
|
936
|
+
.update(runningJobExecutions)
|
|
937
|
+
.set({lastHeartbeatAt: sql`now() - interval '1 hour'`})
|
|
938
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
939
|
+
|
|
940
|
+
await recordHeartbeat({
|
|
941
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
942
|
+
runnerSessionId,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
const [afterSecond] = await db()
|
|
946
|
+
.select()
|
|
947
|
+
.from(runningJobExecutions)
|
|
948
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
949
|
+
expect(afterSecond?.firstHeartbeatAt?.getTime()).toBe(afterFirst?.firstHeartbeatAt?.getTime());
|
|
950
|
+
expect(afterSecond?.lastHeartbeatAt.getTime()).toBeGreaterThan(
|
|
951
|
+
afterFirst?.firstHeartbeatAt?.getTime() ?? 0,
|
|
952
|
+
);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it('returns cancel:true after requestJobExecutionCancellation', async () => {
|
|
956
|
+
await pendingJobFactory.create({workspaceId});
|
|
957
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
958
|
+
|
|
959
|
+
await requestJobExecutionCancellation({jobExecutionId: claimed?.jobExecutionId as string});
|
|
960
|
+
|
|
961
|
+
const result = await recordHeartbeat({
|
|
962
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
963
|
+
runnerSessionId,
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
expect(result).toMatchObject({
|
|
967
|
+
cancellationRequested: true,
|
|
968
|
+
runningJobExecution: {
|
|
969
|
+
jobId: claimed?.jobId,
|
|
970
|
+
jobExecutionId: claimed?.jobExecutionId,
|
|
971
|
+
runnerSessionId,
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('throws RunningJobExecutionNotFoundError when jobId is unknown', async () => {
|
|
977
|
+
await expect(
|
|
978
|
+
recordHeartbeat({jobExecutionId: crypto.randomUUID(), runnerSessionId}),
|
|
979
|
+
).rejects.toThrow('Running job execution not found');
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('throws when jobId belongs to a different session', async () => {
|
|
983
|
+
const otherRunnerSession = await runnerSessionFactory.create({workspaceId});
|
|
984
|
+
await pendingJobFactory.create({workspaceId});
|
|
985
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
986
|
+
|
|
987
|
+
await expect(
|
|
988
|
+
recordHeartbeat({
|
|
989
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
990
|
+
runnerSessionId: otherRunnerSession.id,
|
|
991
|
+
}),
|
|
992
|
+
).rejects.toThrow('Running job execution not found');
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
describe('requestJobExecutionCancellation', () => {
|
|
997
|
+
let workspaceId: string;
|
|
998
|
+
let runnerSessionId: string;
|
|
999
|
+
|
|
1000
|
+
beforeEach(async () => {
|
|
1001
|
+
workspaceId = crypto.randomUUID();
|
|
1002
|
+
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
1003
|
+
runnerSessionId = runnerSession.id;
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it('sets cancellation_requested_at on a fresh row', async () => {
|
|
1007
|
+
await pendingJobFactory.create({workspaceId});
|
|
1008
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1009
|
+
|
|
1010
|
+
await requestJobExecutionCancellation({jobExecutionId: claimed?.jobExecutionId as string});
|
|
1011
|
+
|
|
1012
|
+
const rows = await db()
|
|
1013
|
+
.select()
|
|
1014
|
+
.from(runningJobExecutions)
|
|
1015
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
1016
|
+
expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it('is idempotent: second call preserves the first timestamp', async () => {
|
|
1020
|
+
await pendingJobFactory.create({workspaceId});
|
|
1021
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1022
|
+
|
|
1023
|
+
await requestJobExecutionCancellation({jobExecutionId: claimed?.jobExecutionId as string});
|
|
1024
|
+
const after1 = await db()
|
|
1025
|
+
.select()
|
|
1026
|
+
.from(runningJobExecutions)
|
|
1027
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
1028
|
+
const firstTs = after1[0]?.cancellationRequestedAt;
|
|
1029
|
+
|
|
1030
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1031
|
+
await requestJobExecutionCancellation({jobExecutionId: claimed?.jobExecutionId as string});
|
|
1032
|
+
|
|
1033
|
+
const after2 = await db()
|
|
1034
|
+
.select()
|
|
1035
|
+
.from(runningJobExecutions)
|
|
1036
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
1037
|
+
expect(after2[0]?.cancellationRequestedAt?.getTime()).toBe(firstTs?.getTime());
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it('is a no-op when the job execution is missing (does not throw)', async () => {
|
|
1041
|
+
await expect(
|
|
1042
|
+
requestJobExecutionCancellation({jobExecutionId: crypto.randomUUID()}),
|
|
1043
|
+
).resolves.toBeUndefined();
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
describe('cancelRunnerJobs', () => {
|
|
1048
|
+
let workspaceId: string;
|
|
1049
|
+
let runnerSessionId: string;
|
|
1050
|
+
|
|
1051
|
+
beforeEach(async () => {
|
|
1052
|
+
workspaceId = crypto.randomUUID();
|
|
1053
|
+
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
1054
|
+
runnerSessionId = runnerSession.id;
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it('deletes queued jobs and requests cancellation for running jobs', async () => {
|
|
1058
|
+
const running = await pendingJobFactory.create({workspaceId});
|
|
1059
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1060
|
+
const queued = await pendingJobFactory.create({workspaceId});
|
|
1061
|
+
|
|
1062
|
+
await cancelRunnerJobs({jobIds: [queued.jobId, claimed?.jobId as string]});
|
|
1063
|
+
|
|
1064
|
+
expect(
|
|
1065
|
+
await db()
|
|
1066
|
+
.select()
|
|
1067
|
+
.from(pendingJobExecutions)
|
|
1068
|
+
.where(eq(pendingJobExecutions.workspaceId, workspaceId)),
|
|
1069
|
+
).toHaveLength(0);
|
|
1070
|
+
const rows = await db()
|
|
1071
|
+
.select()
|
|
1072
|
+
.from(runningJobExecutions)
|
|
1073
|
+
.where(eq(runningJobExecutions.workspaceId, workspaceId));
|
|
1074
|
+
expect(rows).toHaveLength(1);
|
|
1075
|
+
expect(rows[0]?.jobId).toBe(running.jobId);
|
|
1076
|
+
expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('is idempotent and no-ops for absent jobs', async () => {
|
|
1080
|
+
const queued = await pendingJobFactory.create({workspaceId});
|
|
1081
|
+
|
|
1082
|
+
await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
|
|
1083
|
+
await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
|
|
1084
|
+
|
|
1085
|
+
expect(
|
|
1086
|
+
await db()
|
|
1087
|
+
.select()
|
|
1088
|
+
.from(pendingJobExecutions)
|
|
1089
|
+
.where(eq(pendingJobExecutions.workspaceId, workspaceId)),
|
|
1090
|
+
).toHaveLength(0);
|
|
1091
|
+
expect(
|
|
1092
|
+
await db()
|
|
1093
|
+
.select()
|
|
1094
|
+
.from(runningJobExecutions)
|
|
1095
|
+
.where(eq(runningJobExecutions.workspaceId, workspaceId)),
|
|
1096
|
+
).toHaveLength(0);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it('prevents a cancelled queued job from being claimed', async () => {
|
|
1100
|
+
const queued = await pendingJobFactory.create({workspaceId});
|
|
1101
|
+
|
|
1102
|
+
await cancelRunnerJobs({jobIds: [queued.jobId]});
|
|
1103
|
+
|
|
1104
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1105
|
+
expect(claimed).toBeNull();
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
describe('detectAndExpireStuckJobs', () => {
|
|
1110
|
+
let workspaceId: string;
|
|
1111
|
+
let runnerSessionId: string;
|
|
1112
|
+
|
|
1113
|
+
beforeEach(async () => {
|
|
1114
|
+
workspaceId = crypto.randomUUID();
|
|
1115
|
+
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
1116
|
+
runnerSessionId = runnerSession.id;
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
async function makeStaleJob(staleSeconds: number): Promise<{
|
|
1120
|
+
jobId: string;
|
|
1121
|
+
jobExecutionId: string;
|
|
1122
|
+
workflowRunId: string;
|
|
1123
|
+
workflowRunAttemptId: string;
|
|
1124
|
+
projectId: string;
|
|
1125
|
+
}> {
|
|
1126
|
+
await pendingJobFactory.create({workspaceId});
|
|
1127
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1128
|
+
await db()
|
|
1129
|
+
.update(runningJobExecutions)
|
|
1130
|
+
.set({
|
|
1131
|
+
firstHeartbeatAt: sql`now() - (${staleSeconds} || ' seconds')::interval`,
|
|
1132
|
+
lastHeartbeatAt: sql`now() - (${staleSeconds} || ' seconds')::interval`,
|
|
1133
|
+
})
|
|
1134
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
1135
|
+
return {
|
|
1136
|
+
jobId: claimed?.jobId as string,
|
|
1137
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
1138
|
+
workflowRunId: claimed?.workflowRunId as string,
|
|
1139
|
+
workflowRunAttemptId: claimed?.workflowRunAttemptId as string,
|
|
1140
|
+
projectId: claimed?.projectId as string,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
async function makeNoFirstHeartbeatJob(ageSeconds: number): Promise<{
|
|
1145
|
+
jobId: string;
|
|
1146
|
+
jobExecutionId: string;
|
|
1147
|
+
workflowRunId: string;
|
|
1148
|
+
workflowRunAttemptId: string;
|
|
1149
|
+
projectId: string;
|
|
1150
|
+
}> {
|
|
1151
|
+
await pendingJobFactory.create({workspaceId});
|
|
1152
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1153
|
+
await db()
|
|
1154
|
+
.update(runningJobExecutions)
|
|
1155
|
+
.set({
|
|
1156
|
+
startedAt: sql`now() - (${ageSeconds} || ' seconds')::interval`,
|
|
1157
|
+
lastHeartbeatAt: sql`now() - (${ageSeconds} || ' seconds')::interval`,
|
|
1158
|
+
})
|
|
1159
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
1160
|
+
return {
|
|
1161
|
+
jobId: claimed?.jobId as string,
|
|
1162
|
+
jobExecutionId: claimed?.jobExecutionId as string,
|
|
1163
|
+
workflowRunId: claimed?.workflowRunId as string,
|
|
1164
|
+
workflowRunAttemptId: claimed?.workflowRunAttemptId as string,
|
|
1165
|
+
projectId: claimed?.projectId as string,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function runningJobsForTest() {
|
|
1170
|
+
return await db()
|
|
1171
|
+
.select()
|
|
1172
|
+
.from(runningJobExecutions)
|
|
1173
|
+
.where(eq(runningJobExecutions.workspaceId, workspaceId));
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function outboxForJobs(jobIds: string[]) {
|
|
1177
|
+
const all = await db().select().from(runnersOutbox);
|
|
1178
|
+
return all.filter((row) => {
|
|
1179
|
+
// The same job ids can also have queued and claimed events from setup.
|
|
1180
|
+
if (row.eventType !== RUNNER_JOB_LEASE_EXPIRED) return false;
|
|
1181
|
+
const payload = row.payload as {jobId?: string};
|
|
1182
|
+
return payload.jobId !== undefined && jobIds.includes(payload.jobId);
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
it('expires a stuck job and writes a runners.job.lease_expired event', async () => {
|
|
1187
|
+
const {jobId, workflowRunId, workflowRunAttemptId} = await makeStaleJob(600);
|
|
1188
|
+
|
|
1189
|
+
const result = await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1190
|
+
|
|
1191
|
+
expect(result.expired).toBeGreaterThanOrEqual(1);
|
|
1192
|
+
expect(await runningJobsForTest()).toHaveLength(0);
|
|
1193
|
+
|
|
1194
|
+
const outbox = await outboxForJobs([jobId]);
|
|
1195
|
+
expect(outbox).toHaveLength(1);
|
|
1196
|
+
expect(outbox[0]?.eventType).toBe(RUNNER_JOB_LEASE_EXPIRED);
|
|
1197
|
+
const payload = outbox[0]?.payload as Record<string, unknown>;
|
|
1198
|
+
expect(payload.jobId).toBe(jobId);
|
|
1199
|
+
expect(payload.workflowRunId).toBe(workflowRunId);
|
|
1200
|
+
expect(payload.workflowRunAttemptId).toBe(workflowRunAttemptId);
|
|
1201
|
+
// The lease-expired event carries only the assignment identifiers.
|
|
1202
|
+
expect(payload.status).toBeUndefined();
|
|
1203
|
+
expect(payload.steps).toBeUndefined();
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it('expires a job that never sent a first heartbeat after the startup grace', async () => {
|
|
1207
|
+
const {jobId, workflowRunId, workflowRunAttemptId} = await makeNoFirstHeartbeatJob(90);
|
|
1208
|
+
|
|
1209
|
+
const result = await detectAndExpireStuckJobs({
|
|
1210
|
+
noFirstHeartbeatGraceSeconds: 60,
|
|
1211
|
+
thresholdSeconds: 180,
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
expect(result.expired).toBeGreaterThanOrEqual(1);
|
|
1215
|
+
expect(await runningJobsForTest()).toHaveLength(0);
|
|
1216
|
+
|
|
1217
|
+
const outbox = await outboxForJobs([jobId]);
|
|
1218
|
+
expect(outbox).toHaveLength(1);
|
|
1219
|
+
const payload = outbox[0]?.payload as Record<string, unknown>;
|
|
1220
|
+
expect(payload.jobId).toBe(jobId);
|
|
1221
|
+
expect(payload.workflowRunId).toBe(workflowRunId);
|
|
1222
|
+
expect(payload.workflowRunAttemptId).toBe(workflowRunAttemptId);
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
it('does not expire a job that is still inside the first heartbeat grace', async () => {
|
|
1226
|
+
const {jobId} = await makeNoFirstHeartbeatJob(30);
|
|
1227
|
+
|
|
1228
|
+
await detectAndExpireStuckJobs({
|
|
1229
|
+
noFirstHeartbeatGraceSeconds: 60,
|
|
1230
|
+
thresholdSeconds: 180,
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
expect(await runningJobsForTest()).toHaveLength(1);
|
|
1234
|
+
expect(await outboxForJobs([jobId])).toHaveLength(0);
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it('does not expire a heartbeated job through the first heartbeat grace path', async () => {
|
|
1238
|
+
const {jobId} = await makeStaleJob(90);
|
|
1239
|
+
|
|
1240
|
+
await detectAndExpireStuckJobs({
|
|
1241
|
+
noFirstHeartbeatGraceSeconds: 60,
|
|
1242
|
+
thresholdSeconds: 180,
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
expect(await runningJobsForTest()).toHaveLength(1);
|
|
1246
|
+
expect(await outboxForJobs([jobId])).toHaveLength(0);
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
it('uses the stale-heartbeat threshold for upgraded rows that heartbeated before first heartbeat tracking', async () => {
|
|
1250
|
+
await pendingJobFactory.create({workspaceId});
|
|
1251
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1252
|
+
await db()
|
|
1253
|
+
.update(runningJobExecutions)
|
|
1254
|
+
.set({
|
|
1255
|
+
startedAt: sql`now() - interval '90 seconds'`,
|
|
1256
|
+
firstHeartbeatAt: null,
|
|
1257
|
+
lastHeartbeatAt: sql`now() - interval '30 seconds'`,
|
|
1258
|
+
})
|
|
1259
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
1260
|
+
|
|
1261
|
+
await detectAndExpireStuckJobs({
|
|
1262
|
+
noFirstHeartbeatGraceSeconds: 60,
|
|
1263
|
+
thresholdSeconds: 180,
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
expect(await runningJobsForTest()).toHaveLength(1);
|
|
1267
|
+
expect(await outboxForJobs([claimed?.jobId as string])).toHaveLength(0);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
it('expires upgraded heartbeated rows only after their stale-heartbeat threshold', async () => {
|
|
1271
|
+
await pendingJobFactory.create({workspaceId});
|
|
1272
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1273
|
+
await db()
|
|
1274
|
+
.update(runningJobExecutions)
|
|
1275
|
+
.set({
|
|
1276
|
+
startedAt: sql`now() - interval '900 seconds'`,
|
|
1277
|
+
firstHeartbeatAt: null,
|
|
1278
|
+
lastHeartbeatAt: sql`now() - interval '600 seconds'`,
|
|
1279
|
+
})
|
|
1280
|
+
.where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
|
|
1281
|
+
|
|
1282
|
+
await detectAndExpireStuckJobs({
|
|
1283
|
+
noFirstHeartbeatGraceSeconds: 60,
|
|
1284
|
+
thresholdSeconds: 180,
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
expect(await runningJobsForTest()).toHaveLength(0);
|
|
1288
|
+
expect(await outboxForJobs([claimed?.jobId as string])).toHaveLength(1);
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
it('does not expire a job whose heartbeat is still inside the threshold window', async () => {
|
|
1292
|
+
const {jobId} = await makeStaleJob(60);
|
|
1293
|
+
|
|
1294
|
+
await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1295
|
+
|
|
1296
|
+
expect(await runningJobsForTest()).toHaveLength(1);
|
|
1297
|
+
expect(await outboxForJobs([jobId])).toHaveLength(0);
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
it('only expires the stuck rows in a mixed batch', async () => {
|
|
1301
|
+
const stuck1 = await makeStaleJob(600);
|
|
1302
|
+
const stuck2 = await makeStaleJob(600);
|
|
1303
|
+
const fresh = await makeStaleJob(30);
|
|
1304
|
+
|
|
1305
|
+
await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1306
|
+
|
|
1307
|
+
const remaining = await runningJobsForTest();
|
|
1308
|
+
expect(remaining.map((r) => r.jobId)).toEqual([fresh.jobId]);
|
|
1309
|
+
expect(await outboxForJobs([stuck1.jobId, stuck2.jobId, fresh.jobId])).toHaveLength(2);
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
it('returns zero when there are no stuck jobs', async () => {
|
|
1313
|
+
const result = await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1314
|
+
expect(result.expired).toBe(0);
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
it('skips a row whose heartbeat refreshed before the atomic DELETE re-evaluates the predicate', async () => {
|
|
1318
|
+
// Pre-stale, then refresh, then run — the cutoff is folded into the DELETE's
|
|
1319
|
+
// WHERE so the live row survives even though the iteration SELECT saw it stale.
|
|
1320
|
+
const {jobId} = await makeStaleJob(600);
|
|
1321
|
+
await db()
|
|
1322
|
+
.update(runningJobExecutions)
|
|
1323
|
+
.set({
|
|
1324
|
+
firstHeartbeatAt: sql`COALESCE(${runningJobExecutions.firstHeartbeatAt}, now())`,
|
|
1325
|
+
lastHeartbeatAt: sql`now()`,
|
|
1326
|
+
})
|
|
1327
|
+
.where(eq(runningJobExecutions.jobId, jobId));
|
|
1328
|
+
|
|
1329
|
+
await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1330
|
+
|
|
1331
|
+
expect(await runningJobsForTest()).toHaveLength(1);
|
|
1332
|
+
expect(await outboxForJobs([jobId])).toHaveLength(0);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
it('double-expiring the same stuck job emits exactly one event', async () => {
|
|
1336
|
+
const {jobId} = await makeStaleJob(600);
|
|
1337
|
+
|
|
1338
|
+
await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1339
|
+
await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1340
|
+
|
|
1341
|
+
expect(
|
|
1342
|
+
await db().select().from(runningJobExecutions).where(eq(runningJobExecutions.jobId, jobId)),
|
|
1343
|
+
).toHaveLength(0);
|
|
1344
|
+
expect(await outboxForJobs([jobId])).toHaveLength(1);
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it('sweeps an orphan pending row for the job it reaps (best-effort release may have failed)', async () => {
|
|
1348
|
+
const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
|
|
1349
|
+
await makeStaleJob(600);
|
|
1350
|
+
// A post-claim enqueue retry left a pending row whose job is already running;
|
|
1351
|
+
// without this sweep it would stay re-claimable for an already-finished job.
|
|
1352
|
+
await db()
|
|
1353
|
+
.insert(pendingJobExecutions)
|
|
1354
|
+
.values({
|
|
1355
|
+
workspaceId,
|
|
1356
|
+
workflowRunId,
|
|
1357
|
+
jobId,
|
|
1358
|
+
jobExecutionId,
|
|
1359
|
+
workflowRunAttemptId,
|
|
1360
|
+
projectId,
|
|
1361
|
+
requiredLabels: ['linux'],
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1365
|
+
|
|
1366
|
+
expect(await runningJobsForTest()).toHaveLength(0);
|
|
1367
|
+
expect(
|
|
1368
|
+
await db().select().from(pendingJobExecutions).where(eq(pendingJobExecutions.jobId, jobId)),
|
|
1369
|
+
).toHaveLength(0);
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
it('leaves the orphan pending row alone when the running row is not stale enough to reap', async () => {
|
|
1373
|
+
const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
|
|
1374
|
+
await makeStaleJob(60);
|
|
1375
|
+
await db()
|
|
1376
|
+
.insert(pendingJobExecutions)
|
|
1377
|
+
.values({
|
|
1378
|
+
workspaceId,
|
|
1379
|
+
workflowRunId,
|
|
1380
|
+
jobId,
|
|
1381
|
+
jobExecutionId,
|
|
1382
|
+
workflowRunAttemptId,
|
|
1383
|
+
projectId,
|
|
1384
|
+
requiredLabels: ['linux'],
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1388
|
+
|
|
1389
|
+
// The sweep is gated on actually reaping a running row, so a live job's
|
|
1390
|
+
// pending row is untouched.
|
|
1391
|
+
expect(
|
|
1392
|
+
await db().select().from(pendingJobExecutions).where(eq(pendingJobExecutions.jobId, jobId)),
|
|
1393
|
+
).toHaveLength(1);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
it('returns the reaped workflow/job identifiers per row without leaking the internal id', async () => {
|
|
1397
|
+
const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId} = await makeStaleJob(600);
|
|
1398
|
+
|
|
1399
|
+
const reaped = await expireStuckJobExecutions({
|
|
1400
|
+
noFirstHeartbeatGraceSeconds: 60,
|
|
1401
|
+
thresholdSeconds: 180,
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
const mine = reaped.find((row) => row.jobId === jobId);
|
|
1405
|
+
expect(mine).toEqual({jobId, jobExecutionId, workflowRunId, workflowRunAttemptId});
|
|
1406
|
+
expect(mine).not.toHaveProperty('id');
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
it('writes one lease_expired event per reaped job in a single bulk insert', async () => {
|
|
1410
|
+
const stuck1 = await makeStaleJob(600);
|
|
1411
|
+
const stuck2 = await makeStaleJob(600);
|
|
1412
|
+
|
|
1413
|
+
await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1414
|
+
|
|
1415
|
+
const outbox = await outboxForJobs([stuck1.jobId, stuck2.jobId]);
|
|
1416
|
+
expect(outbox).toHaveLength(2);
|
|
1417
|
+
expect(outbox.every((row) => row.eventType === RUNNER_JOB_LEASE_EXPIRED)).toBe(true);
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it('two concurrent ticks reap each stuck job exactly once (no double-emit)', async () => {
|
|
1421
|
+
const stuck1 = await makeStaleJob(600);
|
|
1422
|
+
const stuck2 = await makeStaleJob(600);
|
|
1423
|
+
|
|
1424
|
+
await Promise.all([
|
|
1425
|
+
detectAndExpireStuckJobs({thresholdSeconds: 180}),
|
|
1426
|
+
detectAndExpireStuckJobs({thresholdSeconds: 180}),
|
|
1427
|
+
]);
|
|
1428
|
+
|
|
1429
|
+
expect(await runningJobsForTest()).toHaveLength(0);
|
|
1430
|
+
expect(await outboxForJobs([stuck1.jobId, stuck2.jobId])).toHaveLength(2);
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
it('a reaper tick and a concurrent claim of the same orphan-pending job leave consistent state', async () => {
|
|
1434
|
+
const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
|
|
1435
|
+
await makeStaleJob(600);
|
|
1436
|
+
// Orphan pending row from a post-claim enqueue retry for an already-running job.
|
|
1437
|
+
await db()
|
|
1438
|
+
.insert(pendingJobExecutions)
|
|
1439
|
+
.values({
|
|
1440
|
+
workspaceId,
|
|
1441
|
+
workflowRunId,
|
|
1442
|
+
jobId,
|
|
1443
|
+
jobExecutionId,
|
|
1444
|
+
workflowRunAttemptId,
|
|
1445
|
+
projectId,
|
|
1446
|
+
requiredLabels: ['linux'],
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
// The reaper locks running-then-pending while the claim locks pending-then-running;
|
|
1450
|
+
// a deadlock loser rolls back, so either side may settle as rejected.
|
|
1451
|
+
await Promise.allSettled([
|
|
1452
|
+
detectAndExpireStuckJobs({thresholdSeconds: 180}),
|
|
1453
|
+
claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null}),
|
|
1454
|
+
]);
|
|
1455
|
+
|
|
1456
|
+
// A follow-up tick finishes any reap that lost a deadlock race.
|
|
1457
|
+
await detectAndExpireStuckJobs({thresholdSeconds: 180});
|
|
1458
|
+
|
|
1459
|
+
// The expired job is gone and not re-claimable; its orphan pending row is swept.
|
|
1460
|
+
expect(await runningJobsForTest()).toHaveLength(0);
|
|
1461
|
+
expect(
|
|
1462
|
+
await db().select().from(pendingJobExecutions).where(eq(pendingJobExecutions.jobId, jobId)),
|
|
1463
|
+
).toHaveLength(0);
|
|
1464
|
+
expect(
|
|
1465
|
+
await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null}),
|
|
1466
|
+
).toBeNull();
|
|
1467
|
+
});
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
describe('getJobExecutionQueueDepth', () => {
|
|
1471
|
+
let workspaceId: string;
|
|
1472
|
+
let runnerSessionId: string;
|
|
1473
|
+
|
|
1474
|
+
beforeEach(async () => {
|
|
1475
|
+
workspaceId = crypto.randomUUID();
|
|
1476
|
+
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
1477
|
+
runnerSessionId = runnerSession.id;
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it('reports queue depth counters', async () => {
|
|
1481
|
+
const depth = await getJobExecutionQueueDepth();
|
|
1482
|
+
|
|
1483
|
+
expect(depth.pendingJobExecutions).toBeGreaterThanOrEqual(0);
|
|
1484
|
+
expect(depth.runningJobExecutions).toBeGreaterThanOrEqual(0);
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
it('counts pending and running jobs separately', async () => {
|
|
1488
|
+
const baseline = await getJobExecutionQueueDepth();
|
|
1489
|
+
await pendingJobFactory.create({workspaceId});
|
|
1490
|
+
await pendingJobFactory.create({workspaceId});
|
|
1491
|
+
await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1492
|
+
|
|
1493
|
+
const depth = await getJobExecutionQueueDepth();
|
|
1494
|
+
|
|
1495
|
+
expect(depth).toEqual({
|
|
1496
|
+
pendingJobExecutions: baseline.pendingJobExecutions + 1,
|
|
1497
|
+
runningJobExecutions: baseline.runningJobExecutions + 1,
|
|
1498
|
+
});
|
|
1499
|
+
});
|
|
1500
|
+
});
|