feat(upgrade): expose twenty_upgrade_workspaces_up_to_date_total (#20555)

## Summary
Adds a fourth gauge alongside the existing
`twenty_upgrade_workspaces_behind_total` /
`twenty_upgrade_workspaces_failed_total` so dashboards can show how many
workspaces are currently healthy, not just the ones that need attention.

- New gauge: `twenty_upgrade_workspaces_up_to_date_total`
- New count is computed during
`UpgradeStatusService.refreshInstanceAndAllWorkspacesStatus` (cheap — we
already iterate over every workspace), persisted in the existing
`UpgradeStatusCacheService` so the cache-hit path stays a single round
trip, and surfaced via `InstanceAndAllWorkspacesUpgradeStatusDTO` for
the admin panel.

## Files
-
`packages/twenty-server/src/engine/core-modules/upgrade/upgrade-gauge.service.ts`
— register the new ObservableGauge
-
`packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status.service.ts`
— count UP_TO_DATE workspaces during refresh, propagate through cached
path
-
`packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-status-cache.service.ts`
— persist `upToDateWorkspaceCount` next to behind/failed sets
-
`packages/twenty-server/src/engine/core-modules/upgrade/dtos/instance-and-all-workspaces-upgrade-status.dto.ts`
— `Int` field on the admin DTO
- Tests: extended `upgrade-status.service.spec.ts` (14/14 green) —
cached and refresh paths both assert on `upToDateWorkspaceCount`

## Follow-up
A companion `twenty-infra` PR adds the new tile + line on the
upgrade-status Grafana dashboard.
This commit is contained in:
Charles Bochet
2026-05-13 23:45:56 +02:00
committed by GitHub
parent d81756f2e8
commit 6dd1e8a471
6 changed files with 58 additions and 7 deletions
@@ -294,6 +294,7 @@ export type InstanceAndAllWorkspacesUpgradeStatus = {
__typename?: 'InstanceAndAllWorkspacesUpgradeStatus';
computedAt: Scalars['DateTime'];
instanceUpgradeStatus: InstanceUpgradeStatus;
upToDateWorkspaceCount: Scalars['Int'];
workspacesBehind: Array<WorkspaceUpgradeRef>;
workspacesFailed: Array<WorkspaceUpgradeRef>;
};
@@ -1,4 +1,4 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { InstanceUpgradeStatusDTO } from 'src/engine/core-modules/upgrade/dtos/instance-upgrade-status.dto';
import { WorkspaceUpgradeRefDTO } from 'src/engine/core-modules/upgrade/dtos/workspace-upgrade-ref.dto';
@@ -14,6 +14,9 @@ export class InstanceAndAllWorkspacesUpgradeStatusDTO {
@Field(() => [WorkspaceUpgradeRefDTO])
workspacesFailed: WorkspaceUpgradeRefDTO[];
@Field(() => Int)
upToDateWorkspaceCount: number;
@Field(() => Date)
computedAt: Date;
}
@@ -57,6 +57,7 @@ describe('UpgradeStatusService', () => {
let cacheGetComputedAt: jest.Mock;
let cacheGetBehindWorkspaceIds: jest.Mock;
let cacheGetFailedWorkspaceIds: jest.Mock;
let cacheGetUpToDateWorkspaceCount: jest.Mock;
let cacheWrite: jest.Mock;
let cacheInvalidate: jest.Mock;
@@ -81,6 +82,7 @@ describe('UpgradeStatusService', () => {
cacheGetComputedAt = jest.fn();
cacheGetBehindWorkspaceIds = jest.fn().mockResolvedValue([]);
cacheGetFailedWorkspaceIds = jest.fn().mockResolvedValue([]);
cacheGetUpToDateWorkspaceCount = jest.fn().mockResolvedValue(0);
cacheWrite = jest.fn().mockResolvedValue(undefined);
cacheInvalidate = jest.fn().mockResolvedValue(undefined);
@@ -115,6 +117,7 @@ describe('UpgradeStatusService', () => {
getComputedAt: cacheGetComputedAt,
getBehindWorkspaceIds: cacheGetBehindWorkspaceIds,
getFailedWorkspaceIds: cacheGetFailedWorkspaceIds,
getUpToDateWorkspaceCount: cacheGetUpToDateWorkspaceCount,
write: cacheWrite,
invalidate: cacheInvalidate,
},
@@ -277,6 +280,7 @@ describe('UpgradeStatusService', () => {
cacheGetComputedAt.mockResolvedValue(computedAt);
cacheGetBehindWorkspaceIds.mockResolvedValue(['ws-2']);
cacheGetFailedWorkspaceIds.mockResolvedValue(['ws-3']);
cacheGetUpToDateWorkspaceCount.mockResolvedValue(5);
getLastAttemptedInstanceCommand.mockResolvedValue({
name: LAST_INSTANCE_COMMAND,
status: 'completed',
@@ -295,6 +299,7 @@ describe('UpgradeStatusService', () => {
expect(result.workspacesBehind).toEqual([{ id: 'ws-2', name: 'Banana' }]);
expect(result.workspacesFailed).toEqual([{ id: 'ws-3', name: 'Cherry' }]);
expect(result.upToDateWorkspaceCount).toBe(5);
expect(result.computedAt).toEqual(computedAt);
expect(getWorkspaceLastAttemptedCommandName).not.toHaveBeenCalled();
expect(cacheWrite).not.toHaveBeenCalled();
@@ -385,10 +390,12 @@ describe('UpgradeStatusService', () => {
expect(result.workspacesBehind).toEqual([{ id: 'ws-2', name: 'Banana' }]);
expect(result.workspacesFailed).toEqual([{ id: 'ws-3', name: 'Cherry' }]);
expect(result.upToDateWorkspaceCount).toBe(1);
expect(cacheWrite).toHaveBeenCalledWith({
behindWorkspaceIds: ['ws-2'],
failedWorkspaceIds: ['ws-3'],
upToDateWorkspaceCount: 1,
computedAt: expect.any(Date),
});
});
@@ -8,6 +8,7 @@ import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/typ
const BEHIND_IDS_KEY = 'upgrade-status:behind-workspace-ids';
const FAILED_IDS_KEY = 'upgrade-status:failed-workspace-ids';
const UP_TO_DATE_COUNT_KEY = 'upgrade-status:up-to-date-workspace-count';
const COMPUTED_AT_KEY = 'upgrade-status:computed-at';
const CACHE_TTL_MS = 60 * 60 * 1000;
@@ -32,13 +33,21 @@ export class UpgradeStatusCacheService {
return this.cacheStorage.setMembers(FAILED_IDS_KEY);
}
async getUpToDateWorkspaceCount(): Promise<number> {
const raw = await this.cacheStorage.get<number>(UP_TO_DATE_COUNT_KEY);
return isDefined(raw) ? raw : 0;
}
async write({
behindWorkspaceIds,
failedWorkspaceIds,
upToDateWorkspaceCount,
computedAt,
}: {
behindWorkspaceIds: string[];
failedWorkspaceIds: string[];
upToDateWorkspaceCount: number;
computedAt: Date;
}): Promise<void> {
await Promise.all([
@@ -57,6 +66,11 @@ export class UpgradeStatusCacheService {
failedWorkspaceIds,
CACHE_TTL_MS,
),
this.cacheStorage.set(
UP_TO_DATE_COUNT_KEY,
upToDateWorkspaceCount,
CACHE_TTL_MS,
),
this.cacheStorage.set(
COMPUTED_AT_KEY,
computedAt.toISOString(),
@@ -69,6 +83,7 @@ export class UpgradeStatusCacheService {
await Promise.all([
this.cacheStorage.del(BEHIND_IDS_KEY),
this.cacheStorage.del(FAILED_IDS_KEY),
this.cacheStorage.del(UP_TO_DATE_COUNT_KEY),
this.cacheStorage.del(COMPUTED_AT_KEY),
]);
}
@@ -45,6 +45,7 @@ export type InstanceAndAllWorkspacesUpgradeStatus = {
instanceUpgradeStatus: InstanceUpgradeStatus;
workspacesBehind: WorkspaceUpgradeRef[];
workspacesFailed: WorkspaceUpgradeRef[];
upToDateWorkspaceCount: number;
computedAt: Date;
};
@@ -144,12 +145,17 @@ export class UpgradeStatusService {
return this.refreshInstanceAndAllWorkspacesStatus();
}
const [instanceUpgradeStatus, behindWorkspaceIds, failedWorkspaceIds] =
await Promise.all([
this.getInstanceStatus(),
this.upgradeStatusCacheService.getBehindWorkspaceIds(),
this.upgradeStatusCacheService.getFailedWorkspaceIds(),
]);
const [
instanceUpgradeStatus,
behindWorkspaceIds,
failedWorkspaceIds,
upToDateWorkspaceCount,
] = await Promise.all([
this.getInstanceStatus(),
this.upgradeStatusCacheService.getBehindWorkspaceIds(),
this.upgradeStatusCacheService.getFailedWorkspaceIds(),
this.upgradeStatusCacheService.getUpToDateWorkspaceCount(),
]);
const workspaceNamesById = await this.loadWorkspaceNamesById([
...behindWorkspaceIds,
@@ -166,6 +172,7 @@ export class UpgradeStatusService {
failedWorkspaceIds,
workspaceNamesById,
),
upToDateWorkspaceCount,
computedAt,
};
}
@@ -180,6 +187,7 @@ export class UpgradeStatusService {
const workspacesBehind: WorkspaceUpgradeRef[] = [];
const workspacesFailed: WorkspaceUpgradeRef[] = [];
let upToDateWorkspaceCount = 0;
for (const workspaceStatus of workspaceStatuses) {
const workspaceRef: WorkspaceUpgradeRef = {
@@ -191,6 +199,8 @@ export class UpgradeStatusService {
workspacesBehind.push(workspaceRef);
} else if (workspaceStatus.health === UpgradeHealthEnum.FAILED) {
workspacesFailed.push(workspaceRef);
} else if (workspaceStatus.health === UpgradeHealthEnum.UP_TO_DATE) {
upToDateWorkspaceCount++;
}
}
@@ -199,6 +209,7 @@ export class UpgradeStatusService {
await this.upgradeStatusCacheService.write({
behindWorkspaceIds: workspacesBehind.map((workspace) => workspace.id),
failedWorkspaceIds: workspacesFailed.map((workspace) => workspace.id),
upToDateWorkspaceCount,
computedAt,
});
@@ -206,6 +217,7 @@ export class UpgradeStatusService {
instanceUpgradeStatus,
workspacesBehind,
workspacesFailed,
upToDateWorkspaceCount,
computedAt,
};
}
@@ -79,6 +79,19 @@ export class UpgradeGaugeService implements OnModuleInit {
},
cacheValue: true,
});
this.metricsService.createObservableGauge({
metricName: 'twenty_upgrade_workspaces_up_to_date_total',
options: {
description: 'Number of workspaces up-to-date on upgrade commands',
},
callback: async () => {
const upgradeStatus = await this.getCachedUpgradeStatus();
return upgradeStatus?.upToDateWorkspaceCount ?? 0;
},
cacheValue: true,
});
}
private async getCachedUpgradeStatus(): Promise<InstanceAndAllWorkspacesUpgradeStatus | null> {