@pikku/core 0.12.88 → 0.12.90

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/dist/services/http-personas.js +8 -0
  3. package/dist/types/core.types.d.ts +8 -1
  4. package/dist/wirings/rpc/rpc-runner.js +5 -6
  5. package/dist/wirings/virtual-user/index.d.ts +3 -1
  6. package/dist/wirings/virtual-user/index.js +1 -0
  7. package/dist/wirings/virtual-user/virtual-user-agents.d.ts +7 -2
  8. package/dist/wirings/virtual-user/virtual-user-agents.js +8 -2
  9. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +32 -1
  10. package/dist/wirings/virtual-user/virtual-user-schedule-store.d.ts +89 -0
  11. package/dist/wirings/virtual-user/virtual-user-schedule-store.js +1 -0
  12. package/dist/wirings/virtual-user/virtual-user-schedule.d.ts +71 -0
  13. package/dist/wirings/virtual-user/virtual-user-schedule.js +101 -0
  14. package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -2
  15. package/dist/wirings/workflow/pikku-workflow-service.js +7 -4
  16. package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
  17. package/dist/wirings/workflow/workflow-constants.js +17 -0
  18. package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
  19. package/dist/wirings/workflow/workflow-recovery.js +30 -2
  20. package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +66 -0
  21. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +8 -3
  22. package/knowledge/decisions/internals/index.md +1 -0
  23. package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +7 -6
  24. package/package.json +1 -1
  25. package/src/app-leaf-surface.test.ts +2 -2
  26. package/src/ecosystem-tier-removed.test.ts +69 -0
  27. package/src/public-surface.json +6 -0
  28. package/src/services/http-personas-converse.test.ts +16 -2
  29. package/src/services/http-personas.ts +8 -0
  30. package/src/types/core.types.ts +8 -1
  31. package/src/wirings/rpc/rpc-runner.test.ts +106 -1
  32. package/src/wirings/rpc/rpc-runner.ts +9 -6
  33. package/src/wirings/virtual-user/index.ts +18 -0
  34. package/src/wirings/virtual-user/virtual-user-agents.test.ts +8 -4
  35. package/src/wirings/virtual-user/virtual-user-agents.ts +8 -3
  36. package/src/wirings/virtual-user/virtual-user-run-store.ts +33 -0
  37. package/src/wirings/virtual-user/virtual-user-schedule-store.ts +93 -0
  38. package/src/wirings/virtual-user/virtual-user-schedule.test.ts +280 -0
  39. package/src/wirings/virtual-user/virtual-user-schedule.ts +156 -0
  40. package/src/wirings/workflow/pikku-workflow-service.ts +6 -2
  41. package/src/wirings/workflow/workflow-constants.ts +19 -0
  42. package/src/wirings/workflow/workflow-recovery.ts +31 -1
  43. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
  44. package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,156 @@
