Files
speckle-server/packages/server/modules/multiregion/services/queue.ts
T
Gergő Jedlicska 2fdcf1bd1d refactor(shared): unified queue handling (#4691)
* feat(shared): unified queue initialization in shared

* feat(queues): use the new queue creation everywhere

* chore(shared): move to redis module

* chore(shared): fix export maps

* chore(fileimport): add deps properly

* fix(shared): import fix

* fix(everything): moear imports

* fix(server): cjs imports
2025-05-08 16:58:43 +02:00

280 lines
8.8 KiB
TypeScript

import Bull from 'bull'
import { logger } from '@/observability/logging'
import { isProdEnv, isTestEnv } from '@/modules/shared/helpers/envHelper'
import cryptoRandomString from 'crypto-random-string'
import { Optional, TIME_MS } from '@speckle/shared'
import { UninitializedResourceAccessError } from '@/modules/shared/errors'
import {
MultiRegionInvalidJobError,
MultiRegionNotYetImplementedError
} from '@/modules/multiregion/errors'
import { getProjectDbClient, getRegionDb } from '@/modules/multiregion/utils/dbSelector'
import {
getProjectObjectStorage,
getRegionObjectStorage
} from '@/modules/multiregion/utils/blobStorageSelector'
import {
updateProjectRegionFactory,
validateProjectRegionCopyFactory
} from '@/modules/workspaces/services/projectRegions'
import { db } from '@/db/knex'
import { getProjectFactory } from '@/modules/core/repositories/projects'
import { getAvailableRegionsFactory } from '@/modules/workspaces/services/regions'
import { getRegionsFactory } from '@/modules/multiregion/repositories'
import { canWorkspaceUseRegionsFactory } from '@/modules/gatekeeper/services/featureAuthorization'
import { getWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing'
import {
upsertProjectRegionKeyFactory,
deleteRegionKeyFromCacheFactory
} from '@/modules/multiregion/repositories/projectRegion'
import { updateProjectRegionKeyFactory } from '@/modules/multiregion/services/projectRegion'
import { getGenericRedis } from '@/modules/shared/redis/redis'
import { initializeQueue as setupQueue } from '@speckle/shared/dist/commonjs/queue/index.js'
import { getEventBus } from '@/modules/shared/services/eventBus'
import {
copyWorkspaceFactory,
copyProjectsFactory,
copyProjectModelsFactory,
copyProjectVersionsFactory,
copyProjectObjectsFactory,
copyProjectAutomationsFactory,
copyProjectCommentsFactory,
copyProjectWebhooksFactory,
copyProjectBlobs,
countProjectModelsFactory,
countProjectVersionsFactory,
countProjectObjectsFactory,
countProjectAutomationsFactory,
countProjectCommentsFactory,
countProjectWebhooksFactory
} from '@/modules/workspaces/repositories/projectRegions'
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
import { getRedisUrl } from '@/modules/shared/helpers/envHelper'
const MULTIREGION_QUEUE_NAME = isTestEnv()
? `test:multiregion:${cryptoRandomString({ length: 5 })}`
: 'default:multiregion'
if (isTestEnv()) {
logger.info(`Multiregion test queue ID: ${MULTIREGION_QUEUE_NAME}`)
logger.info(`Monitor using: 'yarn cli bull monitor ${MULTIREGION_QUEUE_NAME}'`)
}
type MultiregionJob =
| {
type: 'move-project-region'
payload: {
projectId: string
regionKey: string
}
}
| {
type: 'delete-project-region-data'
payload: {
projectId: string
regionKey: string
}
}
let queue: Optional<Bull.Queue<MultiregionJob>>
export const getQueue = (): Bull.Queue<MultiregionJob> => {
if (!queue) {
throw new UninitializedResourceAccessError(
'Attempting to use uninitialized Bull queue'
)
}
return queue
}
export const initializeQueue = async () => {
queue = await setupQueue({
queueName: MULTIREGION_QUEUE_NAME,
redisUrl: getRedisUrl(),
options: {
...(!isTestEnv()
? {
limiter: {
max: 10,
duration: TIME_MS.second
}
}
: {}),
defaultJobOptions: {
attempts: 5,
timeout: 15 * TIME_MS.minute,
backoff: {
type: 'fixed',
delay: 5 * TIME_MS.minute
},
removeOnComplete: isProdEnv(),
removeOnFail: false
}
}
})
}
/**
* Add a job to the multiregion job queue.
*/
export const scheduleJob = async (jobData: MultiregionJob): Promise<string> => {
const queue = getQueue()
const job = await queue.add(jobData)
return job.id.toString()
}
const isMultiregionJob = (job: Bull.Job): job is Bull.Job<MultiregionJob> => {
const jobTypes: MultiregionJob['type'][] = [
'move-project-region',
'delete-project-region-data'
]
return !!job.data.type && jobTypes.includes(job.data.type)
}
/**
* Start processing jobs in queue in current process.
*/
export const startQueue = async () => {
const queue = getQueue()
void queue.process(async (job) => {
if (!isMultiregionJob(job)) {
throw new MultiRegionInvalidJobError()
}
logger.info(
{
jobId: job.id,
jobQueue: MULTIREGION_QUEUE_NAME,
payload: job.data.payload,
type: job.data.type
},
'Processing multiregion job {jobId}'
)
switch (job.data.type) {
case 'move-project-region': {
const { projectId, regionKey } = job.data.payload
const sourceDb = await getProjectDbClient({ projectId })
const sourceObjectStorage = await getProjectObjectStorage({ projectId })
const targetDb = await getRegionDb({ regionKey })
const targetObjectStorage = await getRegionObjectStorage({ regionKey })
return await withTransaction(
async ({ db: targetDbTrx }) => {
const updateProjectRegion = updateProjectRegionFactory({
getProject: getProjectFactory({ db: sourceDb }),
getAvailableRegions: getAvailableRegionsFactory({
getRegions: getRegionsFactory({ db }),
canWorkspaceUseRegions: canWorkspaceUseRegionsFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db })
})
}),
copyWorkspace: copyWorkspaceFactory({
sourceDb,
targetDb: targetDbTrx
}),
copyProjects: copyProjectsFactory({
sourceDb,
targetDb: targetDbTrx
}),
copyProjectModels: copyProjectModelsFactory({
sourceDb,
targetDb: targetDbTrx
}),
copyProjectVersions: copyProjectVersionsFactory({
sourceDb,
targetDb: targetDbTrx
}),
copyProjectObjects: copyProjectObjectsFactory({
sourceDb,
targetDb: targetDbTrx
}),
copyProjectAutomations: copyProjectAutomationsFactory({
sourceDb,
targetDb: targetDbTrx
}),
copyProjectComments: copyProjectCommentsFactory({
sourceDb,
targetDb: targetDbTrx
}),
copyProjectWebhooks: copyProjectWebhooksFactory({
sourceDb,
targetDb: targetDbTrx
}),
copyProjectBlobs: copyProjectBlobs({
sourceDb,
sourceObjectStorage,
targetDb: targetDbTrx,
targetObjectStorage
}),
validateProjectRegionCopy: validateProjectRegionCopyFactory({
countProjectModels: countProjectModelsFactory({ db: sourceDb }),
countProjectVersions: countProjectVersionsFactory({ db: sourceDb }),
countProjectObjects: countProjectObjectsFactory({ db: sourceDb }),
countProjectAutomations: countProjectAutomationsFactory({
db: sourceDb
}),
countProjectComments: countProjectCommentsFactory({ db: sourceDb }),
countProjectWebhooks: countProjectWebhooksFactory({ db: sourceDb })
}),
updateProjectRegionKey: updateProjectRegionKeyFactory({
upsertProjectRegionKey: upsertProjectRegionKeyFactory({ db }),
cacheDeleteRegionKey: deleteRegionKeyFromCacheFactory({
redis: getGenericRedis()
}),
emitEvent: getEventBus().emit
})
})
return updateProjectRegion({ projectId, regionKey })
},
{ db: targetDb }
)
}
case 'delete-project-region-data':
default:
throw new MultiRegionNotYetImplementedError()
}
})
void queue.on('completed', (job) => {
const { projectId, regionKey } = job.data.payload
logger.info(
{
jobId: job.id,
jobQueue: MULTIREGION_QUEUE_NAME,
projectId,
regionKey
},
'Completed multiregion job {jobId}'
)
})
void queue.on('failed', (job, err) => {
logger.error(
{
jobId: job.id,
jobQueue: MULTIREGION_QUEUE_NAME,
error: err,
errorMessage: err.message
},
'Failed to process multiregion job {jobId}'
)
})
void queue.on('error', (err) => {
logger.error(
{
jobQueue: MULTIREGION_QUEUE_NAME,
error: err,
errorMessage: err.message
},
'Failed to process multiregion job'
)
})
}
export const shutdownQueue = async () => {
if (!queue) return
await queue.close()
}