UBERF-8425: Fix get pending workspace on CR (#8009)

This commit is contained in:
Alexey Zinoviev
2025-02-14 18:58:23 +07:00
committed by GitHub
parent 2602c8e61a
commit de7a032a9f
3 changed files with 565 additions and 10 deletions
+267 -2
View File
@@ -14,7 +14,15 @@
//
/* eslint-disable @typescript-eslint/unbound-method */
import { Collection, Db } from 'mongodb'
import { type WorkspaceMode, type WorkspaceUuid, type PersonUuid, SocialIdType, AccountRole } from '@hcengineering/core'
import {
type WorkspaceMode,
type WorkspaceUuid,
type PersonUuid,
SocialIdType,
AccountRole,
type Version,
type Data
} from '@hcengineering/core'
import {
MongoDbCollection,
AccountMongoDbCollection,
@@ -683,7 +691,10 @@ describe('MongoAccountDB', () => {
updateOne: jest.fn(),
insertOne: jest.fn(),
find: jest.fn(),
ensureIndices: jest.fn()
ensureIndices: jest.fn(),
collection: {
findOneAndUpdate: jest.fn()
}
}
mockWorkspaceMembers = {
@@ -856,6 +867,260 @@ describe('MongoAccountDB', () => {
})
})
describe('getPendingWorkspace', () => {
const version: Data<Version> = { major: 1, minor: 0, patch: 0 }
const processingTimeoutMs = 5000
const wsLivenessMs = 300000 // 5 minutes
const NOW = 1234567890000 // Fixed timestamp
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(NOW)
})
afterEach(() => {
jest.restoreAllMocks()
})
it('should get pending creation workspace', async () => {
await accountDb.getPendingWorkspace('', version, 'create', processingTimeoutMs)
expect(accountDb.workspace.collection.findOneAndUpdate).toHaveBeenCalledWith(
{
$and: [
{ 'status.mode': { $ne: 'manual-creation' } },
{ $or: [{ 'status.mode': { $in: ['pending-creation', 'creating'] } }] },
{
$or: [{ 'status.processingAttempts': { $exists: false } }, { 'status.processingAttempts': { $lte: 3 } }]
},
{ $or: [{ region: { $exists: false } }, { region: '' }] },
{
$or: [
{ 'status.lastProcessingTime': { $exists: false } },
{ 'status.lastProcessingTime': { $lt: NOW - processingTimeoutMs } }
]
}
]
},
{
$inc: {
'status.processingAttempts': 1
},
$set: {
'status.lastProcessingTime': NOW
}
},
{
returnDocument: 'after',
sort: {
'status.lastVisit': -1
}
}
)
})
it('should get workspace pending upgrade', async () => {
await accountDb.getPendingWorkspace('', version, 'upgrade', processingTimeoutMs, wsLivenessMs)
expect(accountDb.workspace.collection.findOneAndUpdate).toHaveBeenCalledWith(
{
$and: [
{ 'status.mode': { $ne: 'manual-creation' } },
{
$or: [
{
$and: [
{
$or: [{ 'status.isDisabled': false }, { 'status.isDisabled': { $exists: false } }]
},
{
$or: [{ 'status.mode': 'active' }, { 'status.mode': { $exists: false } }]
},
{
$or: [
{ 'status.versionMajor': { $lt: version.major } },
{ 'status.versionMajor': version.major, 'status.versionMinor': { $lt: version.minor } },
{
'status.versionMajor': version.major,
'status.versionMinor': version.minor,
'status.versionPatch': { $lt: version.patch }
}
]
},
{
'status.lastVisit': { $gt: NOW - wsLivenessMs }
}
]
},
{
$or: [{ 'status.isDisabled': false }, { 'status.isDisabled': { $exists: false } }],
'status.mode': 'upgrading'
}
]
},
{
$or: [{ 'status.processingAttempts': { $exists: false } }, { 'status.processingAttempts': { $lte: 3 } }]
},
{ $or: [{ region: { $exists: false } }, { region: '' }] },
{
$or: [
{ 'status.lastProcessingTime': { $exists: false } },
{ 'status.lastProcessingTime': { $lt: NOW - processingTimeoutMs } }
]
}
]
},
{
$inc: {
'status.processingAttempts': 1
},
$set: {
'status.lastProcessingTime': NOW
}
},
{
returnDocument: 'after',
sort: {
'status.lastVisit': -1
}
}
)
})
it('should get workspace for all+backup operations', async () => {
await accountDb.getPendingWorkspace('', version, 'all+backup', processingTimeoutMs)
expect(accountDb.workspace.collection.findOneAndUpdate).toHaveBeenCalledWith(
{
$and: [
{ 'status.mode': { $ne: 'manual-creation' } },
{
$or: [
{ 'status.mode': { $in: ['pending-creation', 'creating'] } },
{
$and: [
{
$or: [{ 'status.isDisabled': false }, { 'status.isDisabled': { $exists: false } }]
},
{
$or: [{ 'status.mode': 'active' }, { 'status.mode': { $exists: false } }]
},
{
$or: [
{ 'status.versionMajor': { $lt: version.major } },
{ 'status.versionMajor': version.major, 'status.versionMinor': { $lt: version.minor } },
{
'status.versionMajor': version.major,
'status.versionMinor': version.minor,
'status.versionPatch': { $lt: version.patch }
}
]
}
]
},
{
$or: [{ 'status.isDisabled': false }, { 'status.isDisabled': { $exists: false } }],
'status.mode': 'upgrading'
},
{
'status.mode': {
$in: [
'migration-backup',
'migration-pending-backup',
'migration-clean',
'migration-pending-clean'
]
}
},
{
'status.mode': {
$in: [
'archiving-pending-backup',
'archiving-backup',
'archiving-pending-clean',
'archiving-clean'
]
}
},
{ 'status.mode': { $in: ['pending-restore', 'restoring'] } },
{ 'status.mode': { $in: ['pending-deletion', 'deleting'] } }
]
},
{
$or: [{ 'status.processingAttempts': { $exists: false } }, { 'status.processingAttempts': { $lte: 3 } }]
},
{ $or: [{ region: { $exists: false } }, { region: '' }] },
{
$or: [
{ 'status.lastProcessingTime': { $exists: false } },
{ 'status.lastProcessingTime': { $lt: NOW - processingTimeoutMs } }
]
}
]
},
{
$inc: {
'status.processingAttempts': 1
},
$set: {
'status.lastProcessingTime': NOW
}
},
{
returnDocument: 'after',
sort: {
'status.lastVisit': -1
}
}
)
})
it('should filter by region when specified', async () => {
const region = 'us-east-1'
await accountDb.getPendingWorkspace(region, version, 'create', processingTimeoutMs)
expect(accountDb.workspace.collection.findOneAndUpdate).toHaveBeenCalledWith(
{
$and: [
{ 'status.mode': { $ne: 'manual-creation' } },
{ $or: [{ 'status.mode': { $in: ['pending-creation', 'creating'] } }] },
{
$or: [{ 'status.processingAttempts': { $exists: false } }, { 'status.processingAttempts': { $lte: 3 } }]
},
{ region },
{
$or: [
{ 'status.lastProcessingTime': { $exists: false } },
{ 'status.lastProcessingTime': { $lt: NOW - processingTimeoutMs } }
]
}
]
},
{
$inc: {
'status.processingAttempts': 1
},
$set: {
'status.lastProcessingTime': NOW
}
},
{
returnDocument: 'after',
sort: {
'status.lastVisit': -1
}
}
)
})
it('should handle undefined result', async () => {
;(accountDb.workspace.collection.findOneAndUpdate as jest.Mock).mockResolvedValue(null)
const result = await accountDb.getPendingWorkspace('', version, 'create', processingTimeoutMs)
expect(result).toBeUndefined()
})
})
describe('createWorkspace', () => {
it('should create workspace and status', async () => {
const workspaceData = {
+297 -7
View File
@@ -510,6 +510,7 @@ describe('PostgresAccountDB', () => {
describe('getPendingWorkspace', () => {
const version: Data<Version> = { major: 1, minor: 0, patch: 0 }
const processingTimeoutMs = 5000
const wsLivenessMs = 300000 // 5 minutes
const NOW = 1234567890000 // Fixed timestamp
beforeEach(() => {
@@ -524,6 +525,280 @@ describe('PostgresAccountDB', () => {
await accountDb.getPendingWorkspace('', version, 'create', processingTimeoutMs)
expect(mockClient.unsafe.mock.calls[0][0].replace(/\s+/g, ' ')).toEqual(
`SELECT
w.uuid,
w.name,
w.url,
w.branding,
w.location,
w.region,
w.created_by,
w.created_on,
w.billing_account,
json_build_object(
'mode', s.mode,
'processing_progress', s.processing_progress,
'version_major', s.version_major,
'version_minor', s.version_minor,
'version_patch', s.version_patch,
'last_processing_time', s.last_processing_time,
'last_visit', s.last_visit,
'is_disabled', s.is_disabled,
'processing_attempts', s.processing_attempts,
'processing_message', s.processing_message,
'backup_info', s.backup_info
) status
FROM global_account.workspace as w
INNER JOIN global_account.workspace_status as s ON s.workspace_uuid = w.uuid
WHERE s.mode IN ('pending-creation', 'creating')
AND s.mode <> 'manual-creation'
AND (s.processing_attempts IS NULL OR s.processing_attempts <= 3)
AND (s.last_processing_time IS NULL OR s.last_processing_time < $1)
AND (w.region IS NULL OR w.region = '')
ORDER BY s.last_visit DESC
LIMIT 1
FOR UPDATE SKIP LOCKED`.replace(/\s+/g, ' ')
)
expect(mockClient.unsafe.mock.calls[0][1]).toEqual([NOW - processingTimeoutMs])
})
it('should get workspace pending upgrade', async () => {
await accountDb.getPendingWorkspace('', version, 'upgrade', processingTimeoutMs, wsLivenessMs)
expect(
mockClient.unsafe.mock.calls[0][0].replace(/\s+/g, ' ').replace(/\(\s/g, '(').replace(/\s\)/g, ')')
).toEqual(
`SELECT
w.uuid,
w.name,
w.url,
w.branding,
w.location,
w.region,
w.created_by,
w.created_on,
w.billing_account,
json_build_object(
'mode', s.mode,
'processing_progress', s.processing_progress,
'version_major', s.version_major,
'version_minor', s.version_minor,
'version_patch', s.version_patch,
'last_processing_time', s.last_processing_time,
'last_visit', s.last_visit,
'is_disabled', s.is_disabled,
'processing_attempts', s.processing_attempts,
'processing_message', s.processing_message,
'backup_info', s.backup_info
) status
FROM global_account.workspace as w
INNER JOIN global_account.workspace_status as s ON s.workspace_uuid = w.uuid
WHERE (
(
(s.is_disabled = FALSE OR s.is_disabled IS NULL)
AND (s.mode = 'active' OR s.mode IS NULL)
AND (
s.version_major < $1
OR (s.version_major = $1 AND s.version_minor < $2)
OR (s.version_major = $1 AND s.version_minor = $2 AND s.version_patch < $3)
)
AND s.last_visit > $4
)
OR
(
(s.is_disabled = FALSE OR s.is_disabled IS NULL)
AND s.mode = 'upgrading'
)
)
AND s.mode <> 'manual-creation'
AND (s.processing_attempts IS NULL OR s.processing_attempts <= 3)
AND (s.last_processing_time IS NULL OR s.last_processing_time < $5)
AND (w.region IS NULL OR w.region = '')
ORDER BY s.last_visit DESC
LIMIT 1
FOR UPDATE SKIP LOCKED`
.replace(/\s+/g, ' ')
.replace(/\(\s/g, '(')
.replace(/\s\)/g, ')')
)
expect(mockClient.unsafe.mock.calls[0][1]).toEqual([
version.major,
version.minor,
version.patch,
NOW - wsLivenessMs,
NOW - processingTimeoutMs
])
})
it('should get workspace for all operations', async () => {
await accountDb.getPendingWorkspace('', version, 'all', processingTimeoutMs, wsLivenessMs)
expect(
mockClient.unsafe.mock.calls[0][0].replace(/\s+/g, ' ').replace(/\(\s/g, '(').replace(/\s\)/g, ')')
).toEqual(
`SELECT
w.uuid,
w.name,
w.url,
w.branding,
w.location,
w.region,
w.created_by,
w.created_on,
w.billing_account,
json_build_object(
'mode', s.mode,
'processing_progress', s.processing_progress,
'version_major', s.version_major,
'version_minor', s.version_minor,
'version_patch', s.version_patch,
'last_processing_time', s.last_processing_time,
'last_visit', s.last_visit,
'is_disabled', s.is_disabled,
'processing_attempts', s.processing_attempts,
'processing_message', s.processing_message,
'backup_info', s.backup_info
) status
FROM global_account.workspace as w
INNER JOIN global_account.workspace_status as s ON s.workspace_uuid = w.uuid
WHERE (
s.mode IN ('pending-creation', 'creating')
OR
(
(
(s.is_disabled = FALSE OR s.is_disabled IS NULL)
AND (s.mode = 'active' OR s.mode IS NULL)
AND (
s.version_major < $1
OR (s.version_major = $1 AND s.version_minor < $2)
OR (s.version_major = $1 AND s.version_minor = $2 AND s.version_patch < $3)
)
AND s.last_visit > $4
)
OR
(
(s.is_disabled = FALSE OR s.is_disabled IS NULL)
AND s.mode = 'upgrading'
)
)
)
AND s.mode <> 'manual-creation'
AND (s.processing_attempts IS NULL OR s.processing_attempts <= 3)
AND (s.last_processing_time IS NULL OR s.last_processing_time < $5)
AND (w.region IS NULL OR w.region = '')
ORDER BY s.last_visit DESC
LIMIT 1
FOR UPDATE SKIP LOCKED`
.replace(/\s+/g, ' ')
.replace(/\(\s/g, '(')
.replace(/\s\)/g, ')')
)
expect(mockClient.unsafe.mock.calls[0][1]).toEqual([
version.major,
version.minor,
version.patch,
NOW - wsLivenessMs,
NOW - processingTimeoutMs
])
})
it('should get workspace for all+backup operations', async () => {
await accountDb.getPendingWorkspace('', version, 'all+backup', processingTimeoutMs, wsLivenessMs)
expect(
mockClient.unsafe.mock.calls[0][0].replace(/\s+/g, ' ').replace(/\(\s/g, '(').replace(/\s\)/g, ')')
).toEqual(
`SELECT
w.uuid,
w.name,
w.url,
w.branding,
w.location,
w.region,
w.created_by,
w.created_on,
w.billing_account,
json_build_object(
'mode', s.mode,
'processing_progress', s.processing_progress,
'version_major', s.version_major,
'version_minor', s.version_minor,
'version_patch', s.version_patch,
'last_processing_time', s.last_processing_time,
'last_visit', s.last_visit,
'is_disabled', s.is_disabled,
'processing_attempts', s.processing_attempts,
'processing_message', s.processing_message,
'backup_info', s.backup_info
) status
FROM global_account.workspace as w
INNER JOIN global_account.workspace_status as s ON s.workspace_uuid = w.uuid
WHERE (
s.mode IN ('pending-creation', 'creating')
OR
(
(
(s.is_disabled = FALSE OR s.is_disabled IS NULL)
AND (s.mode = 'active' OR s.mode IS NULL)
AND (
s.version_major < $1
OR (s.version_major = $1 AND s.version_minor < $2)
OR (s.version_major = $1 AND s.version_minor = $2 AND s.version_patch < $3)
)
AND s.last_visit > $4
)
OR
(
(s.is_disabled = FALSE OR s.is_disabled IS NULL)
AND s.mode = 'upgrading'
)
)
OR
s.mode IN (
'migration-backup',
'migration-pending-backup',
'migration-clean',
'migration-pending-clean'
)
OR
s.mode IN (
'archiving-pending-backup',
'archiving-backup',
'archiving-pending-clean',
'archiving-clean'
)
OR
s.mode IN ('pending-restore', 'restoring')
OR
s.mode IN ('pending-deletion', 'deleting')
)
AND s.mode <> 'manual-creation'
AND (s.processing_attempts IS NULL OR s.processing_attempts <= 3)
AND (s.last_processing_time IS NULL OR s.last_processing_time < $5)
AND (w.region IS NULL OR w.region = '')
ORDER BY s.last_visit DESC
LIMIT 1
FOR UPDATE SKIP LOCKED`
.replace(/\s+/g, ' ')
.replace(/\(\s/g, '(')
.replace(/\s\)/g, ')')
)
expect(mockClient.unsafe.mock.calls[0][1]).toEqual([
version.major,
version.minor,
version.patch,
NOW - wsLivenessMs,
NOW - processingTimeoutMs
])
})
it('should filter by region when specified', async () => {
const region = 'us-east-1'
await accountDb.getPendingWorkspace(region, version, 'create', processingTimeoutMs)
expect(
mockClient.unsafe.mock.calls[0][0].replace(/\s+/g, ' ').replace(/\(\s/g, '(').replace(/\s\)/g, ')')
).toEqual(
`SELECT
w.uuid,
w.name,
@@ -553,29 +828,44 @@ describe('PostgresAccountDB', () => {
AND s.mode <> 'manual-creation'
AND (s.processing_attempts IS NULL OR s.processing_attempts <= 3)
AND (s.last_processing_time IS NULL OR s.last_processing_time < $1)
AND (w.region IS NULL OR w.region = '')
AND region = $2
ORDER BY s.last_visit DESC
LIMIT 1
FOR UPDATE SKIP LOCKED`.replace(/\s+/g, ' ')
FOR UPDATE SKIP LOCKED`
.replace(/\s+/g, ' ')
.replace(/\(\s/g, '(')
.replace(/\s\)/g, ')')
)
expect(mockClient.unsafe.mock.calls[0][1]).toEqual([NOW - processingTimeoutMs])
expect(mockClient.unsafe.mock.calls[0][1]).toEqual([NOW - processingTimeoutMs, region])
})
// Should also verify update after fetch
it('should update processing attempts and time after fetch', async () => {
const wsUuid = 'ws1'
mockClient.unsafe.mockResolvedValueOnce([{ uuid: wsUuid }]) // Mock the fetch result
mockClient.unsafe.mockResolvedValueOnce([{ uuid: wsUuid }])
await accountDb.getPendingWorkspace('', version, 'create', processingTimeoutMs)
// Verify the update was called
expect(mockClient.unsafe.mock.calls[1][0].replace(/\s+/g, ' ')).toEqual(
expect(
mockClient.unsafe.mock.calls[1][0].replace(/\s+/g, ' ').replace(/\(\s/g, '(').replace(/\s\)/g, ')')
).toEqual(
`UPDATE global_account.workspace_status
SET processing_attempts = processing_attempts + 1, "last_processing_time" = $1
WHERE workspace_uuid = $2`.replace(/\s+/g, ' ')
WHERE workspace_uuid = $2`
.replace(/\s+/g, ' ')
.replace(/\(\s/g, '(')
.replace(/\s\)/g, ')')
)
expect(mockClient.unsafe.mock.calls[1][1]).toEqual([NOW, wsUuid])
})
it('should handle null result', async () => {
mockClient.unsafe.mockResolvedValueOnce([])
const result = await accountDb.getPendingWorkspace('', version, 'create', processingTimeoutMs)
expect(result).toBeUndefined()
})
})
})
+1 -1
View File
@@ -546,7 +546,7 @@ export class PostgresAccountDB implements AccountDB {
const archivingSql =
"s.mode IN ('archiving-pending-backup', 'archiving-backup', 'archiving-pending-clean', 'archiving-clean')"
const versionSql =
'(s.version_major < $1) OR (s.version_major = $1 AND s.version_minor < $2) OR (s.version_major = $1 AND s.version_minor = $2 AND s.version_patch < $3)'
'(s.version_major < $1 OR (s.version_major = $1 AND s.version_minor < $2) OR (s.version_major = $1 AND s.version_minor = $2 AND s.version_patch < $3))'
const pendingUpgradeSql = `(((s.is_disabled = FALSE OR s.is_disabled IS NULL) AND (s.mode = 'active' OR s.mode IS NULL) AND ${versionSql} ${wsLivenessMs !== undefined ? 'AND s.last_visit > $4' : ''}) OR ((s.is_disabled = FALSE OR s.is_disabled IS NULL) AND s.mode = 'upgrading'))`
let operationSql: string = ''
switch (operation) {