1
+ import type { VirtualUserRunStore } from './virtual-user-run-store.js'
2
+ import type {
3
+ VirtualUserScheduleRecord,
4
+ VirtualUserScheduleStore,
5
+ } from './virtual-user-schedule-store.js'
6
+
7
+ /**
8
+ * How long a run may sit at `running` before it is read as dead rather than
9
+ * busy.
10
+ *
11
+ * A run holds no process across a restart — see {@link VirtualUserRunRecord} —
12
+ * so a deploy mid-run strands the record, and a stranded record would block its
13
+ * persona's schedule for good. Twice the longest duration budget anyone sets in
14
+ * practice, because the failure this guards against is cheap to recover from
15
+ * and expensive to trigger early: reaping a run that was still working loses
16
+ * its findings.
17
+ */
18
+ export const STALE_RUN_AFTER_MS = 2 * 60 * 60 * 1000
19
+
20
+ /**
21
+ * The cadence a schedule gets when it is written without one: roughly a run a
22
+ * day, at an hour nobody can predict.
23
+ *
24
+ * Sparse on purpose. Every tick spends model budget with no caller present to
25
+ * notice, so the default is the one an app can leave switched on and forget,
26
+ * not the one that finds the most.
27
+ */
28
+ export const DEFAULT_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000
29
+ export const DEFAULT_MAX_INTERVAL_MS = 24 * 60 * 60 * 1000
30
+
31
+ /** Whether a row is the tick's business, for stores that cannot ask in a query. */
32
+ export const isDue = (schedule: VirtualUserScheduleRecord, now: Date) =>
33
+ schedule.enabled && schedule.nextRunAt.getTime() <= now.getTime()
34
+
35
+ /**
36
+ * When this persona should next appear, drawn from its own interval.
37
+ *
38
+ * Uniform between the two bounds. Reversed bounds are read as a range rather
39
+ * than rejected, because a schedule is configuration and a swapped pair is a
40
+ * typo, not an attack.
41
+ */
42
+ export const nextRunAt = (
43
+ schedule: Pick<VirtualUserScheduleRecord, 'minIntervalMs' | 'maxIntervalMs'>,
44
+ now: Date,
45
+ random: () => number
46
+ ) => {
47
+ const low = Math.max(
48
+ 0,
49
+ Math.min(schedule.minIntervalMs, schedule.maxIntervalMs)
50
+ )
51
+ const high = Math.max(
52
+ 0,
53
+ Math.max(schedule.minIntervalMs, schedule.maxIntervalMs)
54
+ )
55
+ return new Date(now.getTime() + low + random() * (high - low))
56
+ }
57
+
58
+ export type VirtualUserSkipReason =
59
+ 'in-flight' | 'dispatch-failed' | 'claimed-elsewhere'
60
+
61
+ export interface VirtualUserTickResult {
62
+ dispatched: { persona: string; runId: string }[]
63
+ skipped: { persona: string; reason: VirtualUserSkipReason }[]
64
+ /** Runs found stranded at `running` and marked failed. */
65
+ reaped: string[]
66
+ }
67
+
68
+ export interface VirtualUserTickParams {
69
+ schedules: VirtualUserScheduleStore
70
+ runs: VirtualUserRunStore
71
+ /** Starts one run and answers with its id. Nothing here knows how. */
72
+ dispatch: (schedule: VirtualUserScheduleRecord) => Promise<string>
73
+ now?: Date
74
+ random?: () => number
75
+ staleAfterMs?: number
76
+ }
77
+
78
+ /**
79
+ * Acts on whichever personas are due, once.
80
+ *
81
+ * The whole cadence lives in this one call, so what schedules it is the host's
82
+ * choice — a cron wiring, a platform scheduler, or a person clicking a button.
83
+ * Pikku does not start a timer on an app's behalf; a scaffold that did would
84
+ * begin spending model budget the moment a project ran `pikku all`.
85
+ *
86
+ * A persona is skipped, not queued, while its previous run is still going. Two
87
+ * copies of the same user acting at once is not a heavier test, it is a
88
+ * different one, and every finding it produces is unreproducible. Two ticks
89
+ * running at once are held to the same rule by the claim, which only lands for
90
+ * whichever of them still sees the `nextRunAt` it read.
91
+ */
92
+ export const tickVirtualUserSchedules = async ({
93
+ schedules,
94
+ runs,
95
+ dispatch,
96
+ now = new Date(),
97
+ random = Math.random,
98
+ staleAfterMs = STALE_RUN_AFTER_MS,
99
+ }: VirtualUserTickParams): Promise<VirtualUserTickResult> => {
100
+ const result: VirtualUserTickResult = {
101
+ dispatched: [],
102
+ skipped: [],
103
+ reaped: [],
104
+ }
105
+
106
+ for (const schedule of await schedules.due(now)) {
107
+ const [latest] = await runs.list({ persona: schedule.persona, limit: 1 })
108
+ if (latest?.status === 'running') {
109
+ if (now.getTime() - latest.createdAt.getTime() < staleAfterMs) {
110
+ result.skipped.push({ persona: schedule.persona, reason: 'in-flight' })
111
+ continue
112
+ }
113
+ await runs.fail(
114
+ latest.runId,
115
+ `Abandoned: still running ${Math.round((now.getTime() - latest.createdAt.getTime()) / 60000)}m after it started, which is longer than any budget allows.`
116
+ )
117
+ result.reaped.push(latest.runId)
118
+ }
119
+
120
+ const due = nextRunAt(schedule, now, random)
121
+ const acquired = await schedules.claim(schedule.persona, {
122
+ from: schedule.nextRunAt,
123
+ nextRunAt: due,
124
+ runId: null,
125
+ at: now,
126
+ })
127
+ if (!acquired) {
128
+ result.skipped.push({
129
+ persona: schedule.persona,
130
+ reason: 'claimed-elsewhere',
131
+ })
132
+ continue
133
+ }
134
+
135
+ let runId: string
136
+ try {
137
+ runId = await dispatch(schedule)
138
+ } catch {
139
+ result.skipped.push({
140
+ persona: schedule.persona,
141
+ reason: 'dispatch-failed',
142
+ })
143
+ continue
144
+ }
145
+
146
+ await schedules.claim(schedule.persona, {
147
+ from: due,
148
+ nextRunAt: due,
149
+ runId,
150
+ at: now,
151
+ })
152
+ result.dispatched.push({ persona: schedule.persona, runId })
153
+ }
154
+
155
+ return result
156
+ }
@@ -59,6 +59,7 @@ import {
59
59
  WORKFLOW_POLL_FACTOR,
60
60
  WORKFLOW_POLL_MIN_MS,
61
61
  WORKFLOW_TERMINAL_STATES,
62
+ isRunSettled,
62
63
  } from './workflow-constants.js'
