Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 74 additions & 8 deletions extensions/saas/sources/migration/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 │
└─────────────────────────┘ └─────────────────────────────────┘
```

---
Expand Down Expand Up @@ -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:
Expand All @@ -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<Class<?>, List<ColumnDef>>`) is built once per entity type
Expand Down Expand Up @@ -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:
Expand All @@ -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)

```
Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand All @@ -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. |
7 changes: 7 additions & 0 deletions extensions/saas/sources/migration/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@
<version>26.7.0</version>
</dependency>

<!-- Entity Files: durable storage (local/S3/Buckie) for the final export/backup ZIP -->
<dependency>
<groupId>tools.dynamia.modules</groupId>
<artifactId>tools.dynamia.modules.entityfiles</artifactId>
<version>26.7.0</version>
</dependency>

<!-- Dynamia Integration: SchedulerUtil, VT, Containers -->
<dependency>
<groupId>tools.dynamia</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand All @@ -102,6 +103,17 @@ public interface AccountMigrationJobService {
*/
List<AccountMigrationJobDto> 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.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* This is <strong>not</strong> 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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -219,32 +217,33 @@ public ResponseEntity<Map<String, String>> 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<Resource> 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);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.).
*
* <p>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}
Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading