From 35e2e38811358cab1bf125dd09acb87950e2dec2 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 25 Aug 2025 16:42:05 -0400 Subject: [PATCH] feat(attachments): setup Garbage Collector for attachments module --- src/modules/attachments/attachments.module.ts | 12 ++- .../services/garbage-collector.service.ts | 78 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/modules/attachments/services/garbage-collector.service.ts diff --git a/src/modules/attachments/attachments.module.ts b/src/modules/attachments/attachments.module.ts index 75f5f02..ee8883f 100644 --- a/src/modules/attachments/attachments.module.ts +++ b/src/modules/attachments/attachments.module.ts @@ -2,10 +2,18 @@ import { ScheduleModule } from "@nestjs/schedule"; import { PrismaService } from "src/prisma/prisma.service"; import { ArchivalAttachmentService } from "./services/archival-attachment.service"; import { Module } from "@nestjs/common"; +import { GarbargeCollectorService } from "./services/garbage-collector.service"; @Module({ imports: [ScheduleModule.forRoot()], - providers: [PrismaService, ArchivalAttachmentService], - exports: [ArchivalAttachmentService], + providers: [ + PrismaService, + ArchivalAttachmentService, + GarbargeCollectorService, + ], + exports: [ + ArchivalAttachmentService, + GarbargeCollectorService + ], }) export class ArchivalAttachmentModule {} \ No newline at end of file diff --git a/src/modules/attachments/services/garbage-collector.service.ts b/src/modules/attachments/services/garbage-collector.service.ts new file mode 100644 index 0000000..13e8c21 --- /dev/null +++ b/src/modules/attachments/services/garbage-collector.service.ts @@ -0,0 +1,78 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Cron } from "@nestjs/schedule"; +import { PrismaService } from 'src/prisma/prisma.service'; +import * as path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { resolveAttachmentsRoot } from "src/config/attachment.config"; + +@Injectable() +export class GarbargeCollectorService { + private readonly logger = new Logger(GarbargeCollectorService.name); + + //.env refs + private readonly batch_size = Number(process.env.GC_BATCH_SIZE || 500); + private readonly cron_expression = process.env.GC_CRON || '15 4 * * *'; // everyday at 04:15 AM + + //fetchs root of storage + private readonly root = resolveAttachmentsRoot(); + + constructor(private readonly prisma: PrismaService) {} + + //planif for the Cronjob + @Cron(function(this:GarbargeCollectorService) { return this.cron_expression; } as any) + async runScheduled() { + await this.collect(); + } + + //Manage Garbage collecting by batch of elements until a batch != full + async collect() { + let total = 0, round = 0; + //infinit loop (;;) with break + for(;;) { + round++; + const num = await this.collectBatch(); + total += num; + this.logger.log(`Garbage Collector round #${round} removed ${num}`); + if(num < this.batch_size) break; //breaks if not a full batch + } + this.logger.log(`Garbage Collecting done: total removed ${total}`); + return { removed:total }; + } + + //Manage a single lot of orphan blobs + private async collectBatch(): Promise { + const blobs = await this.prisma.blobs.findMany({ + where: { refcount: { lte: 0 } }, + select: { sha256: true, storage_path: true }, + take: this.batch_size, + }); + if(blobs.length === 0) return 0; + + // delete original file and all its variants in the same file + await Promise.all( + blobs.map(async (blob)=> { + const absolute_path = path.join(this.root, blob.storage_path); + await this.deleteFileIfExists(absolute_path); //tries to delete original file if found + + const dir = path.dirname(absolute_path); + const base = path.basename(absolute_path); + try { + const entries = await fsp.readdir(dir, { withFileTypes: true}); + const targets = entries.filter(entry => entry.isFile() && entry.name.startsWith(base + '.')) + .map(entry => path.join(dir, entry.name)); + //deletes all variants + await Promise.all(targets.map(target => this.deleteFileIfExists(target))); + } catch {} + }) + ); + //deletes blobs lignes if file is deleted + const hashes = blobs.map(blob => blob.sha256); + await this.prisma.blobs.deleteMany({where: { sha256: { in: hashes } } }); + return blobs.length; + } + + //helper: deletes path if exists and ignore errors + private async deleteFileIfExists(path: string) { + try { await fsp.unlink(path); } catch {} + } +} \ No newline at end of file