Skip to content

Commit bca044f

Browse files
Refactor account migration and export logic to support columnar format and improve chunk handling
1 parent 9f557d2 commit bca044f

14 files changed

Lines changed: 334 additions & 193 deletions

File tree

extensions/saas/sources/migration/ARCHITECTURE.md

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -108,27 +108,29 @@ OutputStream (raw or GZIPOutputStream)
108108
├── Write "account": AccountDTO object
109109
└── Write "entities": {
110110
For each entityClass in topological order:
111-
Write entityClass.getName(): [
112-
LOOP (pagination by chunks):
113-
count = CrudService.count(entityClass, {accountId})
114-
for page 1..N:
115-
chunk = CrudService.find(entityClass, {accountId, paginator})
116-
for each record:
117-
write as JSON object (flat map, refs as _ref_id)
118-
]
111+
Write entityClass.getName(): {
112+
"fields": ["id", "name", "category_ref_id", ...],
113+
"rows": [
114+
[1, "John", 3],
115+
[2, "Cindy", null],
116+
...
117+
]
118+
}
119119
}
120120
```
121121

122-
### Serialization of a Single Entity
122+
### Serialization of a Single Entity (Columnar Format)
123123

124+
Columns are built once per entity type from `EntityType.getSingularAttributes()`:
124125
```
125-
For each SingularAttribute in EntityType:
126-
BASIC / EMBEDDED → write field value directly
127-
MANY_TO_ONE / ONE_TO_ONE → write {fieldName}_ref_id: <referenced entity's id>
128-
ONE_TO_MANY / MANY_TO_MANY → SKIP (reconstructed during import via child entities)
126+
id → always first column
127+
For each SingularAttribute (excluding id):
128+
BASIC / EMBEDDED → column name = fieldName, value written directly
129+
MANY_TO_ONE / ONE_TO_ONE → column name = fieldName + "_ref_id", value = referenced PK
130+
ONE_TO_MANY / MANY_TO_MANY / ELEMENT_COLLECTION → SKIP
129131
```
130132

131-
Fields annotated with `@ExportIgnore` are skipped.
133+
Fields annotated with `@ExportIgnore` are skipped. Missing or inaccessible fields write `null`.
132134

133135
---
134136

@@ -144,7 +146,9 @@ InputStream (auto-detected: raw or GZIPInputStream)
144146
└── Read "entities" → {
145147
For each entityClassName:
146148
resolve class → Class.forName(entityClassName)
147-
For each JSON record in array (chunked):
149+
Read "fields": [...] → ordered column names
150+
For each row array (chunked):
151+
reconstruct JsonNode from fields + row values
148152
deserialize → entity instance
149153
resolve _ref_id references via idMappings
150154
set accountId = targetAccountId
@@ -204,7 +208,7 @@ saas_export_42_20260614T100500.json[.gz]
204208

205209
```json
206210
{
207-
"version": "1",
211+
"version": "2",
208212
"exportedAt": "2026-06-14T10:05:00",
209213
"sourceAccountId": 42,
210214
"identityStrategy": "KEEP_IDS",
@@ -216,23 +220,34 @@ saas_export_42_20260614T100500.json[.gz]
216220
...
217221
},
218222
"entities": {
219-
"tools.dynamia.modules.saas.jpa.AccountParameter": [
220-
{ "id": 1, "accountId": 42, "name": "theme", "value": "dark" }
221-
],
222-
"com.example.Customer": [
223-
{ "id": 10, "accountId": 42, "name": "John", "category_ref_id": 3 }
224-
],
225-
"com.example.Order": [
226-
{ "id": 100, "accountId": 42, "customer_ref_id": 10, "total": 99.99 }
227-
]
223+
"tools.dynamia.modules.saas.jpa.AccountParameter": {
224+
"fields": ["id", "accountId", "name", "value"],
225+
"rows": [
226+
[1, 42, "theme", "dark"]
227+
]
228+
},
229+
"com.example.Customer": {
230+
"fields": ["id", "accountId", "name", "category_ref_id"],
231+
"rows": [
232+
[10, 42, "John", 3]
233+
]
234+
},
235+
"com.example.Order": {
236+
"fields": ["id", "accountId", "total", "customer_ref_id"],
237+
"rows": [
238+
[100, 42, 99.99, 10]
239+
]
240+
}
228241
}
229242
}
230243
```
231244

232245
**Key conventions:**
246+
- Each entity section is an object with `fields` (column names) and `rows` (value arrays).
233247
- `{fieldName}_ref_id` encodes a `@ManyToOne` / `@OneToOne` reference by its primary key.
234248
- The `account` section makes the package self-describing.
235249
- Entities appear in topological order (parents before children).
250+
- Format version `"2"` uses the columnar layout; version `"1"` (legacy) used per-row objects.
236251

237252
---
238253

extensions/saas/sources/migration/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
<version>26.6.0</version>
2828
</parent>
2929

30-
<name>DynamiaModules - SaaS Tenant Mobility (Migration)</name>
30+
<name>DynamiaModules - SaaS Migration</name>
3131
<artifactId>tools.dynamia.modules.saas.migration</artifactId>
3232
<url>https://www.dynamia.tools/modules/saas/migration</url>
3333
<description>

extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
*/
2020
public class AccountExportOptions {
2121

22+
public static final int DEFAULT_CHUNK_SIZE = 5000;
23+
2224
/**
23-
* Number of records to read from DB per pagination page. Default: 500.
25+
* Number of records to read from DB per pagination page. Default: 5000.
2426
*/
25-
private int chunkSize = 500;
27+
private int chunkSize = DEFAULT_CHUNK_SIZE;
2628

2729
/**
2830
* When {@code true}, the output stream is wrapped in GZIP compression.
@@ -75,7 +77,7 @@ public AccountExportOptions label(String label) {
7577

7678
public int getChunkSize() {
7779
if (chunkSize <= 0) {
78-
chunkSize = 500;
80+
chunkSize = DEFAULT_CHUNK_SIZE;
7981
}
8082
return chunkSize;
8183
}

extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
*/
1111
package tools.dynamia.modules.saas.migration.api;
1212

13+
import com.fasterxml.jackson.annotation.JsonInclude;
1314
import tools.dynamia.modules.saas.migration.domain.AccountJobStatus;
1415
import tools.dynamia.modules.saas.migration.domain.AccountJobType;
1516
import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob;
1617

18+
import java.time.Duration;
1719
import java.time.LocalDateTime;
1820

1921
/**
@@ -22,6 +24,7 @@
2224
*
2325
* @author Mario Serrano Leones
2426
*/
27+
@JsonInclude(JsonInclude.Include.NON_NULL)
2528
public record AccountMigrationJobDto(
2629
Long id,
2730
String uuid,
@@ -31,18 +34,28 @@ public record AccountMigrationJobDto(
3134
AccountJobStatus status,
3235
int progress,
3336
String progressMessage,
37+
long records,
3438
String errorMessage,
3539
String downloadUrl,
3640
LocalDateTime createdAt,
3741
LocalDateTime startedAt,
3842
LocalDateTime finishedAt
3943
) {
4044

41-
/** Convenience: returns {@code true} when the job has reached a terminal state. */
45+
/**
46+
* Convenience: returns {@code true} when the job has reached a terminal state.
47+
*/
4248
public boolean isFinished() {
4349
return status == AccountJobStatus.COMPLETED
4450
|| status == AccountJobStatus.FAILED
4551
|| status == AccountJobStatus.CANCELLED;
4652
}
53+
54+
public Duration getDuration() {
55+
if (startedAt != null && finishedAt != null) {
56+
return Duration.between(startedAt, finishedAt);
57+
}
58+
return null;
59+
}
4760
}
4861

extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgress.java

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,28 @@
1313
/**
1414
* Carries progress information during a tenant migration operation.
1515
*
16-
* @param processedRecords Total records processed so far.
17-
* @param totalRecords Total records expected (0 if unknown).
18-
* @param message Human-readable description of the current step.
16+
* @param processedEntities Total entities processed so far.
17+
* @param totalEntities Total entities expected (0 if unknown).
18+
* @param message Human-readable description of the current step.
19+
* @param processedRecords Total records processed so far (across all entities).
1920
* @author Mario Serrano Leones
2021
*/
21-
public record MigrationProgress(long processedRecords, long totalRecords, String message) {
22+
public record MigrationProgress(long processedEntities, long totalEntities, String message, long processedRecords) {
2223

23-
/** Returns the progress as a percentage (0–100), or -1 if total is unknown. */
24+
/**
25+
* Returns the progress as a percentage (0–100), or -1 if total is unknown.
26+
*/
2427
public int percentage() {
25-
if (totalRecords <= 0) return -1;
26-
return (int) Math.min(100, (processedRecords * 100L) / totalRecords);
28+
if (totalEntities <= 0) return -1;
29+
return (int) Math.min(100, (processedEntities * 100L) / totalEntities);
2730
}
2831

2932
@Override
3033
public String toString() {
31-
if (totalRecords > 0) {
32-
return "[%d%%] %s (%d / %d)".formatted(percentage(), message, processedRecords, totalRecords);
34+
if (totalEntities > 0) {
35+
return "[%d%%] %s (%d / %d)".formatted(percentage(), message, processedEntities, totalEntities);
3336
}
34-
return "[?] %s (%d processed)".formatted(message, processedRecords);
37+
return "[?] %s (%d processed)".formatted(message, processedEntities);
3538
}
3639
}
3740

extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/discovery/AccountEntityDiscovery.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.slf4j.Logger;
1616
import org.slf4j.LoggerFactory;
1717
import tools.dynamia.integration.sterotypes.Service;
18+
import tools.dynamia.modules.entityfile.domain.EntityFile;
1819
import tools.dynamia.modules.saas.api.AccountAware;
1920
import tools.dynamia.modules.saas.api.AccountExportIgnore;
2021
import tools.dynamia.modules.saas.domain.Account;
@@ -60,6 +61,8 @@ public List<Class<?>> discoverExportableEntities() {
6061

6162
// Always include Account as the tenant root
6263
exportable.add(Account.class);
64+
65+
6366
log.debug("[Migration] Always including: {}", Account.class.getName());
6467

6568
for (EntityType<?> entityType : managedTypes) {
@@ -84,6 +87,7 @@ public List<Class<?>> discoverExportableEntities() {
8487
exportable.add(javaType);
8588
log.debug("[Migration] Discovered exportable entity: {}", javaType.getName());
8689
}
90+
exportable.add(EntityFile.class); // EntityFile is used for storing exported file metadata and must be included even if not AccountAware
8791

8892
log.info("[Migration] Discovered {} exportable entity types", exportable.size());
8993
return exportable;

extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public class AccountMigrationJob extends SimpleEntity {
7272
* Completion percentage 0–100.
7373
*/
7474
private int progress;
75+
private long records;
7576

7677
@Column(length = 2000)
7778
private String progressMessage;
@@ -140,9 +141,10 @@ public void markCancelled(String reason) {
140141
/**
141142
* Update running progress (0-100) and an optional human-readable message.
142143
*/
143-
public void updateProgress(int progress, String message) {
144+
public void updateProgress(int progress, String message, long records) {
144145
this.progress = Math.min(100, Math.max(0, progress));
145146
this.progressMessage = StringUtils.truncate(message, 1999);
147+
this.records = records;
146148
}
147149

148150
/**
@@ -264,5 +266,13 @@ public void setOptionsJson(String optionsJson) {
264266
public String toString() {
265267
return "TenantMobilityJob{uuid=" + uuid + ", type=" + jobType + ", status=" + status + "}";
266268
}
269+
270+
public long getRecords() {
271+
return records;
272+
}
273+
274+
public void setRecords(long records) {
275+
this.records = records;
276+
}
267277
}
268278

extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstants.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ private ExportConstants() {
2121
}
2222

2323
/** Current format version written to every export file. */
24-
public static final String FORMAT_VERSION = "1";
24+
public static final String FORMAT_VERSION = "2";
2525

2626
/**
2727
* Suffix appended to field names when serializing {@code @ManyToOne} /
@@ -48,5 +48,11 @@ private ExportConstants() {
4848

4949
/** Top-level JSON field for the identity strategy name. */
5050
public static final String FIELD_IDENTITY_STRATEGY = "identityStrategy";
51+
52+
/** Per-entity field listing the ordered column names in the columnar format. */
53+
public static final String FIELD_FIELDS = "fields";
54+
55+
/** Per-entity field containing the data rows as value arrays in the columnar format. */
56+
public static final String FIELD_ROWS = "rows";
5157
}
5258

0 commit comments

Comments
 (0)