63
64
  import {
64
65
  WorkflowAsyncException,
@@ -643,8 +644,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
643
644
  * overriding this, or no concurrency for one to exclude: the relay makes
644
645
  * duplicate dispatch routine, and the claim is what keeps a duplicate from
645
646
  * becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
646
- * status-guarded claim, `in-memory` on being inline and single-process;
647
- * `mongodb` still qualifies on neither.
647
+ * status-guarded claim, `mongodb` on the same claim expressed as a
648
+ * single-document update, `in-memory` on being inline and single-process —
649
+ * none of them overrides this yet.
648
650
  */
649
651
  protected async findUndispatchedSteps(
650
652
  _before: Date,
@@ -672,6 +674,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
672
674
  }): Promise<{ resumed: string[] }> {
673
675
  return sweepStalledRuns(
674
676
  (before, limit) => this.findStalledRunIds(before, limit),
677
+ this.redispatchBackoff,
675
678
  options,
676
679
  this.sweepDeps
677
680
  )
@@ -1084,6 +1087,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1084
1087
  if (!run) {
1085
1088
  throw new WorkflowRunNotFoundError(runId)
1086
1089
  }
1090
+ if (isRunSettled(run.status)) return
1087
1091
 
1088
1092
  const resolved = resolveWorkflowMeta(run.workflow)
1089
1093
  const workflowMeta = resolved?.meta
@@ -15,6 +15,25 @@ export const WORKFLOW_TERMINAL_STATES: ReadonlySet<string> = new Set([
15
15
  'cancelled',
16
16
  ])
17
17
 
18
+ /**
19
+ * True for a run that will never move again, whatever arrives for it.
20
+ *
21
+ * Worth checking before doing anything with an orchestrator message, because
22
+ * such a message is routine rather than exceptional: the queue is
23
+ * at-least-once, the relay re-dispatches on purpose, and a run can settle
24
+ * while a message for it is still in flight. Replaying one is not free —
25
+ * `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
26
+ * body re-entered after its run failed can park on a wait that nothing will
27
+ * ever satisfy, holding the lock and the connection under it until something
28
+ * external gives up. Every leaked advisory lock seen in production traced back
29
+ * to that: a granted lock, an idle session, and a run already `failed`.
30
+ *
31
+ * Note `suspended` is deliberately absent. It ends a run's *current* pass but
32
+ * not the run, which resumes when its approval or signal arrives.
33
+ */
34
+ export const isRunSettled = (status: string): boolean =>
35
+ WORKFLOW_TERMINAL_STATES.has(status)
36
+
18
37
  export const WORKFLOW_POLL_MIN_MS = 10
19
38
 
20
39
  export const WORKFLOW_POLL_FACTOR = 1.6
@@ -17,6 +17,15 @@ import {
17
17
  * owed a job, so holding off a single step while resuming its run would
18
18
  * suppress nothing.
19
19
  *
20
+ * For the same reason one instance is shared by every sweep rather than kept
21
+ * per sweep. The record is of the action, not of the signal that prompted it:
22
+ * a stalled run and an undispatched step are different observations, but both
23
+ * are answered by the one orchestrator message, so a run the relay re-drove a
24
+ * moment ago gains nothing from the stalled sweep re-driving it again. Sharing
25
+ * is what makes the guarantee a message-per-run-per-window instead of one per
26
+ * sweep, and no recovery is lost by it: whichever sweep gets there first
27
+ * performs the identical re-drive, and the cap keeps the delay at 10m.
28
+ *
20
29
  * Losing this on restart costs extra dispatches, never correctness.
21
30
  */
22
31
  export class RedispatchBackoff {
@@ -88,19 +97,40 @@ const resumeEach = async (
88
97
  * actually stuck costs an orchestration pass and changes nothing. That
89
98
  * idempotence is what makes an idle-time heuristic safe here; a run that is
90
99
  * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
100
+ *
101
+ * Idempotent is not free, though: a run stays stalled until something clears
102
+ * the reason it stalled, so an unconditional sweep re-queues the same runs on
103
+ * every tick forever. That is how a handful of wedged runs became a queue of
104
+ * thousands that could not drain — each pass added work the previous pass had
105
+ * not finished. The same per-run backoff the relay uses bounds it: a run is
106
+ * re-driven, then held off for a doubling delay, so a sweep costs at most one
107
+ * message per run per window rather than one per run per tick.
91
108
  */
92
109
  export const sweepStalledRuns = async (
93
110
  findStalledRunIds: (before: Date, limit: number) => Promise<string[]>,
111
+ backoff: RedispatchBackoff,
94
112
  options: { stalledAfterMs?: number; limit?: number } | undefined,
95
113
  deps: SweepDeps
96
114
  ): Promise<{ resumed: string[] }> => {
97
115
  const before = new Date(
98
116
  Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS)
99
117
  )
100
- const runIds = await findStalledRunIds(
118
+ const found = await findStalledRunIds(
101
119
  before,
102
120
  options?.limit ?? DEFAULT_STALLED_RUN_LIMIT
103
121
  )
122
+
123
+ const now = Date.now()
124
+ const runIds: string[] = []
125
+ for (const runId of found) {
126
+ if (!backoff.isEligible(runId, now)) continue
127
+ // Noted before the resume, not after: a run whose resume throws is exactly
128
+ // the run most likely to still be here next tick, and re-driving it every
129
+ // tick is the amplification this backoff exists to stop.
130
+ backoff.note(runId, now)
131
+ runIds.push(runId)
132
+ }
133
+
104
134
  return {
105
135
  resumed: await resumeEach(
106
136
  runIds,
@@ -89,6 +89,52 @@ describe('stalled run recovery', () => {
89
89
  assert.deepEqual(resumed, [], 'a run that just moved is not swept')
90
90
  })
91
91
 
92
+ test('backs off rather than re-resuming the same run every tick', async () => {
93
+ const resumes = trackResumes()
94
+ const ws = new InMemoryWorkflowService()
95
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
96
+ type: 'test',
97
+ })
98
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
99
+ await backdate(ws, runId, 10 * 60_000)
100
+
101
+ const first = await ws.recoverStalledRuns()
102
+ // Still stalled: a resume does not clear whatever wedged the run, so an
103
+ // unconditional sweep would re-queue it on this tick and on every tick
104
+ // after it.
105
+ const second = await ws.recoverStalledRuns()
106
+
107
+ assert.deepEqual(first.resumed, [runId], 'the first sweep resumes it')
108
+ assert.deepEqual(
109
+ second.resumed,
110
+ [],
111
+ 'the next sweep is held off by the backoff'
112
+ )
113
+ assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
114
+ })
115
+
116
+ test('does not re-drive a run the relay has just re-dispatched', async () => {
117
+ const resumes = trackResumes()
118
+ const ws = new InMemoryWorkflowService()
119
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
120
+ type: 'test',
121
+ })
122
+ // Old enough to read as both an undispatched step and a stalled run.
123
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
124
+ await backdate(ws, runId, 10 * 60_000)
125
+
126
+ const relayed = await ws.relayUndispatchedSteps()
127
+ const swept = await ws.recoverStalledRuns()
128
+
129
+ assert.deepEqual(relayed.redispatched, [runId], 'the relay re-drives it')
130
+ assert.deepEqual(
131
+ swept.resumed,
132
+ [],
133
+ 'the sweep adds nothing the relay has not already queued'
134
+ )
135
+ assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
136
+ })
137
+
92
138
  test('leaves a finished run alone', async () => {
93
139
  trackResumes()
94
140
  const ws = new InMemoryWorkflowService()
@@ -0,0 +1,105 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
6
+
7
+ const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
8
+
9
+ /** Records every run lock taken, which is the whole point of the guard. */
10
+ class LockSpyWorkflowService extends InMemoryWorkflowService {
11
+ public readonly locked: string[] = []
12
+
13
+ public override async withRunLock<T>(
14
+ id: string,
15
+ fn: () => Promise<T>
16
+ ): Promise<T> {
17
+ this.locked.push(id)
18
+ return super.withRunLock(id, fn)
19
+ }
20
+ }
21
+
22
+ /** A registered, fully described workflow, so nothing else can short-circuit. */
23
+ const startRun = async (): Promise<{
24
+ service: LockSpyWorkflowService
25
+ runId: string
26
+ entered: () => boolean
27
+ }> => {
28
+ resetPikkuState()
29
+ pikkuState(null, 'package', 'singletonServices', {
30
+ logger: silentLogger,
31
+ queueService: { add: async () => 'job-1' },
32
+ } as any)
33
+
34
+ let bodyEntered = false
35
+ const func = async () => {
36
+ bodyEntered = true
37
+ }
38
+ pikkuState(null, 'workflows', 'meta', {
39
+ flow: { name: 'flow', pikkuFuncId: 'flow', source: 'dsl' },
40
+ } as any)
41
+ pikkuState(null, 'workflows', 'registrations').set('flow', {
42
+ name: 'flow',
43
+ func,
44
+ } as any)
45
+ pikkuState(null, 'function', 'meta', {
46
+ flow: {
47
+ pikkuFuncId: 'flow',
48
+ inputSchemaName: null,
49
+ outputSchemaName: null,
50
+ sessionless: true,
51
+ },
52
+ } as any)
53
+ pikkuState(null, 'function', 'functions').set('flow', { func } as any)
54
+
55
+ const service = new LockSpyWorkflowService()
56
+ const runId = await service.createRun('flow', {}, false, '', { type: 'test' })
57
+ return { service, runId, entered: () => bodyEntered }
58
+ }
59
+
60
+ /**
61
+ * The leak this guards, read straight off production: every granted advisory
62
+ * lock held by an idle session mapped to a run that was already `failed`. The
63
+ * orchestrator queue is at-least-once, so a message for a settled run is
64
+ * routine — and answering it by taking the run lock and replaying the body is
65
+ * how a run that can never move again ends up holding a lock and a pooled
66
+ * connection while it waits on something that will never arrive.
67
+ */
68
+ describe('an orchestrator message for a run that already settled', () => {
69
+ for (const status of ['failed', 'completed', 'cancelled'] as const) {
70
+ test(`a ${status} run is answered without taking its lock`, async () => {
71
+ const { service, runId, entered } = await startRun()
72
+ await service.updateRunStatus(runId, status)
73
+
74
+ await service.runWorkflowJob(runId, {} as any)
75
+
76
+ assert.deepEqual(
77
+ service.locked,
78
+ [],
79
+ 'the run lock was taken for a run that can never move again'
80
+ )
81
+ assert.equal(
82
+ entered(),
83
+ false,
84
+ 'the workflow body was replayed after the run had settled'
85
+ )
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Guards the tests above: they would pass just as well for a service that
91
+ * refused to orchestrate anything at all.
92
+ */
93
+ test('a suspended run is still orchestrated', async () => {
94
+ const { service, runId } = await startRun()
95
+ await service.updateRunStatus(runId, 'suspended')
96
+
97
+ await service.runWorkflowJob(runId, {} as any)
98
+
99
+ assert.deepEqual(
100
+ service.locked,
101
+ [runId],
102
+ 'suspended ends a pass, not the run — it resumes when its signal arrives'
103
+ )
104
+ })
105
+ })