mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(managed-server): let a forced update or rollback cross mixed daemon generations (#34664)
The terminal census collapsed sessions spread over more than one daemon generation to a null protocol, so a host that kept a legacy daemon alive across an earlier update refused every forced update and rollback while any terminal ran. The census now reports every live protocol, and the crossing check requires the incoming build to attach each one. The single-protocol field stays for older desktops.
This commit is contained in:
@@ -19,6 +19,7 @@ describe('orcad terminal census', () => {
|
||||
liveSessions: 3,
|
||||
startedSinceActivation: 2,
|
||||
daemonProtocolVersion: 7,
|
||||
daemonProtocolVersions: [7],
|
||||
inProcessSessions: 0
|
||||
})
|
||||
})
|
||||
@@ -34,18 +35,22 @@ describe('orcad terminal census', () => {
|
||||
liveSessions: 0,
|
||||
startedSinceActivation: 0,
|
||||
daemonProtocolVersion: null,
|
||||
daemonProtocolVersions: [],
|
||||
inProcessSessions: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the protocol unknown when sessions span daemon generations', async () => {
|
||||
it('names every daemon generation when sessions span them', async () => {
|
||||
const census = await collectOrcadTerminalCensus(
|
||||
0,
|
||||
async () => [session(1, 7), session(2, 6)],
|
||||
async () => [session(1, 7), session(2, 6), session(3, 7)],
|
||||
async () => 0
|
||||
)
|
||||
expect(census.daemonProtocolVersion).toBeNull()
|
||||
expect(census.liveSessions).toBe(2)
|
||||
expect(census).toMatchObject({
|
||||
liveSessions: 3,
|
||||
daemonProtocolVersion: null,
|
||||
daemonProtocolVersions: [6, 7]
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the since-activation count unknown when a session has no creation time', async () => {
|
||||
@@ -85,6 +90,7 @@ describe('orcad terminal census', () => {
|
||||
liveSessions: 1,
|
||||
startedSinceActivation: null,
|
||||
daemonProtocolVersion: null,
|
||||
daemonProtocolVersions: [],
|
||||
inProcessSessions: 1
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,8 +31,9 @@ export async function collectOrcadTerminalCensus(
|
||||
const timestampsKnown = sessions.every(
|
||||
(session) => Number.isFinite(session.createdAt) && session.createdAt > 0
|
||||
)
|
||||
const protocols = new Set(sessions.map((session) => session.protocolVersion))
|
||||
const [protocol] = protocols
|
||||
const protocols = [...new Set(sessions.map((session) => session.protocolVersion))].sort(
|
||||
(a, b) => a - b
|
||||
)
|
||||
return {
|
||||
liveSessions: sessions.length + inProcess,
|
||||
// Fallback terminals lack creation times, so activation-relative counts remain unknown.
|
||||
@@ -40,8 +41,9 @@ export async function collectOrcadTerminalCensus(
|
||||
timestampsKnown && inProcess === 0
|
||||
? sessions.filter((session) => session.createdAt >= activatedAt).length
|
||||
: null,
|
||||
// Mixed daemon generations cannot establish one protocol owner for build compatibility.
|
||||
daemonProtocolVersion: protocols.size === 1 && protocol !== undefined ? protocol : null,
|
||||
// Older desktops read only the scalar, which cannot name mixed generations.
|
||||
daemonProtocolVersion: protocols.length === 1 ? protocols[0] : null,
|
||||
daemonProtocolVersions: protocols,
|
||||
inProcessSessions: inProcess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,18 @@ export function orcadBuildCanAttachDaemon(
|
||||
}
|
||||
|
||||
export type OrcadLiveDaemonCrossing =
|
||||
| 'no-live-terminals'
|
||||
| 'attachable'
|
||||
| 'strands-live-terminals'
|
||||
| 'unverifiable'
|
||||
| { state: 'no-live-terminals' }
|
||||
| { state: 'attachable' }
|
||||
| { state: 'strands-live-terminals'; unattachable: readonly number[] }
|
||||
| { state: 'unverifiable' }
|
||||
|
||||
/** Hosts predating the protocol set report only a single-generation scalar. */
|
||||
function liveDaemonProtocols(census: OrcadTerminalCensus): readonly number[] | null {
|
||||
if (census.daemonProtocolVersions !== undefined) {
|
||||
return census.daemonProtocolVersions
|
||||
}
|
||||
return census.daemonProtocolVersion === null ? null : [census.daemonProtocolVersion]
|
||||
}
|
||||
|
||||
/** An unknown session count or daemon protocol is planned for as live and unattachable. */
|
||||
export function assessOrcadLiveDaemonCrossing(
|
||||
@@ -37,12 +45,21 @@ export function assessOrcadLiveDaemonCrossing(
|
||||
incoming: OrcadDaemonProtocolFacts
|
||||
): OrcadLiveDaemonCrossing {
|
||||
if (census.liveSessions === 0) {
|
||||
return 'no-live-terminals'
|
||||
return { state: 'no-live-terminals' }
|
||||
}
|
||||
if (census.daemonProtocolVersion === null) {
|
||||
return 'unverifiable'
|
||||
const protocols = liveDaemonProtocols(census)
|
||||
if (protocols === null || protocols.length === 0) {
|
||||
return { state: 'unverifiable' }
|
||||
}
|
||||
return orcadBuildCanAttachDaemon(incoming, census.daemonProtocolVersion)
|
||||
? 'attachable'
|
||||
: 'strands-live-terminals'
|
||||
// Every generation must stay reachable; one unattachable daemon strands its terminals.
|
||||
const unattachable = protocols.filter(
|
||||
(protocol) => !orcadBuildCanAttachDaemon(incoming, protocol)
|
||||
)
|
||||
return unattachable.length === 0
|
||||
? { state: 'attachable' }
|
||||
: { state: 'strands-live-terminals', unattachable }
|
||||
}
|
||||
|
||||
export function describeDaemonProtocols(protocols: readonly number[]): string {
|
||||
return protocols.length === 1 ? `protocol ${protocols[0]}` : `protocols ${protocols.join(', ')}`
|
||||
}
|
||||
|
||||
@@ -291,6 +291,49 @@ describe('D7 daemon protocol crossing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const mixed = (daemonProtocolVersions: number[]) => ({
|
||||
liveSessions: 80,
|
||||
startedSinceActivation: 0,
|
||||
daemonProtocolVersion: null,
|
||||
daemonProtocolVersions
|
||||
})
|
||||
|
||||
it('forces an update past mixed daemon generations only when the candidate attaches each', () => {
|
||||
const plan = (daemonProtocolVersions: number[]) =>
|
||||
planOrcadUpdate({
|
||||
record: record(),
|
||||
candidateVersion: '0.3.0+cc01',
|
||||
candidateDaemonProtocol: PROTOCOL,
|
||||
census: mixed(daemonProtocolVersions),
|
||||
force: true
|
||||
})
|
||||
expect(plan([2, 3])).toMatchObject({ action: 'proceed', preservesLiveDaemon: true })
|
||||
expect(plan([3, 4])).toMatchObject({
|
||||
action: 'defer',
|
||||
code: 'orcad_update_strands_live_terminals'
|
||||
})
|
||||
expect(plan([])).toMatchObject({
|
||||
action: 'defer',
|
||||
code: 'orcad_update_daemon_protocol_unverifiable'
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back over mixed daemon generations only when the target attaches each', () => {
|
||||
const rollback = (daemonProtocolVersions: number[]) =>
|
||||
assessOrcadRollback({
|
||||
record: record(),
|
||||
snapshotPresent: true,
|
||||
census: mixed(daemonProtocolVersions),
|
||||
targetDaemonProtocol: PROTOCOL,
|
||||
stateWritesSinceActivation: false
|
||||
})
|
||||
expect(rollback([1, 3])).toMatchObject({ safety: 'clean' })
|
||||
expect(rollback([2, 4])).toMatchObject({
|
||||
safety: 'unsafe',
|
||||
code: 'orcad_rollback_strands_live_terminals'
|
||||
})
|
||||
})
|
||||
|
||||
it('needs no protocol answer when no terminals are running', () => {
|
||||
const plan = planOrcadUpdate({
|
||||
record: record(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OrcadActivationRecord } from '../managed-server/activation/orcad-a
|
||||
import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census'
|
||||
import {
|
||||
assessOrcadLiveDaemonCrossing,
|
||||
describeDaemonProtocols,
|
||||
type OrcadDaemonProtocolFacts
|
||||
} from './orcad-daemon-protocol-crossing'
|
||||
|
||||
@@ -55,18 +56,18 @@ export function planOrcadUpdate(input: {
|
||||
}
|
||||
const crossing = assessOrcadLiveDaemonCrossing(input.census, input.candidateDaemonProtocol)
|
||||
// Force can accept mixed builds, never unreachable terminals.
|
||||
if (crossing === 'strands-live-terminals') {
|
||||
if (crossing.state === 'strands-live-terminals') {
|
||||
return {
|
||||
action: 'defer',
|
||||
code: 'orcad_update_strands_live_terminals',
|
||||
reason:
|
||||
`The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` +
|
||||
`A live terminal daemon speaks ${describeDaemonProtocols(crossing.unattachable)}, ` +
|
||||
`which orcad ${input.candidateVersion} cannot attach to. Restarting now would leave ` +
|
||||
'every running terminal unreachable. Update when no terminals are running.'
|
||||
'its running terminals unreachable. Update when no terminals are running.'
|
||||
}
|
||||
}
|
||||
const { liveSessions } = input.census
|
||||
if (input.force && crossing === 'unverifiable') {
|
||||
if (input.force && crossing.state === 'unverifiable') {
|
||||
return {
|
||||
action: 'defer',
|
||||
code: 'orcad_update_daemon_protocol_unverifiable',
|
||||
@@ -209,7 +210,7 @@ export function assessOrcadRollback(input: {
|
||||
}
|
||||
}
|
||||
const crossing = assessOrcadLiveDaemonCrossing(input.census, input.targetDaemonProtocol)
|
||||
if (crossing === 'unverifiable') {
|
||||
if (crossing.state === 'unverifiable') {
|
||||
return {
|
||||
safety: 'unsafe',
|
||||
code: 'orcad_rollback_daemon_protocol_unverifiable',
|
||||
@@ -218,13 +219,13 @@ export function assessOrcadRollback(input: {
|
||||
`${target} would reach the terminals still running. Retry when the daemon answers.`
|
||||
}
|
||||
}
|
||||
if (crossing === 'strands-live-terminals') {
|
||||
if (crossing.state === 'strands-live-terminals') {
|
||||
return {
|
||||
safety: 'unsafe',
|
||||
code: 'orcad_rollback_strands_live_terminals',
|
||||
reason:
|
||||
`The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` +
|
||||
`which ${target} does not list. Rolling back now would leave every running terminal ` +
|
||||
`A live terminal daemon speaks ${describeDaemonProtocols(crossing.unattachable)}, ` +
|
||||
`which ${target} does not list. Rolling back now would leave its running terminals ` +
|
||||
'unreachable. Roll back when no terminals are running, or deploy forward.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ export const OrcadTerminalCensusSchema = z.object({
|
||||
liveSessions: CountSchema,
|
||||
/** Of those, how many started at or after the active version's activation. */
|
||||
startedSinceActivation: CountSchema,
|
||||
/** Protocol of the daemon that owns those sessions. */
|
||||
/** Protocol of the daemon that owns those sessions; null unless exactly one generation owns them. */
|
||||
daemonProtocolVersion: z.number().int().positive().nullable(),
|
||||
/** Protocol of every daemon generation owning those sessions; absent on older hosts. */
|
||||
daemonProtocolVersions: z.array(z.number().int().positive()).optional(),
|
||||
/** In-process fallback sessions end with orcad, unlike daemon sessions; absent on older hosts. */
|
||||
inProcessSessions: CountSchema.optional(),
|
||||
/** Admission-fence expiry, epoch ms; 0 means reopened. Absence never proves older servers fenced admission. */
|
||||
|
||||
Reference in New Issue
Block a user