diff --git a/packages/backend/server/src/core/doc-service/job.ts b/packages/backend/server/src/core/doc-service/job.ts index af4809fa3..8326628eb 100644 --- a/packages/backend/server/src/core/doc-service/job.ts +++ b/packages/backend/server/src/core/doc-service/job.ts @@ -1,10 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { PrismaClient } from '@prisma/client'; import { JOB_SIGNAL, JobQueue, metrics, OnJob } from '../../base'; import { Models } from '../../models'; -import { PgWorkspaceDocStorageAdapter } from '../doc'; +import { DatabaseDocReader, PgWorkspaceDocStorageAdapter } from '../doc'; declare global { interface Jobs { @@ -13,13 +13,23 @@ declare global { docId: string; }; 'doc.recordPendingDocUpdatesCount': {}; + 'doc.findEmptySummaryDocs': { + lastFixedWorkspaceSid?: number; + }; + 'doc.autoFixedDocSummary': { + workspaceId: string; + docId: string; + }; } } @Injectable() export class DocServiceCronJob { + private readonly logger = new Logger(DocServiceCronJob.name); + constructor( private readonly workspace: PgWorkspaceDocStorageAdapter, + private readonly docReader: DatabaseDocReader, private readonly prisma: PrismaClient, private readonly job: JobQueue, private readonly models: Models @@ -86,4 +96,74 @@ export class DocServiceCronJob { } ); } + + @Cron(CronExpression.EVERY_30_SECONDS) + async scheduleFindEmptySummaryDocs() { + await this.job.add( + 'doc.findEmptySummaryDocs', + {}, + { + // make sure only one job is running at a time + delay: 30 * 1000, + jobId: 'findEmptySummaryDocs', + } + ); + } + + @OnJob('doc.findEmptySummaryDocs') + async findEmptySummaryDocs(payload: Jobs['doc.findEmptySummaryDocs']) { + const startSid = payload.lastFixedWorkspaceSid ?? 0; + const workspaces = await this.models.workspace.list( + { sid: { gt: startSid } }, + { id: true, sid: true }, + 100 + ); + + if (workspaces.length === 0) { + return JOB_SIGNAL.Repeat; + } + + let addedCount = 0; + for (const workspace of workspaces) { + const docIds = await this.models.doc.findEmptySummaryDocIds(workspace.id); + for (const docId of docIds) { + // ignore root doc + if (docId === workspace.id) { + continue; + } + await this.job.add( + 'doc.autoFixedDocSummary', + { workspaceId: workspace.id, docId }, + { + jobId: `autoFixedDocSummary/${workspace.id}/${docId}`, + } + ); + addedCount++; + } + } + + const nextSid = workspaces[workspaces.length - 1].sid; + this.logger.log( + `Auto added ${addedCount} docs to queue, lastFixedWorkspaceSid: ${startSid} -> ${nextSid}` + ); + + // update the lastFixedWorkspaceSid in the payload and repeat the job after 30 seconds + payload.lastFixedWorkspaceSid = nextSid; + return JOB_SIGNAL.Repeat; + } + + @OnJob('doc.autoFixedDocSummary') + async autoFixedDocSummary(payload: Jobs['doc.autoFixedDocSummary']) { + const { workspaceId, docId } = payload; + const content = await this.docReader.getDocContent(workspaceId, docId); + if (!content) { + this.logger.warn( + `Summary for doc ${docId} in workspace ${workspaceId} not found` + ); + return; + } + + await this.models.doc.upsertMeta(workspaceId, docId, content); + return; + } } diff --git a/packages/backend/server/src/models/__tests__/doc.spec.ts b/packages/backend/server/src/models/__tests__/doc.spec.ts new file mode 100644 index 000000000..4e9ec935a --- /dev/null +++ b/packages/backend/server/src/models/__tests__/doc.spec.ts @@ -0,0 +1,47 @@ +import { randomUUID } from 'node:crypto'; + +import test from 'ava'; + +import { createModule } from '../../__tests__/create-module'; +import { Mockers } from '../../__tests__/mocks'; +import { Models } from '..'; + +const module = await createModule({}); + +const models = module.get(Models); +const owner = await module.create(Mockers.User); + +test.after.always(async () => { + await module.close(); +}); + +test('should find null summary doc ids', async t => { + const workspace = await module.create(Mockers.Workspace, { + owner, + }); + + const docId = randomUUID(); + await module.create(Mockers.DocMeta, { + workspaceId: workspace.id, + docId, + }); + + const docIds = await models.doc.findEmptySummaryDocIds(workspace.id); + t.deepEqual(docIds, [docId]); +}); + +test('should ignore summary is not null', async t => { + const workspace = await module.create(Mockers.Workspace, { + owner, + }); + + const docId = randomUUID(); + await module.create(Mockers.DocMeta, { + workspaceId: workspace.id, + docId, + summary: 'test', + }); + + const docIds = await models.doc.findEmptySummaryDocIds(workspace.id); + t.is(docIds.length, 0); +}); diff --git a/packages/backend/server/src/models/doc.ts b/packages/backend/server/src/models/doc.ts index dd2d0588c..e910133a5 100644 --- a/packages/backend/server/src/models/doc.ts +++ b/packages/backend/server/src/models/doc.ts @@ -696,5 +696,18 @@ export class DocModel extends BaseModel { return [count, rows] as const; } + async findEmptySummaryDocIds(workspaceId: string) { + const rows = await this.db.workspaceDoc.findMany({ + where: { + workspaceId, + summary: null, + }, + select: { + docId: true, + }, + }); + return rows.map(row => row.docId); + } + // #endregion }