refactor: add cleanup for expired subscriptions

This commit is contained in:
zhom
2026-03-02 18:49:47 +04:00
parent 3cb68c53ad
commit 23d25928fc
13 changed files with 247 additions and 17 deletions
@@ -0,0 +1,38 @@
import {
Body,
Controller,
Headers,
HttpCode,
Post,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { SyncService } from "./sync.service.js";
@Controller("v1/internal")
export class InternalController {
private readonly internalKey: string | undefined;
constructor(
private readonly syncService: SyncService,
private readonly configService: ConfigService,
) {
this.internalKey = this.configService.get<string>("INTERNAL_KEY");
}
@Post("cleanup-excess-profiles")
@HttpCode(200)
async cleanupExcessProfiles(
@Headers("x-internal-key") key: string,
@Body() body: { userId: string; maxProfiles: number },
) {
if (!this.internalKey || key !== this.internalKey) {
throw new UnauthorizedException("Invalid internal key");
}
return this.syncService.cleanupExcessProfiles(
body.userId,
body.maxProfiles,
);
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard.js";
import { InternalController } from "./internal.controller.js";
import { SyncController } from "./sync.controller.js";
import { SyncService } from "./sync.service.js";
@Module({
controllers: [SyncController],
controllers: [SyncController, InternalController],
providers: [SyncService, AuthGuard],
exports: [SyncService],
})
+129
View File
@@ -554,6 +554,135 @@ export class SyncService implements OnModuleInit {
this.changeSubject.next(event);
}
async cleanupExcessProfiles(
userId: string,
maxProfiles: number,
): Promise<{ deletedProfiles: string[]; remaining: number }> {
const userPrefix = `users/${userId}/`;
const profilePrefix = `${userPrefix}profiles/`;
// List all profile directories
const profiles: { id: string; lastModified: Date }[] = [];
let continuationToken: string | undefined;
do {
const result = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: profilePrefix,
Delimiter: "/",
MaxKeys: 1000,
ContinuationToken: continuationToken,
}),
);
if (result.CommonPrefixes) {
for (const cp of result.CommonPrefixes) {
if (!cp.Prefix) continue;
const profileId = cp.Prefix.replace(profilePrefix, "").replace(
/\/$/,
"",
);
// Get creation time from first object in the profile directory
const objects = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: cp.Prefix,
MaxKeys: 1,
}),
);
const firstObj = objects.Contents?.[0];
profiles.push({
id: profileId,
lastModified: firstObj?.LastModified || new Date(0),
});
}
}
continuationToken = result.NextContinuationToken;
} while (continuationToken);
if (profiles.length <= maxProfiles) {
return { deletedProfiles: [], remaining: profiles.length };
}
// Sort newest first — delete newest excess profiles
profiles.sort(
(a, b) => b.lastModified.getTime() - a.lastModified.getTime(),
);
const excessCount = profiles.length - maxProfiles;
const toDelete = profiles.slice(0, excessCount);
const deletedProfiles: string[] = [];
for (const profile of toDelete) {
const prefix = `${profilePrefix}${profile.id}/`;
// Delete all objects under this profile
let delToken: string | undefined;
do {
const listResult = await this.s3Client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: prefix,
MaxKeys: 1000,
ContinuationToken: delToken,
}),
);
const objects = listResult.Contents || [];
if (objects.length > 0) {
const deleteObjects = objects
.filter((obj): obj is typeof obj & { Key: string } => !!obj.Key)
.map((obj) => ({ Key: obj.Key }));
if (deleteObjects.length > 0) {
await this.s3Client.send(
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: { Objects: deleteObjects, Quiet: true },
}),
);
}
}
delToken = listResult.NextContinuationToken;
} while (delToken);
// Create tombstone
const tombstoneKey = `${userPrefix}tombstones/profiles/${profile.id}`;
const tombstoneData = JSON.stringify({
prefix: `profiles/${profile.id}/`,
deleted_at: new Date().toISOString(),
reason: "excess_profile_cleanup",
});
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: tombstoneKey,
Body: tombstoneData,
ContentType: "application/json",
}),
);
deletedProfiles.push(profile.id);
this.logger.log(
`Cleaned up excess profile ${profile.id} for user ${userId}`,
);
}
// Report updated profile usage to backend
const remaining = profiles.length - deletedProfiles.length;
await this.reportProfileUsage(userId, remaining).catch((err) =>
this.logger.warn(`Failed to report usage after cleanup: ${err.message}`),
);
return { deletedProfiles, remaining };
}
/**
* Check if the user has reached their profile limit.
* Counts objects in the profiles/ prefix.