feat(server): blob reconciliation (#15165)

#### PR Dependency Tree


* **PR #15165** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added automated backend maintenance for missing blob metadata
backfill, document-to-blob reference rebuilding, and unreferenced blob
cleanup planning/execution.
* Introduced scheduled batch processing (workspace-paged) and paginated
object-storage listing.
* **Bug Fixes**
* Improved reliability of object-storage reads by treating expected “not
found” results as non-errors.
* Strengthened blob/expired cleanup flows with runtime-driven batching
and reduced coupling to metadata synchronization.
* **Tests**
* Expanded unit and e2e coverage for partial blob metadata and updated
runtime/job cleanup test assertions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-29 00:02:38 +08:00
committed by GitHub
parent 4a7c931eca
commit 0a422aa158
42 changed files with 2494 additions and 264 deletions
@@ -2,17 +2,25 @@ import { ScheduleModule } from '@nestjs/schedule';
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import Sinon from 'sinon';
import { AuthModule, AuthService } from '../../core/auth';
import { AuthCronJob } from '../../core/auth/job';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import { createTestingModule } from '../utils';
let m: TestingModule;
let db: PrismaClient;
const runtime = {
cleanupExpiredUserSessions: Sinon.stub(),
};
test.before(async () => {
m = await createTestingModule({
imports: [ScheduleModule.forRoot(), AuthModule],
tapModule: builder => {
builder.overrideProvider(BackendRuntimeProvider).useValue(runtime);
},
});
db = m.get(PrismaClient);
@@ -32,16 +40,17 @@ test('should clean expired user sessions', async t => {
let userSessions = await db.userSession.findMany();
t.is(userSessions.length, 2);
// no expired sessions
runtime.cleanupExpiredUserSessions.reset();
runtime.cleanupExpiredUserSessions.resolves(0);
await job.cleanExpiredUserSessions();
userSessions = await db.userSession.findMany();
t.is(userSessions.length, 2);
t.true(runtime.cleanupExpiredUserSessions.calledOnce);
t.deepEqual(runtime.cleanupExpiredUserSessions.firstCall.args, [1000]);
// clean all expired sessions
await db.userSession.updateMany({
data: { expiresAt: new Date(Date.now() - 1000) },
});
runtime.cleanupExpiredUserSessions.reset();
runtime.cleanupExpiredUserSessions.onCall(0).resolves(1000);
runtime.cleanupExpiredUserSessions.onCall(1).resolves(2);
await job.cleanExpiredUserSessions();
userSessions = await db.userSession.findMany();
t.is(userSessions.length, 0);
t.is(runtime.cleanupExpiredUserSessions.callCount, 2);
t.deepEqual(runtime.cleanupExpiredUserSessions.firstCall.args, [1000]);
t.deepEqual(runtime.cleanupExpiredUserSessions.secondCall.args, [1000]);
});
@@ -1,7 +1,9 @@
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import { DocStorageModule } from '../../core/doc';
import { DocStorageCronJob } from '../../core/doc/job';
import { createTestingModule, type TestingModule } from '../utils';
@@ -10,14 +12,23 @@ interface Context {
module: TestingModule;
db: PrismaClient;
cronJob: DocStorageCronJob;
runtime: { cleanupExpiredSnapshotHistories: Sinon.SinonStub };
}
const test = ava as TestFn<Context>;
// cleanup database before each test
test.before(async t => {
t.context.runtime = {
cleanupExpiredSnapshotHistories: Sinon.stub(),
};
t.context.module = await createTestingModule({
imports: [ScheduleModule.forRoot(), DocStorageModule],
tapModule: builder => {
builder
.overrideProvider(BackendRuntimeProvider)
.useValue(t.context.runtime);
},
});
t.context.db = t.context.module.get(PrismaClient);
@@ -26,6 +37,7 @@ test.before(async t => {
test.beforeEach(async t => {
await t.context.module.initTestingDB();
t.context.runtime.cleanupExpiredSnapshotHistories.reset();
});
test.after.always(async t => {
@@ -33,7 +45,7 @@ test.after.always(async t => {
});
test('should be able to cleanup expired history', async t => {
const { db } = t.context;
const { db, runtime } = t.context;
const timestamp = Date.now();
// insert expired data
@@ -65,12 +77,10 @@ test('should be able to cleanup expired history', async t => {
let count = await db.snapshotHistory.count();
t.is(count, 20);
runtime.cleanupExpiredSnapshotHistories.onCall(0).resolves(1000);
runtime.cleanupExpiredSnapshotHistories.onCall(1).resolves(10);
await t.context.cronJob.cleanExpiredHistories();
count = await db.snapshotHistory.count();
t.is(count, 10);
const example = await db.snapshotHistory.findFirst();
t.truthy(example);
t.true(example!.expiredAt > new Date());
t.is(runtime.cleanupExpiredSnapshotHistories.callCount, 2);
});
@@ -303,28 +303,3 @@ test('should delete userSession fail when sessionId not match', async t => {
);
t.is(count, 0);
});
test('should cleanup expired userSessions', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
});
const session = await t.context.db.session.create({
data: {},
});
const userSession = await t.context.session.createOrRefreshUserSession(
user.id,
session.id
);
await t.context.session.cleanExpiredUserSessions();
let count = await t.context.db.userSession.count();
t.is(count, 1);
// Set expiresAt to past time
await t.context.db.userSession.update({
where: { id: userSession.id },
data: { expiresAt: new Date('2022-01-01') },
});
await t.context.session.cleanExpiredUserSessions();
count = await t.context.db.userSession.count();
t.is(count, 0);
});
@@ -4,6 +4,7 @@ import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { OneDay } from '../../base';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import { StorageModule, WorkspaceBlobStorage } from '../../core/storage';
import { BlobUploadCleanupJob } from '../../core/storage/job';
import { MockUser, MockWorkspace } from '../mocks';
@@ -14,13 +15,22 @@ interface Context {
db: PrismaClient;
job: BlobUploadCleanupJob;
storage: WorkspaceBlobStorage;
runtime: { cleanupExpiredPendingBlobs: Sinon.SinonStub };
}
const test = ava as TestFn<Context>;
test.before(async t => {
t.context.runtime = {
cleanupExpiredPendingBlobs: Sinon.stub(),
};
t.context.module = await createTestingModule({
imports: [ScheduleModule.forRoot(), StorageModule],
tapModule: builder => {
builder
.overrideProvider(BackendRuntimeProvider)
.useValue(t.context.runtime);
},
});
t.context.db = t.context.module.get(PrismaClient);
@@ -30,6 +40,7 @@ test.before(async t => {
test.beforeEach(async t => {
await t.context.module.initTestingDB();
t.context.runtime.cleanupExpiredPendingBlobs.reset();
});
test.after.always(async t => {
@@ -86,24 +97,14 @@ test('should cleanup expired pending blobs', async t => {
],
});
const abortSpy = Sinon.stub(
t.context.storage,
'abortMultipartUpload'
).resolves();
const deleteSpy = Sinon.spy(t.context.storage, 'delete');
t.teardown(() => {
abortSpy.restore();
deleteSpy.restore();
t.context.runtime.cleanupExpiredPendingBlobs.resolves({
scanned: 2,
deleted: 2,
abortedMultipart: 1,
workspaceIds: [workspace.id],
});
await t.context.job.cleanExpiredPendingBlobs();
t.is(abortSpy.callCount, 1);
t.is(deleteSpy.callCount, 2);
const remaining = await t.context.db.blob.findMany({
where: { workspaceId: workspace.id },
});
const remainingKeys = remaining.map(record => record.key).sort();
t.deepEqual(remainingKeys, ['completed-keep', 'pending-active']);
t.true(t.context.runtime.cleanupExpiredPendingBlobs.calledOnce);
});
@@ -119,6 +119,50 @@ test('should list blobs', async t => {
t.deepEqual(ret.map(x => x.key).sort(), [hash1, hash2].sort());
});
test('should keep partial blob metadata listing on DB path without storage scan', async t => {
await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app);
const storage = app.get(WorkspaceBlobStorage);
const rawProvider = (storage as any).provider;
const listSpy = Sinon.spy(rawProvider, 'list');
t.teardown(() => listSpy.restore());
const buffer1 = Buffer.from('with metadata');
const buffer2 = Buffer.from('without metadata');
const key1 = sha256Base64urlWithPadding(buffer1);
const key2 = sha256Base64urlWithPadding(buffer2);
const config = app.get(Config);
const factory = app.get(StorageProviderFactory);
const provider = factory.create(config.storages.blob.storage);
await provider.put(`${workspace.id}/${key1}`, buffer1, {
contentType: 'text/plain',
contentLength: buffer1.length,
});
await provider.put(`${workspace.id}/${key2}`, buffer2, {
contentType: 'text/plain',
contentLength: buffer2.length,
});
const blobModel = app.get(BlobModel);
await blobModel.upsert({
workspaceId: workspace.id,
key: key1,
mime: 'text/plain',
size: buffer1.length,
status: 'completed',
uploadId: null,
});
const listed = await storage.list(workspace.id);
t.deepEqual(
listed.map(blob => blob.key),
[key1]
);
t.true(listSpy.notCalled);
});
test('should create pending blob upload with graphql fallback', async t => {
await app.signupV1('u1@affine.pro');
@@ -221,10 +265,13 @@ test('should auto delete blobs when workspace is deleted', async t => {
const blobs = await listBlobs(app, workspace.id);
t.is(blobs.length, 2);
const workspaceBlobStorage = Sinon.spy(app.get(WorkspaceBlobStorage));
const storage = app.get(WorkspaceBlobStorage);
const rawProvider = (storage as any).provider;
const listSpy = Sinon.spy(rawProvider, 'list');
t.teardown(() => listSpy.restore());
await deleteWorkspace(app, workspace.id);
// should not emit workspace.blob.sync event
t.is(workspaceBlobStorage.syncBlobMeta.callCount, 0);
t.is(listSpy.callCount, 0);
});
test('should calc blobs size', async t => {
@@ -30,6 +30,7 @@ import { RateLimiterModule } from './base/throttler';
import { WebSocketModule } from './base/websocket';
import { AccessTokenModule } from './core/access-token';
import { AuthModule } from './core/auth';
import { BackendRuntimeModule } from './core/backend-runtime';
import { CommentModule } from './core/comment';
import { ServerConfigModule, ServerConfigResolverModule } from './core/config';
import { DocStorageModule } from './core/doc';
@@ -120,6 +121,7 @@ export const FunctionalityModules = [
JobModule.forRoot(),
RealtimeModule,
ModelsModule,
BackendRuntimeModule,
ScheduleModule.forRoot(),
MonitorModule,
];
@@ -94,4 +94,12 @@ defineModuleConfig('job', {
},
schema,
},
'queues.backendRuntime': {
desc: 'The config for backend runtime job queue',
default: {
concurrency: 1,
},
schema,
},
});
@@ -29,6 +29,7 @@ export enum Queue {
COPILOT = 'copilot',
INDEXER = 'indexer',
CALENDAR = 'calendar',
BACKENDRUNTIME = 'backendRuntime',
}
export const QUEUES = Object.values(Queue);
@@ -2,6 +2,7 @@ import './config';
import { Module } from '@nestjs/common';
import { BackendRuntimeModule } from '../backend-runtime';
import { FeatureModule } from '../features';
import { MailModule } from '../mail';
import { QuotaModule } from '../quota';
@@ -20,7 +21,13 @@ import { AuthService } from './service';
import { SessionIssuer } from './session-issuer';
@Module({
imports: [FeatureModule, UserModule, QuotaModule, MailModule],
imports: [
BackendRuntimeModule,
FeatureModule,
UserModule,
QuotaModule,
MailModule,
],
providers: [
AuthService,
AuthResolver,
+6 -3
View File
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { JobQueue, OnJob } from '../../base';
import { Models } from '../../models';
import { BackendRuntimeProvider } from '../backend-runtime';
declare global {
interface Jobs {
@@ -13,7 +13,7 @@ declare global {
@Injectable()
export class AuthCronJob {
constructor(
private readonly models: Models,
private readonly rt: BackendRuntimeProvider,
private readonly queue: JobQueue
) {}
@@ -31,6 +31,9 @@ export class AuthCronJob {
@OnJob('nightly.cleanExpiredUserSessions')
async cleanExpiredUserSessions() {
await this.models.session.cleanExpiredUserSessions();
for (;;) {
const count = await this.rt.cleanupExpiredUserSessions(1000);
if (count < 1000) break;
}
}
}
@@ -0,0 +1,57 @@
import { ScheduleModule } from '@nestjs/schedule';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { BackendRuntimeModule, BackendRuntimeProvider } from '../index';
import { BackendRuntimeHousekeepingJob } from '../job';
interface Context {
module: TestingModule;
job: BackendRuntimeHousekeepingJob;
runtime: {
cleanupExpiredRuntimeStates: Sinon.SinonStub;
cleanupExpiredRuntimeGates: Sinon.SinonStub;
};
}
const test = ava as TestFn<Context>;
test.before(async t => {
t.context.runtime = {
cleanupExpiredRuntimeStates: Sinon.stub(),
cleanupExpiredRuntimeGates: Sinon.stub(),
};
t.context.module = await createTestingModule({
imports: [ScheduleModule.forRoot(), BackendRuntimeModule],
tapModule: builder => {
builder
.overrideProvider(BackendRuntimeProvider)
.useValue(t.context.runtime);
},
});
t.context.job = t.context.module.get(BackendRuntimeHousekeepingJob);
});
test.beforeEach(t => {
t.context.runtime.cleanupExpiredRuntimeStates.reset();
t.context.runtime.cleanupExpiredRuntimeGates.reset();
});
test.after.always(async t => {
await t.context.module.close();
});
test('backend-runtime housekeeping cleans runtime state and gate batches', async t => {
t.context.runtime.cleanupExpiredRuntimeStates.onCall(0).resolves(1000);
t.context.runtime.cleanupExpiredRuntimeStates.onCall(1).resolves(2);
t.context.runtime.cleanupExpiredRuntimeGates.resolves(1);
await t.context.job.cleanExpiredRuntimeHousekeeping();
t.is(t.context.runtime.cleanupExpiredRuntimeStates.callCount, 2);
t.is(t.context.runtime.cleanupExpiredRuntimeGates.callCount, 1);
});
@@ -0,0 +1,43 @@
import test from 'ava';
import Sinon from 'sinon';
import { BackendRuntimeProvider } from '../provider';
test('backend-runtime provider starts once, runs migrations once, and reports health', async t => {
const provider = new BackendRuntimeProvider();
const runtime = {
start: Sinon.stub().resolves(),
stop: Sinon.stub().resolves(),
runMigrations: Sinon.stub().resolves(),
health: Sinon.stub().resolves({
started: true,
databaseConnected: true,
objectStorageConfigured: true,
}),
};
(provider as any).runtime = runtime;
await provider.start();
await provider.start();
const health = await provider.health();
await provider.stop();
t.is(runtime.start.callCount, 2);
t.is(runtime.runMigrations.callCount, 1);
t.true(health.databaseConnected);
t.true(health.objectStorageConfigured);
t.is(runtime.stop.callCount, 1);
});
test('backend-runtime provider measures explicit typed methods', async t => {
const provider = new BackendRuntimeProvider();
const runtime = {
cleanupExpiredRuntimeStates: Sinon.stub().resolves(3),
};
(provider as any).runtime = runtime;
const result = await provider.cleanupExpiredRuntimeStates(1000);
t.is(result, 3);
t.true(runtime.cleanupExpiredRuntimeStates.calledOnceWithExactly(1000));
});
@@ -0,0 +1,368 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import { EventBus, JobQueue, OnJob } from '../../base';
import { BackendRuntimeProvider } from './provider';
declare global {
interface Jobs {
'backendRuntime.backfillMissingBlobMetadata': {
workspaceId: string;
limit?: number;
};
'backendRuntime.backfillMissingBlobMetadataBySid': {
lastSid?: number;
workspaceLimit?: number;
objectLimit?: number;
};
'backendRuntime.rebuildWorkspaceDocBlobRefs': {
workspaceId: string;
limit?: number;
};
'backendRuntime.rebuildWorkspaceDocBlobRefsBySid': {
lastSid?: number;
workspaceLimit?: number;
docLimit?: number;
};
'backendRuntime.planUnreferencedWorkspaceBlobs': {
workspaceId: string;
gracePeriodDays?: number;
limit?: number;
};
'backendRuntime.planUnreferencedWorkspaceBlobsBySid': {
lastSid?: number;
workspaceLimit?: number;
gracePeriodDays?: number;
limit?: number;
};
'backendRuntime.executeBlobCleanupCandidates': {
runId: string;
gracePeriodDays?: number;
limit?: number;
};
}
}
@Injectable()
export class BackendRuntimeBlobJob {
private readonly logger = new Logger(BackendRuntimeBlobJob.name);
constructor(
private readonly rt: BackendRuntimeProvider,
private readonly event: EventBus,
private readonly queue: JobQueue,
private readonly db: PrismaClient
) {}
async enqueueBackfillMissingBlobMetadata(workspaceId: string, limit = 1000) {
await this.queue.add('backendRuntime.backfillMissingBlobMetadata', {
workspaceId,
limit,
});
}
async enqueueBackfillMissingBlobMetadataBySid(
lastSid = 0,
workspaceLimit = 100,
objectLimit = 1000
) {
await this.queue.add('backendRuntime.backfillMissingBlobMetadataBySid', {
lastSid,
workspaceLimit,
objectLimit,
});
}
async enqueueRebuildWorkspaceDocBlobRefs(workspaceId: string, limit = 1000) {
await this.queue.add('backendRuntime.rebuildWorkspaceDocBlobRefs', {
workspaceId,
limit,
});
}
async enqueueRebuildWorkspaceDocBlobRefsBySid(
lastSid = 0,
workspaceLimit = 100,
docLimit = 1000
) {
await this.queue.add('backendRuntime.rebuildWorkspaceDocBlobRefsBySid', {
lastSid,
workspaceLimit,
docLimit,
});
}
@OnJob('backendRuntime.backfillMissingBlobMetadataBySid')
async backfillMissingBlobMetadataBySid({
lastSid = 0,
workspaceLimit = 100,
objectLimit = 1000,
}: Jobs['backendRuntime.backfillMissingBlobMetadataBySid']) {
const workspaces = await this.db.workspace.findMany({
where: { sid: { gt: lastSid } },
orderBy: { sid: 'asc' },
select: { id: true, sid: true },
take: workspaceLimit,
});
for (const workspace of workspaces) {
await this.drainBlobMetadataBackfill(workspace.id, objectLimit, {
sid: workspace.sid,
});
}
const nextSid = workspaces.at(-1)?.sid;
if (nextSid !== undefined && workspaces.length === workspaceLimit) {
await this.enqueueBackfillMissingBlobMetadataBySid(
nextSid,
workspaceLimit,
objectLimit
);
}
}
async enqueuePlanUnreferencedWorkspaceBlobs(
workspaceId: string,
gracePeriodDays = 30,
limit = 1000
) {
await this.queue.add('backendRuntime.planUnreferencedWorkspaceBlobs', {
workspaceId,
gracePeriodDays,
limit,
});
}
async enqueuePlanUnreferencedWorkspaceBlobsBySid(
lastSid = 0,
workspaceLimit = 100,
gracePeriodDays = 30,
limit = 1000
) {
await this.queue.add('backendRuntime.planUnreferencedWorkspaceBlobsBySid', {
lastSid,
workspaceLimit,
gracePeriodDays,
limit,
});
}
async enqueueExecuteBlobCleanupCandidates(
runId: string,
gracePeriodDays = 30,
limit = 1000
) {
await this.queue.add('backendRuntime.executeBlobCleanupCandidates', {
runId,
gracePeriodDays,
limit,
});
}
@Cron(CronExpression.EVERY_DAY_AT_1AM)
async dailyBlobMetadataBackfill() {
await this.queue.add(
'backendRuntime.backfillMissingBlobMetadataBySid',
{},
{ jobId: 'daily-backend-runtime-blob-metadata-backfill' }
);
}
@Cron(CronExpression.EVERY_DAY_AT_2AM)
async dailyDocBlobRefsRebuild() {
await this.queue.add(
'backendRuntime.rebuildWorkspaceDocBlobRefsBySid',
{},
{ jobId: 'daily-backend-runtime-doc-blob-refs-rebuild' }
);
}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async dailyBlobCleanupPlanning() {
await this.queue.add(
'backendRuntime.planUnreferencedWorkspaceBlobsBySid',
{},
{ jobId: 'daily-backend-runtime-blob-cleanup-planning' }
);
}
@OnJob('backendRuntime.backfillMissingBlobMetadata')
async backfillMissingBlobMetadata({
workspaceId,
limit = 1000,
}: Jobs['backendRuntime.backfillMissingBlobMetadata']) {
await this.drainBlobMetadataBackfill(workspaceId, limit);
}
@OnJob('backendRuntime.rebuildWorkspaceDocBlobRefs')
async rebuildWorkspaceDocBlobRefs({
workspaceId,
limit = 1000,
}: Jobs['backendRuntime.rebuildWorkspaceDocBlobRefs']) {
await this.drainWorkspaceDocBlobRefs(workspaceId, limit);
}
@OnJob('backendRuntime.rebuildWorkspaceDocBlobRefsBySid')
async rebuildWorkspaceDocBlobRefsBySid({
lastSid = 0,
workspaceLimit = 100,
docLimit = 1000,
}: Jobs['backendRuntime.rebuildWorkspaceDocBlobRefsBySid']) {
const workspaces = await this.db.workspace.findMany({
where: {
sid: {
gt: lastSid,
},
},
orderBy: {
sid: 'asc',
},
select: {
id: true,
sid: true,
},
take: workspaceLimit,
});
for (const workspace of workspaces) {
await this.drainWorkspaceDocBlobRefs(workspace.id, docLimit, {
sid: workspace.sid,
});
}
const nextSid = workspaces.at(-1)?.sid;
if (nextSid !== undefined && workspaces.length === workspaceLimit) {
await this.enqueueRebuildWorkspaceDocBlobRefsBySid(
nextSid,
workspaceLimit,
docLimit
);
}
}
@OnJob('backendRuntime.planUnreferencedWorkspaceBlobs')
async planUnreferencedWorkspaceBlobs({
workspaceId,
gracePeriodDays = 30,
limit = 1000,
}: Jobs['backendRuntime.planUnreferencedWorkspaceBlobs']) {
const result = await this.rt.planUnreferencedWorkspaceBlobs(
workspaceId,
gracePeriodDays,
limit
);
this.logger.log(
`planned blob cleanup workspace=${workspaceId} run=${result.runId} candidates=${result.candidatesMarked} scanned=${result.scannedBlobs}`
);
}
@OnJob('backendRuntime.planUnreferencedWorkspaceBlobsBySid')
async planUnreferencedWorkspaceBlobsBySid({
lastSid = 0,
workspaceLimit = 100,
gracePeriodDays = 30,
limit = 1000,
}: Jobs['backendRuntime.planUnreferencedWorkspaceBlobsBySid']) {
const workspaces = await this.db.workspace.findMany({
where: {
sid: {
gt: lastSid,
},
},
orderBy: {
sid: 'asc',
},
select: {
id: true,
sid: true,
},
take: workspaceLimit,
});
for (const workspace of workspaces) {
const result = await this.rt.planUnreferencedWorkspaceBlobs(
workspace.id,
gracePeriodDays,
limit
);
this.logger.log(
`planned blob cleanup workspace=${workspace.id} sid=${workspace.sid} run=${result.runId} candidates=${result.candidatesMarked} scanned=${result.scannedBlobs}`
);
}
const nextSid = workspaces.at(-1)?.sid;
if (nextSid !== undefined && workspaces.length === workspaceLimit) {
await this.enqueuePlanUnreferencedWorkspaceBlobsBySid(
nextSid,
workspaceLimit,
gracePeriodDays,
limit
);
}
}
@OnJob('backendRuntime.executeBlobCleanupCandidates')
async executeBlobCleanupCandidates({
runId,
gracePeriodDays = 30,
limit = 1000,
}: Jobs['backendRuntime.executeBlobCleanupCandidates']) {
const result = await this.rt.executeBlobCleanupCandidates(
runId,
gracePeriodDays,
limit
);
await Promise.all(
result.workspaceIds.map(workspaceId =>
this.event.emitAsync('workspace.blobs.updated', { workspaceId })
)
);
this.logger.log(
`executed blob cleanup run=${runId} deleted=${result.deletedObjects} skipped=${result.skippedStillReferenced} failed=${result.failed}`
);
}
private async drainBlobMetadataBackfill(
workspaceId: string,
limit: number,
context: { sid?: number } = {}
) {
for (;;) {
const result = await this.rt.backfillMissingBlobMetadata(
workspaceId,
limit
);
await Promise.all(
result.workspaceIds.map(workspaceId =>
this.event.emitAsync('workspace.blobs.updated', { workspaceId })
)
);
this.logger.log(
`backfilled blob metadata workspace=${workspaceId}${context.sid === undefined ? '' : ` sid=${context.sid}`} upserted=${result.upsertedMetadata} scanned=${result.scannedObjects}`
);
if (!result.nextCursor) {
break;
}
}
}
private async drainWorkspaceDocBlobRefs(
workspaceId: string,
limit: number,
context: { sid?: number } = {}
) {
for (;;) {
const result = await this.rt.rebuildWorkspaceDocBlobRefs(
workspaceId,
limit
);
this.logger.log(
`rebuilt doc blob refs workspace=${workspaceId}${context.sid === undefined ? '' : ` sid=${context.sid}`} parsed=${result.parsedDocs} failed=${result.failedDocs}`
);
if (!result.nextCursor) {
break;
}
}
}
}
@@ -0,0 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { BackendRuntimeBlobJob } from './blob-job';
import { BackendRuntimeHousekeepingJob } from './job';
import { BackendRuntimeProvider } from './provider';
@Global()
@Module({
providers: [
BackendRuntimeProvider,
BackendRuntimeBlobJob,
BackendRuntimeHousekeepingJob,
],
exports: [BackendRuntimeProvider, BackendRuntimeBlobJob],
})
export class BackendRuntimeModule {}
export { BackendRuntimeProvider } from './provider';
@@ -0,0 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { JobQueue, OnJob } from '../../base';
import { BackendRuntimeProvider } from './provider';
declare global {
interface Jobs {
'nightly.cleanExpiredBackendRuntimeHousekeeping': {};
}
}
@Injectable()
export class BackendRuntimeHousekeepingJob {
private readonly logger = new Logger(BackendRuntimeHousekeepingJob.name);
constructor(
private readonly rt: BackendRuntimeProvider,
private readonly queue: JobQueue
) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async nightlyJob() {
await this.queue.add(
'nightly.cleanExpiredBackendRuntimeHousekeeping',
{},
{
jobId: 'nightly-backend-runtime-housekeeping',
}
);
}
@OnJob('nightly.cleanExpiredBackendRuntimeHousekeeping')
async cleanExpiredRuntimeHousekeeping() {
const states = await this.cleanBatches(() =>
this.rt.cleanupExpiredRuntimeStates(1000)
);
const gates = await this.cleanBatches(() =>
this.rt.cleanupExpiredRuntimeGates(1000)
);
this.logger.log(
`cleaned runtime housekeeping states=${states} gates=${gates}`
);
}
private async cleanBatches(fn: () => Promise<number>) {
let total = 0;
for (;;) {
const count = Number(await fn());
total += count;
if (count < 1000) {
break;
}
}
return total;
}
}
@@ -0,0 +1,137 @@
import {
Injectable,
Logger,
type OnApplicationBootstrap,
type OnApplicationShutdown,
} from '@nestjs/common';
import { wrapCallMetric } from '../../base/metrics';
import { BackendRuntime, type BackendRuntimeHealth } from '../../native';
type RuntimeInstance = InstanceType<typeof BackendRuntime>;
@Injectable()
export class BackendRuntimeProvider
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger(BackendRuntimeProvider.name);
private readonly runtime: RuntimeInstance = new BackendRuntime();
private migrationsStarted = false;
async onApplicationBootstrap() {
await this.start();
}
async onApplicationShutdown() {
await this.stop();
}
async start() {
await this.runtime.start();
await this.runMigrationsOnce();
const health = await this.runtime.health();
this.logger.log(
`backend runtime started: db=${health.databaseConnected} objectStorage=${health.objectStorageConfigured}`
);
}
async stop() {
await this.runtime.stop();
this.logger.log('backend runtime stopped');
}
async health(): Promise<BackendRuntimeHealth> {
return await this.runtime.health();
}
async cleanupExpiredPendingBlobs(cutoffMs: number, limit: number) {
return await this.measured('cleanupExpiredPendingBlobs', rt =>
rt.cleanupExpiredPendingBlobs(cutoffMs, limit)
);
}
async releaseDeletedBlobs(workspaceId: string, limit: number) {
return await this.measured('releaseDeletedBlobs', rt =>
rt.releaseDeletedBlobs(workspaceId, limit)
);
}
async cleanupExpiredSnapshotHistories(limit: number) {
return await this.measured('cleanupExpiredSnapshotHistories', rt =>
rt.cleanupExpiredSnapshotHistories(limit)
);
}
async cleanupExpiredUserSessions(limit: number) {
return await this.measured('cleanupExpiredUserSessions', rt =>
rt.cleanupExpiredUserSessions(limit)
);
}
async cleanupExpiredRuntimeStates(limit: number) {
return await this.measured('cleanupExpiredRuntimeStates', rt =>
rt.cleanupExpiredRuntimeStates(limit)
);
}
async cleanupExpiredRuntimeGates(limit: number) {
return await this.measured('cleanupExpiredRuntimeGates', rt =>
rt.cleanupExpiredRuntimeGates(limit)
);
}
async backfillMissingBlobMetadata(
workspaceId: string | null | undefined,
limit: number
) {
return await this.measured('backfillMissingBlobMetadata', rt =>
rt.backfillMissingBlobMetadata(workspaceId, limit)
);
}
async rebuildWorkspaceDocBlobRefs(workspaceId: string, limit: number) {
return await this.measured('rebuildWorkspaceDocBlobRefs', rt =>
rt.rebuildWorkspaceDocBlobRefs(workspaceId, limit)
);
}
async planUnreferencedWorkspaceBlobs(
workspaceId: string,
gracePeriodDays: number,
limit: number
) {
return await this.measured('planUnreferencedWorkspaceBlobs', rt =>
rt.planUnreferencedWorkspaceBlobs(workspaceId, gracePeriodDays, limit)
);
}
async executeBlobCleanupCandidates(
runId: string,
gracePeriodDays: number,
limit: number
) {
return await this.measured('executeBlobCleanupCandidates', rt =>
rt.executeBlobCleanupCandidates(runId, gracePeriodDays, limit)
);
}
private async measured<T>(
method: string,
fn: (runtime: RuntimeInstance) => Promise<T>
): Promise<T> {
return await wrapCallMetric(
() => fn(this.runtime),
'storage',
'backend_runtime',
{ method }
)();
}
private async runMigrationsOnce() {
if (this.migrationsStarted) {
return;
}
await this.runtime.runMigrations();
this.migrationsStarted = true;
}
}
@@ -2,6 +2,7 @@ import './config';
import { Module } from '@nestjs/common';
import { BackendRuntimeModule } from '../backend-runtime';
import { PermissionModule } from '../permission';
import { QuotaModule } from '../quota';
import { StorageModule } from '../storage';
@@ -14,7 +15,7 @@ import { DatabaseDocReader, DocReader, DocReaderProvider } from './reader';
import { DocWriter } from './writer';
@Module({
imports: [QuotaModule, PermissionModule, StorageModule],
imports: [BackendRuntimeModule, QuotaModule, PermissionModule, StorageModule],
providers: [
DocStorageOptions,
PgWorkspaceDocStorageAdapter,
+6 -3
View File
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { JobQueue, OnJob } from '../../base';
import { Models } from '../../models';
import { BackendRuntimeProvider } from '../backend-runtime';
declare global {
interface Jobs {
@@ -13,7 +13,7 @@ declare global {
@Injectable()
export class DocStorageCronJob {
constructor(
private readonly models: Models,
private readonly rt: BackendRuntimeProvider,
private readonly queue: JobQueue
) {}
@@ -30,6 +30,9 @@ export class DocStorageCronJob {
@OnJob('nightly.cleanExpiredHistories')
async cleanExpiredHistories() {
await this.models.history.cleanExpired();
for (;;) {
const count = await this.rt.cleanupExpiredSnapshotHistories(1000);
if (count < 1000) break;
}
}
}
@@ -2,6 +2,7 @@ import './config';
import { Module } from '@nestjs/common';
import { BackendRuntimeModule } from '../backend-runtime';
import { BlobUploadCleanupJob } from './job';
import { R2UploadController } from './r2-proxy';
import {
@@ -11,6 +12,7 @@ import {
} from './wrappers';
@Module({
imports: [BackendRuntimeModule],
controllers: [R2UploadController],
providers: [
WorkspaceBlobStorage,
+21 -18
View File
@@ -1,9 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { JobQueue, OneDay, OnJob } from '../../base';
import { Models } from '../../models';
import { WorkspaceBlobStorage } from './wrappers/blob';
import { EventBus, JobQueue, OneDay, OnJob } from '../../base';
import { BackendRuntimeProvider } from '../backend-runtime';
declare global {
interface Jobs {
@@ -16,8 +15,8 @@ export class BlobUploadCleanupJob {
private readonly logger = new Logger(BlobUploadCleanupJob.name);
constructor(
private readonly models: Models,
private readonly storage: WorkspaceBlobStorage,
private readonly rt: BackendRuntimeProvider,
private readonly event: EventBus,
private readonly queue: JobQueue
) {}
@@ -34,21 +33,25 @@ export class BlobUploadCleanupJob {
@OnJob('nightly.cleanExpiredPendingBlobs')
async cleanExpiredPendingBlobs() {
const cutoff = new Date(Date.now() - OneDay);
const pending = await this.models.blob.listPendingExpired(cutoff);
for (const blob of pending) {
if (blob.uploadId) {
await this.storage.abortMultipartUpload(
blob.workspaceId,
blob.key,
blob.uploadId
);
const cutoff = Date.now() - OneDay;
let scanned = 0;
let deleted = 0;
for (;;) {
const result = await this.rt.cleanupExpiredPendingBlobs(cutoff, 1000);
scanned += result.scanned;
deleted += result.deleted;
await Promise.all(
result.workspaceIds.map(workspaceId =>
this.event.emitAsync('workspace.blobs.updated', { workspaceId })
)
);
if (result.scanned < 1000) {
break;
}
await this.storage.delete(blob.workspaceId, blob.key, true);
}
this.logger.log(`cleaned ${pending.length} expired pending blobs`);
this.logger.log(
`cleaned ${deleted} expired pending blobs, scanned ${scanned}`
);
}
}
@@ -7,7 +7,6 @@ import {
Config,
EventBus,
type GetObjectMetadata,
ListObjectsMetadata,
OnEvent,
PutObjectMetadata,
type StorageProvider,
@@ -15,13 +14,10 @@ import {
URLHelper,
} from '../../../base';
import { Models } from '../../../models';
import { BackendRuntimeProvider } from '../../backend-runtime';
declare global {
interface Events {
'workspace.blob.sync': {
workspaceId: string;
key: string;
};
'workspace.blob.delete': {
workspaceId: string;
key: string;
@@ -57,7 +53,8 @@ export class WorkspaceBlobStorage {
private readonly event: EventBus,
private readonly storageFactory: StorageProviderFactory,
private readonly models: Models,
private readonly url: URLHelper
private readonly url: URLHelper,
private readonly rt: BackendRuntimeProvider
) {}
@OnEvent('config.init')
@@ -223,34 +220,8 @@ export class WorkspaceBlobStorage {
return { ok: true, metadata };
}
async list(workspaceId: string, syncBlobMeta = true) {
const blobsInDb = await this.models.blob.list(workspaceId);
if (blobsInDb.length > 0) {
return blobsInDb;
}
// all blobs are uploading but not completed yet
const hasDbBlobs = await this.models.blob.hasAny(workspaceId);
if (hasDbBlobs) {
return blobsInDb;
}
const blobs = await this.provider.list(workspaceId + '/');
blobs.forEach(blob => {
blob.key = blob.key.slice(workspaceId.length + 1);
});
if (syncBlobMeta) {
this.trySyncBlobsMeta(workspaceId, blobs);
}
return blobs.map(blob => ({
key: blob.key,
size: blob.contentLength,
createdAt: blob.lastModified,
mime: 'application/octet-stream',
}));
async list(workspaceId: string) {
return await this.models.blob.list(workspaceId);
}
async delete(workspaceId: string, key: string, permanently = false) {
@@ -264,17 +235,17 @@ export class WorkspaceBlobStorage {
}
async release(workspaceId: string) {
const deletedBlobs = await this.models.blob.listDeleted(workspaceId);
deletedBlobs.forEach(blob => {
this.event.emit('workspace.blob.delete', {
workspaceId: workspaceId,
key: blob.key,
});
});
let scanned = 0;
let deleted = 0;
for (;;) {
const result = await this.rt.releaseDeletedBlobs(workspaceId, 1000);
scanned += result.scanned;
deleted += result.deleted;
if (result.scanned < 1000) break;
}
this.logger.log(
`released ${deletedBlobs.length} blobs for workspace ${workspaceId}`
`released ${deleted}/${scanned} blobs for workspace ${workspaceId}`
);
await this.event.emitAsync('workspace.blobs.updated', { workspaceId });
@@ -291,15 +262,6 @@ export class WorkspaceBlobStorage {
return this.url.link(`/api/workspaces/${workspaceId}/blobs/${avatarKey}`);
}
private trySyncBlobsMeta(workspaceId: string, blobs: ListObjectsMetadata[]) {
for (const blob of blobs) {
this.event.emit('workspace.blob.sync', {
workspaceId,
key: blob.key,
});
}
}
private async upsert(
workspaceId: string,
key: string,
@@ -315,26 +277,9 @@ export class WorkspaceBlobStorage {
});
}
@OnEvent('workspace.blob.sync')
async syncBlobMeta({ workspaceId, key }: Events['workspace.blob.sync']) {
try {
const meta = await this.provider.head(`${workspaceId}/${key}`);
if (meta) {
await this.upsert(workspaceId, key, meta);
} else {
await this.models.blob.delete(workspaceId, key, true);
}
} catch (e) {
// never throw
this.logger.error('failed to sync blob meta to DB', e);
}
}
@OnEvent('workspace.deleted')
async onWorkspaceDeleted({ id }: Events['workspace.deleted']) {
// do not sync blob meta to DB
const blobs = await this.list(id, false);
const blobs = await this.list(id);
// to reduce cpu time holding
blobs.forEach(blob => {
@@ -105,31 +105,6 @@ test('should list blobs', async t => {
t.is(blobs[1].key, blob2.key);
});
test('should list deleted blobs', async t => {
const workspace = await module.create(Mockers.Workspace);
const blob = await models.blob.upsert({
workspaceId: workspace.id,
key: 'test-key',
mime: 'text/plain',
size: 100,
});
await models.blob.delete(workspace.id, blob.key);
const blobs = await models.blob.listDeleted(workspace.id);
t.is(blobs.length, 1);
t.is(blobs[0].key, blob.key);
t.truthy(blobs[0].deletedAt);
// delete permanently
await models.blob.delete(workspace.id, blob.key, true);
const blobs2 = await models.blob.listDeleted(workspace.id);
t.is(blobs2.length, 0);
});
test('should get blob', async t => {
const workspace = await module.create(Mockers.Workspace);
const blob = await models.blob.upsert({
@@ -96,32 +96,6 @@ export class BlobModel extends BaseModel {
return count > 0;
}
async listPendingExpired(before: Date) {
return await this.db.blob.findMany({
where: {
status: 'pending',
deletedAt: null,
createdAt: {
lt: before,
},
},
select: {
workspaceId: true,
key: true,
uploadId: true,
},
});
}
async listDeleted(workspaceId: string) {
return await this.db.blob.findMany({
where: {
workspaceId,
deletedAt: { not: null },
},
});
}
async totalSize(workspaceId: string) {
const sum = await this.db.blob.aggregate({
where: {
@@ -162,21 +162,4 @@ export class HistoryModel extends BaseModel {
editor: row.createdByUser,
};
}
/**
* Clean expired histories.
*/
async cleanExpired() {
const { count } = await this.db.snapshotHistory.deleteMany({
where: {
expiredAt: {
lte: new Date(),
},
},
});
if (count > 0) {
this.logger.log(`Deleted ${count} expired histories`);
}
return count;
}
}
@@ -148,17 +148,4 @@ export class SessionModel extends BaseModel {
}
return count;
}
async cleanExpiredUserSessions() {
const { count } = await this.db.userSession.deleteMany({
where: {
expiresAt: {
lte: new Date(),
},
},
});
if (count > 0) {
this.logger.log(`Cleaned ${count} expired user sessions`);
}
}
}
+8
View File
@@ -46,9 +46,13 @@ import serverNativeModule, {
type RequestedModelMatchResponse,
type ResolvedEntitlement,
type ResolveEntitlementInput,
type RuntimeBlobCleanupExecuteResult,
type RuntimeBlobCleanupPlanResult,
type RuntimeBlobCleanupResult,
type RuntimeBlobCompleteResult,
type RuntimeBlobMetadataBackfillResult,
type RuntimeByokLocalLeaseRecord,
type RuntimeDocBlobRefsResult,
type RuntimeDocCompactionResult,
type RuntimeMagicLinkOtpConsumeResult,
type RuntimeMultipartUploadInit,
@@ -91,9 +95,13 @@ export type {
RemoteMimeTypeRequest,
ResolvedEntitlement,
ResolveEntitlementInput,
RuntimeBlobCleanupExecuteResult,
RuntimeBlobCleanupPlanResult,
RuntimeBlobCleanupResult,
RuntimeBlobCompleteResult,
RuntimeBlobMetadataBackfillResult,
RuntimeByokLocalLeaseRecord,
RuntimeDocBlobRefsResult,
RuntimeDocCompactionResult,
RuntimeMagicLinkOtpConsumeResult,
RuntimeMultipartUploadInit,