Do not create Github worker for deleting workspace (#10661)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-03-25 16:33:28 +07:00
committed by GitHub
parent 37dda9ed11
commit 466935d26a
3 changed files with 203 additions and 33 deletions
@@ -0,0 +1,111 @@
import type { MeasureContext, WorkspaceInfoWithStatus, WorkspaceUuid } from '@hcengineering/core'
import { GithubWorkerWorkspaceState, getGithubWorkerState } from '../workspaceUtils'
const WS = '00000000-0000-0000-0000-000000000001' as WorkspaceUuid
const DAY_MS = 24 * 60 * 60 * 1000
function createMockCtx (): MeasureContext {
return {
warn: jest.fn(),
info: jest.fn(),
error: jest.fn()
} as unknown as MeasureContext
}
function baseInfo (overrides: Partial<WorkspaceInfoWithStatus> = {}): WorkspaceInfoWithStatus {
return {
uuid: WS,
name: 'ws',
url: 'ws-url',
createdOn: 0,
versionMajor: 0,
versionMinor: 7,
versionPatch: 0,
mode: 'active',
processingAttemps: 0,
...overrides
}
}
describe('getGithubWorkerState', () => {
it('returns Skip and warns when uuid is absent', () => {
const ctx = createMockCtx()
const checked = new Set<string>()
const info = { ...baseInfo(), uuid: undefined as unknown as WorkspaceUuid }
const state = getGithubWorkerState(ctx, WS, info, 3, checked)
expect(state).toBe(GithubWorkerWorkspaceState.Skip)
expect(ctx.warn).toHaveBeenCalled()
})
it('returns Skip when workspace is disabled', () => {
const ctx = createMockCtx()
const state = getGithubWorkerState(ctx, WS, baseInfo({ isDisabled: true, mode: 'active' }), 3, new Set())
expect(state).toBe(GithubWorkerWorkspaceState.Skip)
expect(ctx.info).toHaveBeenCalled()
})
it.each(['pending-deletion', 'deleting', 'deleted'] as const)('returns Skip for mode %s', (mode) => {
const ctx = createMockCtx()
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode }), 3, new Set())).toBe(GithubWorkerWorkspaceState.Skip)
})
it.each(['archived', 'archiving-pending-backup', 'archiving-clean'] as const)(
'returns Skip for archiving %s',
(mode) => {
const ctx = createMockCtx()
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode }), 3, new Set())).toBe(GithubWorkerWorkspaceState.Skip)
}
)
it.each(['upgrading', 'creating', 'pending-creation'] as const)('returns Wait for mode %s', (mode) => {
const ctx = createMockCtx()
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode }), 3, new Set())).toBe(GithubWorkerWorkspaceState.Wait)
expect(ctx.warn).toHaveBeenCalled()
})
it('returns Wait when last visit exceeds inactivity interval', () => {
const ctx = createMockCtx()
const nowMs = 1_700_000_000_000
const lastVisit = nowMs - 4 * DAY_MS
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active', lastVisit }), 3, new Set(), nowMs)).toBe(
GithubWorkerWorkspaceState.Wait
)
})
it('returns Connect when within inactivity interval', () => {
const ctx = createMockCtx()
const nowMs = 1_700_000_000_000
const lastVisit = nowMs - 2 * DAY_MS
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active', lastVisit }), 3, new Set(), nowMs)).toBe(
GithubWorkerWorkspaceState.Connect
)
})
it('does not apply inactivity gate when interval is 0', () => {
const ctx = createMockCtx()
const nowMs = 1_700_000_000_000
const lastVisit = nowMs - 365 * DAY_MS
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active', lastVisit }), 0, new Set(), nowMs)).toBe(
GithubWorkerWorkspaceState.Connect
)
})
it('missing lastVisit uses epoch → Wait inactive when interval > 0', () => {
const ctx = createMockCtx()
const nowMs = 1_700_000_000_000
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active' }), 3, new Set(), nowMs)).toBe(
GithubWorkerWorkspaceState.Wait
)
})
it('logs inactive warning only once per workspace id', () => {
const ctx = createMockCtx()
const checked = new Set<string>()
const nowMs = 1_700_000_000_000
const info = baseInfo({ mode: 'active', lastVisit: nowMs - 10 * DAY_MS })
getGithubWorkerState(ctx, WS, info, 3, checked, nowMs)
getGithubWorkerState(ctx, WS, info, 3, checked, nowMs)
expect(ctx.warn).toHaveBeenCalledTimes(1)
})
})
+23 -33
View File
@@ -11,8 +11,6 @@ import core, {
Client,
ClientConnectEvent,
DocumentUpdate,
isActiveMode,
isDeletingMode,
MeasureContext,
PersonId,
RateLimiter,
@@ -46,6 +44,7 @@ import { type StorageAdapter } from '@hcengineering/server-core'
import { join } from 'path'
import { createPlatformClient } from './client'
import config from './config'
import { GithubWorkerWorkspaceState, getGithubWorkerState } from './workspaceUtils'
import { registerLoaders } from './loaders'
import { createNotification } from './notifications'
import { errorToObj } from './sync/utils'
@@ -68,6 +67,8 @@ interface IntegrationDataValue {
installationId: number | number[]
}
export { GithubWorkerWorkspaceState, getGithubWorkerState } from './workspaceUtils'
export class PlatformWorker {
private readonly clients = new Map<WorkspaceUuid, GithubWorker>()
@@ -948,32 +949,6 @@ export class PlatformWorker {
checkedWorkspaces = new Set<string>()
checkWorkspaceIsActive (workspace: WorkspaceUuid, workspaceInfo: WorkspaceInfoWithStatus): boolean {
if (workspaceInfo?.uuid === undefined) {
this.ctx.error('No workspace exists for workspaceId', { workspace })
return false
}
if (workspaceInfo?.isDisabled === true || isDeletingMode(workspaceInfo?.mode)) {
this.ctx.warn('Workspace is disabled', { workspace })
return false
}
if (!isActiveMode(workspaceInfo?.mode)) {
this.ctx.warn('Workspace is in maitenance, skipping for now.', { workspace, mode: workspaceInfo?.mode })
return true
}
const lastVisit = (Date.now() - (workspaceInfo.lastVisit ?? 0)) / (3600 * 24 * 1000) // In days
if (config.WorkspaceInactivityInterval > 0 && lastVisit > config.WorkspaceInactivityInterval) {
if (!this.checkedWorkspaces.has(workspace)) {
this.checkedWorkspaces.add(workspace)
this.ctx.warn('Workspace is inactive for too long, skipping for now.', { workspace })
}
return true
}
return false
}
checkReconnect (workspace: WorkspaceUuid, event: ClientConnectEvent, worker: GithubWorker): void {
if (event === ClientConnectEvent.Refresh || event === ClientConnectEvent.Upgraded) {
void this.clients
@@ -989,9 +964,15 @@ export class PlatformWorker {
getAccountClient(config.AccountsURL, token)
.getWorkspaceInfo()
.then((wsInfo) => {
const res = this.checkWorkspaceIsActive(workspace, wsInfo)
if (!res) {
this.ctx.warn('Workspace is inactive, removing from clients list.', { workspace })
const state = getGithubWorkerState(
this.ctx,
workspace,
wsInfo,
config.WorkspaceInactivityInterval,
this.checkedWorkspaces
)
if (state === GithubWorkerWorkspaceState.Skip) {
this.ctx.warn('Github worker state is skip, removing from clients list.', { workspace })
this.clients.delete(workspace)
void worker?.close().catch((err) => {
this.ctx.error('Failed to close workspace', { workspace, error: err })
@@ -1053,11 +1034,20 @@ export class PlatformWorker {
rechecks.push(workspace)
continue
}
const needRecheck = this.checkWorkspaceIsActive(workspace, returnedInfo)
if (needRecheck) {
const state = getGithubWorkerState(
this.ctx,
workspace,
returnedInfo,
config.WorkspaceInactivityInterval,
this.checkedWorkspaces
)
if (state === GithubWorkerWorkspaceState.Wait) {
rechecks.push(workspace)
continue
}
if (state === GithubWorkerWorkspaceState.Skip) {
continue
}
await rateLimiter.add(async () => {
try {
const branding = Object.values(this.brandingMap).find((b) => b.key === returnedInfo?.branding) ?? null
@@ -0,0 +1,69 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
isActiveMode,
isArchivingMode,
isDeletingMode,
type MeasureContext,
type WorkspaceInfoWithStatus,
type WorkspaceUuid
} from '@hcengineering/core'
/** Whether this pod should run a GithubWorker for the workspace. */
export enum GithubWorkerWorkspaceState {
/** Active workspace — connect and run worker. */
Connect = 'connect',
/** Not ready yet (upgrade, creation, …) or too inactive — recheck later; keep existing worker. */
Wait = 'wait',
/** Disabled, deleting, deleted, or archiving — do not run; drop worker if present. */
Skip = 'skip'
}
export function getGithubWorkerState (
ctx: MeasureContext,
workspace: WorkspaceUuid,
workspaceInfo: WorkspaceInfoWithStatus,
inactivityIntervalDays: number,
checkedWorkspaces: Set<string>,
nowMs: number = Date.now()
): GithubWorkerWorkspaceState {
if (workspaceInfo?.uuid === undefined) {
ctx.warn('No workspace exists for workspaceId', { workspace })
return GithubWorkerWorkspaceState.Skip
}
if (workspaceInfo.isDisabled === true || isDeletingMode(workspaceInfo.mode) || isArchivingMode(workspaceInfo.mode)) {
ctx.info('Workspace is disabled, deleting, or archived — skipping github worker', {
workspace,
mode: workspaceInfo.mode,
isDisabled: workspaceInfo.isDisabled
})
return GithubWorkerWorkspaceState.Skip
}
if (!isActiveMode(workspaceInfo.mode)) {
ctx.warn('Workspace is in maintenance, skipping for now.', { workspace, mode: workspaceInfo.mode })
return GithubWorkerWorkspaceState.Wait
}
const lastVisitDays = (nowMs - (workspaceInfo.lastVisit ?? 0)) / (3600 * 24 * 1000)
if (inactivityIntervalDays > 0 && lastVisitDays > inactivityIntervalDays) {
if (!checkedWorkspaces.has(workspace)) {
checkedWorkspaces.add(workspace)
ctx.warn('Workspace is inactive for too long, skipping for now.', { workspace })
}
return GithubWorkerWorkspaceState.Wait
}
return GithubWorkerWorkspaceState.Connect
}