mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 13:58:50 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user