Files
speckle-server/packages/server/modules/previews/index.ts
T
Gergő Jedlicska 61609de97e gergo/previews (#3765)
* feat(preview-generator): add new preview generator webapp

* wip(preview-service): reworking the preview service backend

* feat(previews): logging

* feat(preview-service): streamline payloads

* fix(preview-service): do not log the full payload

* feat(preview-service): build new preview service

* feat(preview-service): add separate response queue

* feat(previews): integrate preview queues with the server

* feat(previews): use module alias

* chore(previews): remove old preview service code

* feat(previews): log stuff on job statuses

* fix(previews): add missing deps and scripts

* fix(previews): package deps fix

* fix(server): moar typing fixes

* Metrics related to jobs: total count, request failures, response errors & durations

* duration should include unit.
- histogram metric should be summary
- error responses include duration in seconds
- attempt to remove metric before adding it (prevent errors with duplicate metrics)

* fix(server, frontend): some ts fixes

* fixes

* fix(frontend): remove unneeded ts-expect-error

* chore(preview-service): eslint

* TS fix

* feat(previews): more smoal fixes

* fix(preview-service): alias loading

* feat(helm): updates for new preview service queue setup

* feat(preview-service): launch new browser for each job

* feat(preview-service): add timeout, fix liveliness

* fix(helm): add access to new secret in service accounts

* tidy metrics into a separate file

* Remove broken preview service acceptance test

* fix broken import

* Add metrics to test

* feat(preview-service): handle preview service shutdown properly

* fix(previews): merge bork

---------

Co-authored-by: Iain Sproat <68657+iainsproat@users.noreply.github.com>
Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com>
2025-03-06 14:26:56 +01:00

164 lines
5.5 KiB
TypeScript

/* istanbul ignore file */
import { moduleLogger, previewLogger as logger } from '@/observability/logging'
import { consumePreviewResultFactory } from '@/modules/previews/resultListener'
import { db } from '@/db/knex'
import {
disablePreviews,
getPreviewServiceRedisUrl,
getRedisUrl,
getServerOrigin
} from '@/modules/shared/helpers/envHelper'
import Bull from 'bull'
import Redis, { RedisOptions } from 'ioredis'
import { createBullBoard } from 'bull-board'
import { BullMQAdapter } from 'bull-board/bullMQAdapter'
import { authMiddlewareCreator } from '@/modules/shared/middleware'
import { Roles, TIME } from '@speckle/shared'
import { validateServerRoleBuilderFactory } from '@/modules/shared/authz'
import { getRolesFactory } from '@/modules/shared/repositories/roles'
import { previewRouterFactory } from '@/modules/previews/rest/router'
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { previewResultPayload } from '@speckle/shared/dist/commonjs/previews/job.js'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import {
storePreviewFactory,
upsertObjectPreviewFactory
} from '@/modules/previews/repository/previews'
import { getObjectCommitsWithStreamIdsFactory } from '@/modules/core/repositories/commits'
import prometheusClient from 'prom-client'
import { initializeMetrics } from '@/modules/previews/observability/metrics'
const getPreviewQueues = (params: { responseQueueName: string }) => {
const { responseQueueName } = params
let client: Redis
let subscriber: Redis
const redisUrl = getPreviewServiceRedisUrl() ?? getRedisUrl()
const opts = {
// redisOpts here will contain at least a property of connectionName which will identify the queue based on its name
createClient(type: string, redisOpts: RedisOptions) {
switch (type) {
case 'client':
if (!client) {
client = new Redis(redisUrl, redisOpts)
}
return client
case 'subscriber':
if (!subscriber) {
subscriber = new Redis(redisUrl, {
...redisOpts,
maxRetriesPerRequest: null,
enableReadyCheck: false
})
}
return subscriber
case 'bclient':
return new Redis(redisUrl, {
...redisOpts,
maxRetriesPerRequest: null,
enableReadyCheck: false
})
default:
throw new Error('Unexpected connection type: ' + type)
}
}
}
const previewRequestQueue = new Bull('preview-service-jobs', opts)
// these events are published on the job queue, results come back on the response queue
previewRequestQueue.on('error', (err) => {
logger.error({ err }, 'Preview generation failed')
})
previewRequestQueue.on('failed', (job, err) => {
const jobId = 'jobId' in job.data ? job.data.jobId : undefined
logger.error({ err, jobId }, 'Preview job {jobId} failed.')
})
previewRequestQueue.on('active', (job) => {
const jobId = 'jobId' in job.data ? job.data.jobId : undefined
logger.info({ jobId }, 'Preview job {jobId} processing started.')
})
const previewResponseQueue = new Bull(responseQueueName, opts)
return { previewRequestQueue, previewResponseQueue }
}
export const init: SpeckleModule['init'] = ({ app, isInitial }) => {
if (isInitial) {
if (disablePreviews()) {
moduleLogger.warn('📸 Object preview module is DISABLED')
} else {
moduleLogger.info('📸 Init object preview module')
}
const responseQueueName = `preview-service-results-${
new URL(getServerOrigin()).hostname
}`
const { previewRequestQueue, previewResponseQueue } = getPreviewQueues({
responseQueueName
})
const { previewJobsProcessedSummary } = initializeMetrics({
registers: [prometheusClient.register],
previewRequestQueue
})
const router = createBullBoard([
new BullMQAdapter(previewRequestQueue),
new BullMQAdapter(previewResponseQueue)
]).router
app.use(
'/api/admin/preview-jobs',
async (req, res, next) => {
await authMiddlewareCreator([
validateServerRoleBuilderFactory({ getRoles: getRolesFactory({ db }) })({
requiredRole: Roles.Server.Admin
})
])(req, res, next)
},
router
)
const previewRouter = previewRouterFactory({
previewRequestQueue,
responseQueueName
})
app.use(previewRouter)
previewResponseQueue.process(async (payload, done) => {
const parsedMessage = previewResultPayload.safeParse(payload.data)
if (!parsedMessage.success) {
logger.error(
{ payload: payload.data, reason: parsedMessage.error },
'Failed to parse previewResult payload'
)
done(parsedMessage.error)
return
}
const [projectId, objectId] = parsedMessage.data.jobId.split('.')
const projectDb = await getProjectDbClient({ projectId })
await consumePreviewResultFactory({
logger,
storePreview: storePreviewFactory({ db: projectDb }),
upsertObjectPreview: upsertObjectPreviewFactory({ db: projectDb }),
getObjectCommitsWithStreamIds: getObjectCommitsWithStreamIdsFactory({
db: projectDb
})
})({
projectId,
objectId,
previewResult: parsedMessage.data
})
previewJobsProcessedSummary.observe(
{ status: parsedMessage.data.status },
parsedMessage.data.result.durationSeconds * TIME.second
)
done()
})
}
}
export const finalize = () => {}