Files
orca/src/main/github/pull-requests/refresh/pr-refresh-queue-scheduler.ts
T
2026-11-13 21:09:02 -08:00

298 lines
8.9 KiB
TypeScript

import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import {
aliasFromCandidate,
freshRetryAt,
isBudgetedBackground,
isMergeabilityPendingOutcome,
sameAliasRequestIdentity,
visibleCandidateAfterOutcome
} from './pr-refresh-candidate-policy'
import type {
PreparedPRBranchRead,
PRReadSettlement
} from '../../client/lookup/pr-for-branch-outcome'
import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher'
import type { PRRefreshPacing } from './pr-refresh-pacing'
import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue'
import type { PRWatchJobRunner } from './pr-refresh-watch-jobs'
import type { PRRefreshRetryState } from './pr-refresh-retry-state'
import type { PRRefreshVisibility } from './pr-refresh-visibility'
import { outsideWslUserAct } from '../../../wsl/discovery/wsl-user-act-scope'
export class PRRefreshQueueScheduler {
private timer: ReturnType<typeof setTimeout> | null = null
private timerDueAt = Infinity
constructor(
private readonly queue: PRRefreshQueue,
private readonly pacing: PRRefreshPacing,
private readonly visibility: PRRefreshVisibility,
private readonly retry: PRRefreshRetryState,
private readonly events: PRRefreshEventPublisher,
private readonly watchJobs: PRWatchJobRunner,
private readonly runDrain: () => Promise<void>
) {}
schedule(delay = 0): void {
const dueAt = performance.now() + Math.max(0, delay)
if (this.timer !== null && this.timerDueAt <= dueAt) {
return
}
clearTimeout(this.timer ?? undefined)
this.timerDueAt = dueAt
this.timer = setTimeout(() => {
this.timer = null
this.timerDueAt = Infinity
// The drain serves every queued repo; a scheduling call inside a WSL user act must not lend it that act.
void outsideWslUserAct(() => this.runDrain())
}, delay)
}
settleRead(
key: string,
candidate: GitHubPRRefreshCandidate,
aliases: GitHubPRRefreshAlias[],
prepared: PreparedPRBranchRead,
requestSequence: number,
hosted = false
): PRReadSettlement {
return (outcome, producer) => {
if (!prepared.isCurrent()) {
return outcome
}
const settled =
producer.retained &&
outcome.kind === 'upstream-error' &&
(hosted || this.visibility.has(key))
? this.retry.settleProducerError(
outcome,
key,
hosted
? { ...candidate, isSelected: false }
: this.visibility.candidate(key, candidate),
prepared,
this.queue,
this.queue.ownsRequest(key, requestSequence)
)
: outcome
this.events.observe(candidate, settled, aliases)
return settled
}
}
completeWithoutOutcome(
key: string,
candidate: GitHubPRRefreshCandidate,
aliases: GitHubPRRefreshAlias[],
priority: number,
windowId?: number,
foreground = false
): void {
if (!this.visibility.has(key) || this.queue.get(key)) {
return
}
const current = this.visibility.candidate(key, candidate)
const dueAt = Math.max(
freshRetryAt(current, foreground) ?? Date.now(),
this.queue.backgroundGateUntil(key),
this.retry.manualGateUntil(key)
)
if (!Number.isFinite(dueAt)) {
return
}
this.queue.setVisibleFollowUp({
key,
candidate: current,
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
reason: 'visible',
priority,
dueAt,
queuedAt: this.queue.nextOrder(),
followUp: !foreground,
foreground,
windowId
})
this.schedule(Math.max(0, dueAt - Date.now()))
}
finishWithoutOutcome(
next: PRRefreshQueueEntry,
aliases: GitHubPRRefreshAlias[],
requestSequence: number
): void {
next.hosted?.finish(null)
if (!next.hosted && this.queue.ownsRequest(next.key, requestSequence)) {
this.events.broadcast(
{ aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' },
requestSequence
)
if (next.watch) {
this.watchJobs.skipped(next)
} else {
this.completeWithoutOutcome(
next.key,
next.candidate,
aliases,
next.priority,
next.windowId,
next.foreground
)
}
}
}
consumeGatedError(
entry: PRRefreshQueueEntry,
prepared: PreparedPRBranchRead,
requestSequence: number,
requestStartedAt: number
): boolean {
if (
entry.hosted ||
entry.watch ||
entry.bypassBackgroundBudget ||
(entry.reason !== 'visible' && entry.reason !== 'swr') ||
!prepared.isCurrent()
) {
return false
}
const outcome = prepared.cached()?.outcome
if (outcome?.kind !== 'upstream-error') {
return false
}
const retryAt = Math.max(outcome.nextAutoRetryAt ?? 0, outcome.retryDisabledUntil ?? 0)
if (retryAt <= Date.now()) {
return false
}
const aliases = Array.from(entry.aliases.values())
this.retry.noteManualGate(entry.key, outcome)
this.events.broadcast(
{ aliases, reason: entry.reason, outcome, requestStartedAt },
requestSequence
)
this.scheduleVisibleFollowUp(
entry.key,
entry.candidate,
outcome,
entry.priority,
aliases,
entry.windowId,
{ plannedRetryAt: retryAt }
)
return true
}
scheduleVisibleFollowUp(
key: string,
candidate: GitHubPRRefreshCandidate,
outcome: PRRefreshOutcome,
priority: number,
aliases: GitHubPRRefreshAlias[],
windowId?: number,
options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number }
): void {
if (outcome.kind === 'upstream-error') {
const retryAt = Math.max(
options?.plannedRetryAt ??
this.retry.nextVisibleErrorRetryAt(key, this.visibility.candidate(key, candidate)),
this.retry.manualGateUntil(key)
)
this.queue.protectBackgroundUntil(key, retryAt)
if (!this.visibility.has(key)) {
return
}
this.queue.setVisibleFollowUp({
key,
candidate,
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
reason: 'visible',
priority,
dueAt: retryAt,
queuedAt: this.queue.nextOrder(),
windowId
})
this.schedule(retryAt - Date.now())
return
}
this.retry.reset(key)
const refreshed = visibleCandidateAfterOutcome(candidate, outcome)
this.visibility.update(refreshed)
if (!this.visibility.has(key)) {
return
}
const followUpCandidate = this.visibility.candidate(key, refreshed)
const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now()
const pendingDueAt =
options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome)
? outcome.fetchedAt + options.pendingMergeabilityDelayMs
: null
const dueAt = pendingDueAt === null ? regularDueAt : Math.min(regularDueAt, pendingDueAt)
if (!Number.isFinite(dueAt)) {
const pending = this.queue.get(key)
if (
pending?.reason === 'visible' &&
sameAliasRequestIdentity(
aliasFromCandidate(pending.candidate),
aliasFromCandidate(followUpCandidate)
)
) {
this.queue.delete(key)
}
return
}
this.queue.setVisibleFollowUp({
key,
candidate: followUpCandidate,
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
reason: 'visible',
priority,
dueAt,
queuedAt: this.queue.nextOrder(),
bypassBackgroundBudget: pendingDueAt !== null,
followUp: true,
windowId
})
this.schedule(Math.max(0, dueAt - Date.now()))
}
ordered(): PRRefreshQueueEntry[] {
this.watchJobs.age(this.queue.values(), Date.now())
return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b))
}
nextQueuedWakeDelay(excludedKey: string): number | null {
const now = Date.now()
let nextDelay = Number.POSITIVE_INFINITY
for (const entry of this.queue.values()) {
if (entry.key === excludedKey) {
continue
}
const delay = entry.dueAt > now ? entry.dueAt - now : this.pacing.entryDelay(entry)
nextDelay = Math.min(nextDelay, delay)
}
return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null
}
notePacingDelay(entry: PRRefreshQueueEntry): void {
if (this.pacing.isActiveBurstDelayed(entry) && !entry.activeDelayNotified) {
entry.activeDelayNotified = true
this.events.broadcast({
aliases: Array.from(entry.aliases.values()),
reason: entry.reason,
status: 'queued'
})
}
if (
entry.bypassBackgroundBudget !== true &&
isBudgetedBackground(entry.reason) &&
this.pacing.nextBudgetDelay() > 0
) {
this.events.record('background-pause', entry.reason)
}
}
}