diff --git a/server/account/src/__tests__/mongo.test.ts b/server/account/src/__tests__/mongo.test.ts index 4faf5d8104..7f8aaabc7e 100644 --- a/server/account/src/__tests__/mongo.test.ts +++ b/server/account/src/__tests__/mongo.test.ts @@ -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 = { 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 = { diff --git a/server/account/src/__tests__/postgres.test.ts b/server/account/src/__tests__/postgres.test.ts index a2412e6a32..99aa2861bf 100644 --- a/server/account/src/__tests__/postgres.test.ts +++ b/server/account/src/__tests__/postgres.test.ts @@ -510,6 +510,7 @@ describe('PostgresAccountDB', () => { describe('getPendingWorkspace', () => { const version: Data = { 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() + }) }) }) diff --git a/server/account/src/collections/postgres.ts b/server/account/src/collections/postgres.ts index e2522e0b5d..4dd210bb67 100644 --- a/server/account/src/collections/postgres.ts +++ b/server/account/src/collections/postgres.ts @@ -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) {