From b56b87c11fe06257094a0ef67a95806bc38b8a2a Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Mon, 20 Jul 2026 17:53:49 -0500 Subject: [PATCH] feat(saas-migration): persist export/backup result via EntityFileStorage The export/backup ZIP is now uploaded to EntityFileService (local/S3/Buckie) only after the pipeline finishes successfully, instead of being kept as a raw file on local/container disk. The local working copy is deleted right after, and a job never reports COMPLETED without a durable, downloadable result. AccountMigrationJob.resultPath (raw filesystem path) is replaced by resultFile (EntityFile reference), and the download endpoint streams via StoredEntityFile instead of FileSystemResource. Co-Authored-By: Claude Sonnet 5 --- .../saas/sources/migration/ARCHITECTURE.md | 82 ++++++++++++-- extensions/saas/sources/migration/pom.xml | 7 ++ .../api/AccountMigrationJobService.java | 14 ++- .../config/AccountMigrationProperties.java | 8 +- .../AccountMigrationRestController.java | 33 +++--- .../migration/domain/AccountMigrationJob.java | 24 ++-- .../AccountMigrationJobServiceImpl.java | 107 +++++++++++++++--- 7 files changed, 226 insertions(+), 49 deletions(-) diff --git a/extensions/saas/sources/migration/ARCHITECTURE.md b/extensions/saas/sources/migration/ARCHITECTURE.md index 299a9df6..a7fc0511 100644 --- a/extensions/saas/sources/migration/ARCHITECTURE.md +++ b/extensions/saas/sources/migration/ARCHITECTURE.md @@ -21,7 +21,7 @@ The Account Migration Module enables full lifecycle management of tenant (Accoun │ delegates to ┌─────────────────────────────▼────────────────────────────────────────┐ │ AccountMigrationJobService │ -│ createJob() · cancelJob() · getJob() · listJobs() │ +│ createJob() · cancelJob() · getJob() · listJobs() · downloadResult() │ │ Persists AccountMigrationJob entity in DB │ └──────┬───────────────────────────────────────────┬───────────────────┘ │ launches via │ saves progress via @@ -57,6 +57,14 @@ The Account Migration Module enables full lifecycle management of tenant (Accoun │ EntityDependencyGraph │ topological sort via JPA metamodel │ IdentityMapper SPI │ KEEP_IDS / REGENERATE_IDS └────────────────────────────┘ + +EXPORT / BACKUP only, after the pipeline finishes successfully: + +┌─────────────────────────┐ ┌─────────────────────────────────┐ +│ local working ZIP file │──────▶│ EntityFileService.createEntityFile│ +│ (properties.output-dir) │ upload │ → EntityFileStorage (local/S3/ │ +│ deleted right after │ │ Buckie/…) — the durable copy │ +└─────────────────────────┘ └─────────────────────────────────┘ ``` --- @@ -114,6 +122,11 @@ Account101_20260616T100500.zip ZIP entries use `DEFLATE` at `BEST_SPEED` level. Typical JSON compresses 5–10×. +> **Durability:** the ZIP built by this pipeline is written to a **local, transient working +> file** (`AccountMigrationProperties.outputDirectory`) — it is not the durable artifact. +> Only after the ZIP finishes writing does `AccountMigrationJobServiceImpl.finalizeJob()` +> upload it to `EntityFileService` and delete the local copy. See §7 and §9.1. + ### Parallel Temp-Directory Strategy Entity files are written in parallel to a temporary directory, then assembled into a ZIP in topological order: @@ -125,12 +138,12 @@ Entity files are written in parallel to a temporary directory, then assembled in EntityManager localEm = emf.createEntityManager() ← one per thread, thread-safe write Account{id}_{SimpleName}.json to temp dir localEm.close() -4. ZipOutputStream → output stream: +4. ZipOutputStream → output stream (the local working ZIP file): addEntry(manifest.json) ← always first For each entityClass in topological order: addEntry(Account{id}_{SimpleName}.json) ← Files.copy(), no heap allocation zipOut.finish() -5. deleteDirectory(tempDir) ← always, in finally +5. deleteDirectory(tempDir) ← always, in finally (entity JSON scratch files) ``` The column definition cache (`Map, List>`) is built once per entity type @@ -237,10 +250,15 @@ AccountMigrationJobService.createExportJob(accountId, options) ├── 2. SchedulerUtil.runWithResult(new ExportWorker(jobId, accountId, options)) │ └── Virtual Thread starts │ ├── Update job status → RUNNING - │ ├── Call ExportPipeline.export(...) → writes Account{id}_{ts}.zip + │ ├── Call ExportPipeline.export(...) → writes local working ZIP │ │ └── MigrationProgressListener updates job.progress periodically - │ ├── On success: update job status → COMPLETED, set resultPath - │ └── On failure: update job status → FAILED, set errorMessage + │ ├── finalizeJob(): + │ │ ├── On success: upload working ZIP → EntityFileService.createEntityFile() + │ │ │ ├── upload OK → job status → COMPLETED, resultFile = EntityFile + │ │ │ └── upload FAILS → job status → FAILED (never reports success + │ │ │ without a persisted, downloadable result) + │ │ ├── On pipeline failure: job status → FAILED, set errorMessage + │ │ └── delete the local working ZIP — always, regardless of outcome └── 3. Return jobId to caller (non-blocking) Cancellation: @@ -253,6 +271,47 @@ Cancellation: --- +## 7.1 Result Persistence (EntityFileStorage integration) + +The ZIP produced by an EXPORT or BACKUP job is never kept as a "final" file on local or +container disk. It is built in a local, transient working file +(`AccountMigrationProperties.outputDirectory`, default `${java.io.tmpdir}/saas-migration`) +purely as scratch space while the pipeline runs — the same directory a container image or its +ephemeral volume could lose at any restart/redeploy. + +``` +ExportWorker writes ──▶ local working ZIP ──▶ finalizeJob() on completion + │ + ▼ + EntityFileService.createEntityFile( + fileInfo, /* target */ job, description) + │ + ▼ + EntityFileStorage (LocalStorage / AWSS3Storage / Buckie / …) + │ + ▼ + AccountMigrationJob.resultFile = EntityFile (FK, eager) + local working ZIP deleted +``` + +- The `EntityFile` is associated with the `AccountMigrationJob` itself + (`targetEntity = AccountMigrationJob`, `targetEntityId = job.id`), not with the tenant Account — + it is an operational artifact of the migration system, not tenant data. +- Which backend actually stores the bytes (local safe directory, S3, Buckie, …) is controlled by + the host application's `EntityFileStorage` configuration (`DEFAULT_STORAGE_ID` app parameter) — + the migration module has zero storage-specific code. +- `GET /jobs/{jobId}/download` resolves the result via + `AccountMigrationJobService.downloadResult(jobUuid)` → `EntityFileService.download(entityFile)` + → `StoredEntityFile.toResource()`, streaming from whatever backend is configured. The controller + never touches a raw filesystem path. +- If the upload to `EntityFileStorage` fails, the job is marked **FAILED** (not COMPLETED) — a + job never reports success without a durable, downloadable result. +- CLONE does not go through this path: its export phase writes to a JVM temp file + (`Files.createTempFile`, OS temp dir) that is deleted in a `finally` block once the import phase + finishes — it is a pure intermediate buffer, never exposed for download (see §6). + +--- + ## 8. Export File Format (v3) ``` @@ -372,11 +431,16 @@ CREATE TABLE saas_migration_jobs ( started_at DATETIME, finished_at DATETIME, error_message TEXT, - result_path VARCHAR(1000), + result_file_id BIGINT REFERENCES mod_entity_files(id), options_json VARCHAR(2000) ); ``` +`result_file_id` is a nullable FK into `mod_entity_files` (owned by the `entity-files` module, +see [`entity-files/README.md`](../../../entity-files/README.md)) — it is only set once the +result ZIP has been durably persisted via `EntityFileService`. There is no raw filesystem path +column: a job with `status = COMPLETED` and `result_file_id IS NULL` should not occur (see §7.1). + --- ## 12. Scalability Notes @@ -388,6 +452,7 @@ CREATE TABLE saas_migration_jobs ( | Parallel export | Up to `exportParallelism` (default 4) entity types exported concurrently via virtual threads; each uses its own `EntityManager` | | Disk I/O | 256 KB ZIP buffer, 64 KB per-entity file buffer; DEFLATE BEST_SPEED compression | | Network / disk | ZIP always produced — typically 5–10× smaller than raw JSON | +| Result durability | Result ZIP persisted via `EntityFileService`/`EntityFileStorage` (local safe dir, S3, Buckie, …), not on container-local disk — survives restarts/redeploys and is never subject to image/volume size limits (see §7.1) | | Long-running jobs | Virtual threads, cooperative cancellation via `CancellationToken` | | DB load | Read-only keyset-paginated queries; imports batched per chunk in isolated transactions | | Concurrent jobs | In-memory job registry + DB-backed state; configurable max concurrent | @@ -402,6 +467,7 @@ CREATE TABLE saas_migration_jobs ( | **v1 (legacy)** | EXPORT, IMPORT as single JSON. `KEEP_IDS` + `REGENERATE_IDS`. | | **v2 (legacy)** | Columnar format (fields + rows arrays). Optional GZIP. | | **v3 (current)** | ZIP multi-file: one JSON per entity. Always compressed. Keyset pagination. Parallel entity export (virtual threads). Clone via temp file. | +| **v3.1 (current)** | Result ZIP persisted via `EntityFileService`/`EntityFileStorage` (local safe dir, S3, Buckie, …) instead of raw container-local disk. Built as a transient working file, uploaded and locally deleted only after the pipeline succeeds. See §7.1. | | **v4** | Cross-environment MIGRATE (HTTP push to remote endpoint). Resume after failure (checkpoint in DB). | | **v5** | `UUID7` identity strategy. Partial export (subset of entities). Schema validation on import. | -| **v6** | Multi-region database migration. S3/GCS file storage backend. Event-driven progress via SSE/WebSocket. | +| **v6** | Multi-region database migration. Event-driven progress via SSE/WebSocket. | diff --git a/extensions/saas/sources/migration/pom.xml b/extensions/saas/sources/migration/pom.xml index 60ca1dcf..391983e0 100644 --- a/extensions/saas/sources/migration/pom.xml +++ b/extensions/saas/sources/migration/pom.xml @@ -74,6 +74,13 @@ 26.7.0 + + + tools.dynamia.modules + tools.dynamia.modules.entityfiles + 26.7.0 + + tools.dynamia diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java index bd0e3283..550676b1 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java @@ -12,6 +12,7 @@ import org.springframework.core.io.InputStreamSource; import org.springframework.web.multipart.MultipartFile; +import tools.dynamia.modules.entityfile.StoredEntityFile; import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob; import java.io.Serializable; @@ -87,7 +88,7 @@ public interface AccountMigrationJobService { AccountMigrationJobDto getJob(String jobUuid); /** - * Returns the raw {@link AccountMigrationJob} entity for internal use (e.g. file download). + * Returns the raw {@link AccountMigrationJob} entity for internal/advanced use. * * @param jobUuid UUID of the job * @return entity or {@code null} @@ -102,6 +103,17 @@ public interface AccountMigrationJobService { */ List listJobs(Serializable accountId); + /** + * Resolves the persisted result archive of a completed EXPORT/BACKUP job for download. + * The file is read from wherever {@code EntityFileStorage} is configured + * (local safe directory, S3, Buckie, etc.) — never from a raw local/container path. + * + * @param jobUuid UUID of the job + * @return the stored file, or {@code null} if the job has no result (not found, not + * finished, failed, or has no downloadable output) + */ + StoredEntityFile downloadResult(String jobUuid); + /** * Requests cooperative cancellation of a running job. *

diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java index 7ef86144..f6667c99 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java @@ -35,8 +35,12 @@ public class AccountMigrationProperties { private int chunkSize = 500; /** - * Directory where export/backup files are stored. - * Defaults to {@code ${java.io.tmpdir}/saas-migration}. + * Local, transient working directory used while an export/backup ZIP is being assembled. + *

+ * This is not the durable storage location — once the ZIP finishes writing, + * it is uploaded to {@code EntityFileService} (local safe storage, S3, Buckie, etc.) and the + * file here is deleted, so nothing built from tenant data is ever left behind on + * local/container disk. Defaults to {@code ${java.io.tmpdir}/saas-migration}. */ private String outputDirectory = System.getProperty("java.io.tmpdir") + "/saas-migration"; diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java index 5c957164..24564f0a 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java @@ -10,7 +10,6 @@ */ package tools.dynamia.modules.saas.migration.controllers; -import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -24,15 +23,14 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import tools.dynamia.modules.entityfile.StoredEntityFile; +import tools.dynamia.modules.entityfile.domain.EntityFile; import tools.dynamia.modules.saas.migration.api.AccountCloneOptions; import tools.dynamia.modules.saas.migration.api.AccountExportOptions; import tools.dynamia.modules.saas.migration.api.AccountImportOptions; import tools.dynamia.modules.saas.migration.api.AccountMigrationJobDto; import tools.dynamia.modules.saas.migration.api.AccountMigrationJobService; -import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob; -import java.io.File; -import java.nio.file.Paths; import java.util.List; import java.util.Map; @@ -219,32 +217,33 @@ public ResponseEntity> cancelJob(@PathVariable String jobId) /** * Download the result file of a completed EXPORT or BACKUP job. + * The file is streamed from wherever {@code EntityFileStorage} is configured + * (local safe directory, S3, Buckie, etc.) — never from a raw local/container path. * Returns 404 if the job is not found, not completed, or has no result file. */ @GetMapping("/jobs/{jobId}/download") public ResponseEntity downloadResult(@PathVariable String jobId) { - AccountMigrationJob job = jobService.getJobEntity(jobId); - if (job == null || job.getResultPath() == null) { + StoredEntityFile stored = jobService.downloadResult(jobId); + if (stored == null) { return ResponseEntity.notFound().build(); } - File resultFile = Paths.get(job.getResultPath()).toFile(); - if (!resultFile.exists()) { + Resource resource = stored.toResource(); + if (resource == null) { return ResponseEntity.notFound().build(); } - String contentType = job.getResultPath().endsWith(".gz") - ? "application/gzip" - : "application/json"; + EntityFile entityFile = stored.getEntityFile(); + String contentType = entityFile.getContentType() != null ? entityFile.getContentType() : "application/zip"; - String filename = resultFile.getName(); - - return ResponseEntity.ok() + ResponseEntity.BodyBuilder response = ResponseEntity.ok() .contentType(MediaType.parseMediaType(contentType)) .header(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + filename + "\"") - .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resultFile.length())) - .body(new FileSystemResource(resultFile)); + "attachment; filename=\"" + entityFile.getName() + "\""); + if (entityFile.getSize() != null) { + response.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(entityFile.getSize())); + } + return response.body(resource); } } diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java index 4f212621..57918b17 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java @@ -91,10 +91,20 @@ public class AccountMigrationJob extends SimpleEntity { private String errorMessage; /** - * Absolute path to the result file on disk (EXPORT / BACKUP jobs). + * Persisted result archive for EXPORT / BACKUP jobs. + * + *

Uploaded to {@link tools.dynamia.modules.entityfile.service.EntityFileService} + * only after the export pipeline finishes successfully, so no intermediate ZIP is + * ever left behind as a "final" artifact on local/container disk. Actual bytes live + * wherever {@code EntityFileStorage} is configured (local safe directory, S3, Buckie, etc.). + * + *

Fetched eagerly (this is a single-row lookup, not a bulk listing) so + * {@code getResultFile()} is always safe to call outside the loading transaction/session — + * needed by {@code downloadResult()}, which runs after {@code findByUuid} returns. */ - @Column(length = 1000) - private String resultPath; + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "result_file_id") + private EntityFile resultFile; /** * Serialized {@link AccountExportOptions} @@ -280,12 +290,12 @@ public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } - public String getResultPath() { - return resultPath; + public EntityFile getResultFile() { + return resultFile; } - public void setResultPath(String resultPath) { - this.resultPath = resultPath; + public void setResultFile(EntityFile resultFile) { + this.resultFile = resultFile; } public String getOptionsJson() { diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java index 10a024da..d95cc501 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java @@ -24,6 +24,10 @@ import tools.dynamia.integration.scheduling.SchedulerUtil; import tools.dynamia.integration.scheduling.TaskWithResult; import tools.dynamia.integration.sterotypes.Service; +import tools.dynamia.modules.entityfile.StoredEntityFile; +import tools.dynamia.modules.entityfile.UploadedFileInfo; +import tools.dynamia.modules.entityfile.domain.EntityFile; +import tools.dynamia.modules.entityfile.service.EntityFileService; import tools.dynamia.modules.saas.migration.api.CancellationToken; import tools.dynamia.modules.saas.migration.api.IdentityStrategy; import tools.dynamia.modules.saas.migration.api.MigrationProgress; @@ -90,16 +94,19 @@ public class AccountMigrationJobServiceImpl implements AccountMigrationJobServic private final AccountMigrationService migrationService; private final AccountMigrationProperties properties; private final ObjectMapper objectMapper; + private final EntityFileService entityFileService; private final Semaphore concurrencyLimit; public AccountMigrationJobServiceImpl(CrudService crudService, AccountMigrationService migrationService, AccountMigrationProperties properties, - @Qualifier("migrationObjectMapper") ObjectMapper objectMapper) { + @Qualifier("migrationObjectMapper") ObjectMapper objectMapper, + EntityFileService entityFileService) { this.crudService = crudService; this.migrationService = migrationService; this.properties = properties; this.objectMapper = objectMapper; + this.entityFileService = entityFileService; this.concurrencyLimit = new Semaphore(Math.max(1, properties.getMaxConcurrentJobs())); } @@ -200,6 +207,15 @@ public void cancelJob(String jobUuid) { } } + @Override + public StoredEntityFile downloadResult(String jobUuid) { + AccountMigrationJob job = findByUuid(jobUuid); + if (job == null || job.getResultFile() == null) { + return null; + } + return entityFileService.download(job.getResultFile()); + } + @Override public List getLastJobs() { var jobs = crudService.findReadOnly(AccountMigrationJob.class, QueryParameters.with("status", QueryConditions.notEq(AccountJobStatus.DELETED)) @@ -215,11 +231,11 @@ public List getLastJobs() { private void launchExportJob(AccountMigrationJob job, Serializable accountId, AccountExportOptions options) { CancellationToken token = CancellationToken.active(); activeTokens.put(job.getUuid(), token); - Path outputFile = buildOutputPath(job); + Path workFile = buildWorkFile(job); ExportWorker worker = new ExportWorker( - accountId, outputFile, options, migrationService, + accountId, workFile, options, migrationService, buildProgressListener(job), token); - scheduleWorker(job, worker, outputFile, null, token); + scheduleWorker(job, worker, workFile, null, token); } private void launchImportJob(AccountMigrationJob job, Path inputFile, AccountImportOptions options) { @@ -246,12 +262,14 @@ private void launchCloneJob(AccountMigrationJob job, AccountCloneOptions options * Workers that cannot immediately acquire a slot park the virtual thread * (cheap) until a running job finishes. * - * @param resultFile written to the job on success (may be null) - * @param cleanupPath deleted after the job completes (uploaded temp files; may be null) + * @param exportedZipFile local working ZIP written by an {@code ExportWorker}; on success it is + * uploaded to {@link EntityFileService} and always deleted afterward (may be null) + * @param cleanupPath deleted after the job completes, never uploaded (uploaded import/restore + * input files; may be null) */ private void scheduleWorker(AccountMigrationJob job, TaskWithResult worker, - Path resultFile, + Path exportedZipFile, Path cleanupPath, CancellationToken token) { SchedulerUtil.runWithResult(new TaskWithResult(worker.getName() + "#queued") { @@ -271,7 +289,7 @@ public Boolean doWorkWithResult() { } }).whenComplete((result, ex) -> { activeTokens.remove(job.getUuid()); - finalizeJob(job.getUuid(), ex, resultFile, token); + finalizeJob(job.getUuid(), ex, exportedZipFile, token); if (cleanupPath != null) { try { Files.deleteIfExists(cleanupPath); @@ -314,7 +332,31 @@ private void markRunning(String jobUuid) { }); } - private void finalizeJob(String jobUuid, Throwable ex, Path resultFile, CancellationToken token) { + /** + * Finalizes a job after its worker completes. + * + *

On success, {@code exportedZipFile} (a local working file — never the durable artifact) + * is uploaded to {@link EntityFileService} before the job status transitions + * to COMPLETED, so a job never reports success without a persisted, downloadable result. + * The local file is always deleted afterward, regardless of outcome, so nothing is left + * behind on local/container disk. + */ + private void finalizeJob(String jobUuid, Throwable ex, Path exportedZipFile, CancellationToken token) { + boolean cancelled = token != null && token.isCancelled(); + EntityFile uploadedResult = null; + String uploadError = null; + + if (ex == null && !cancelled && exportedZipFile != null) { + try { + uploadedResult = persistResultFile(jobUuid, exportedZipFile); + } catch (Exception uploadEx) { + uploadError = uploadEx.getMessage() != null ? uploadEx.getMessage() : uploadEx.getClass().getSimpleName(); + log.error("[Migration/Jobs] Job {} failed to persist result file: {}", jobUuid, uploadError, uploadEx); + } + } + + EntityFile finalResult = uploadedResult; + String finalUploadError = uploadError; crudService.executeWithinTransaction(() -> { AccountMigrationJob job = findByUuid(jobUuid); if (job == null) return; @@ -322,18 +364,49 @@ private void finalizeJob(String jobUuid, Throwable ex, Path resultFile, Cancella if (ex != null) { job.markFailed(ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName()); log.error("[Migration/Jobs] Job {} FAILED: {}", jobUuid, ex.getMessage()); - } else if (token != null && token.isCancelled()) { + } else if (cancelled) { job.markCancelled(token.getReason()); log.info("[Migration/Jobs] Job {} CANCELLED: {}", jobUuid, token.getReason()); + } else if (finalUploadError != null) { + job.markFailed("Result file could not be persisted: " + finalUploadError); } else { job.markCompleted(); - if (resultFile != null) { - job.setResultPath(resultFile.toAbsolutePath().toString()); + if (finalResult != null) { + job.setResultFile(finalResult); } log.info("[Migration/Jobs] Job {} COMPLETED", jobUuid); } crudService.update(job); }); + + if (exportedZipFile != null) { + try { + Files.deleteIfExists(exportedZipFile); + } catch (IOException ignored) { + } + } + } + + /** + * Uploads the finished export/backup ZIP to {@link EntityFileService}, associating it + * with the {@link AccountMigrationJob} itself so it can be resolved later for download. + */ + private EntityFile persistResultFile(String jobUuid, Path zipFile) throws IOException { + AccountMigrationJob job = findByUuid(jobUuid); + if (job == null) { + throw new IOException("Job " + jobUuid + " no longer exists"); + } + + UploadedFileInfo fileInfo = new UploadedFileInfo(zipFile); + fileInfo.setContentType("application/zip"); + try { + fileInfo.setAccountId(Long.valueOf(job.getAccountId())); + } catch (NumberFormatException | NullPointerException ignored) { + // non-numeric/absent account id (e.g. UUID tenants) — storage falls back to the current account provider + } + + return entityFileService.createEntityFile(fileInfo, job, + "Account migration " + job.getJobType() + " result for account " + job.getAccountId()); } private MigrationProgressListener buildProgressListener(AccountMigrationJob job) { @@ -388,7 +461,13 @@ private MigrationProgressListener buildProgressListener(AccountMigrationJob job) }; } - private Path buildOutputPath(AccountMigrationJob job) { + /** + * Builds the path of the local, transient working file an {@link ExportWorker} streams + * the ZIP into while an export/backup job runs. This file is never the durable artifact — + * once the pipeline finishes, {@link #finalizeJob} uploads it to {@link EntityFileService} + * and deletes it, so it never lingers on local/container disk. + */ + private Path buildWorkFile(AccountMigrationJob job) { String ts = LocalDateTime.now().format(FILE_TS); String fileName = "Account" + job.getAccountId() + "_" + ts + ".zip"; try { @@ -421,7 +500,7 @@ private AccountMigrationJob findByUuid(String uuid) { private AccountMigrationJobDto toDto(AccountMigrationJob job) { String downloadUrl = null; - if (job.getResultPath() != null) { + if (job.getResultFile() != null) { downloadUrl = "/api/saas/migration/jobs/" + job.getUuid() + "/download"; } return new AccountMigrationJobDto(