Skip to content

Commit da981fd

Browse files
author
hknokh2
committed
feat: add adaptive target query threshold
- add ScriptObject.targetFullQueryRecordsThreshold. - choose target full query by effective target record count. - keep queryAllTarget, complex/autonumber IDs, and special objects full query. - add schema and unit coverage for target query strategy.
1 parent ec6c5e4 commit da981fd

6 files changed

Lines changed: 227 additions & 7 deletions

File tree

schemas/export.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,10 @@
286286
"queryAllTarget": {
287287
"type": "boolean"
288288
},
289+
"targetFullQueryRecordsThreshold": {
290+
"type": "integer",
291+
"minimum": 0
292+
},
289293
"skipExistingRecords": {
290294
"type": "boolean"
291295
},

src/modules/constants/Constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export const DEFAULT_MAX_PARALLEL_BLOB_DOWNLOADS = 20;
130130
export const DEFAULT_MAX_PARALLEL_EXEC_TASKS = 10;
131131
export const MAX_FETCH_SIZE = 100_000;
132132
export const QUERY_BULK_API_THRESHOLD = 30_000;
133+
export const TARGET_FULL_QUERY_RECORDS_THRESHOLD = 30_000;
133134
export const BULK_API_V2_BLOCK_SIZE = 1000;
134135
export const BULK_API_V2_MAX_CSV_SIZE_IN_BYTES = 145_000_000;
135136
export const POLL_TIMEOUT = 3_000_000;

src/modules/models/job/MigrationJob.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
GROUP_OBJECT_NAME,
3737
VALUE_MAPPING_CSV_FILENAME,
3838
USER_AND_GROUP_FILENAME,
39+
TARGET_FULL_QUERY_RECORDS_THRESHOLD,
3940
} from '../../constants/Constants.js';
4041
import type { LoggerType } from '../../logging/LoggerType.js';
4142
import MappingResolver from '../../mapping/MappingResolver.js';
@@ -454,6 +455,7 @@ export default class MigrationJob implements ISFdmuRunCustomAddonJob {
454455
this._createTasks();
455456
this.tasks.forEach((task) => task.refreshPreflightState());
456457
await this._calculateRecordCountsAsync();
458+
this._resolveTargetQueryStrategies();
457459
}
458460

459461
/**
@@ -2122,19 +2124,44 @@ export default class MigrationJob implements ISFdmuRunCustomAddonJob {
21222124

21232125
const shouldProcessAll =
21242126
Boolean(updatedObject.master) || updatedObject.isSpecialObject || updatedObject.isObjectWithoutRelationships;
2125-
if (shouldProcessAll) {
2126-
updatedObject.processAllSource = true;
2127-
updatedObject.processAllTarget = true;
2128-
} else {
2129-
updatedObject.processAllSource = false;
2130-
updatedObject.processAllTarget = updatedObject.hasComplexExternalId || updatedObject.hasAutonumberExternalId;
2131-
}
2127+
updatedObject.processAllSource = shouldProcessAll;
2128+
updatedObject.processAllTarget = this._requiresFullTargetQuery(updatedObject, 0);
21322129

21332130
map.set(updatedObject, task);
21342131
});
21352132
return map;
21362133
}
21372134

2135+
/**
2136+
* Resolves target query strategies after target record counts are available.
2137+
*/
2138+
private _resolveTargetQueryStrategies(): void {
2139+
this.tasks.forEach((task) => {
2140+
const targetTotalRecords = task.targetData.totalRecordCount;
2141+
const { scriptObject } = task;
2142+
scriptObject.processAllTarget = this._requiresFullTargetQuery(scriptObject, targetTotalRecords);
2143+
});
2144+
}
2145+
2146+
/**
2147+
* Returns true when target records must be read with the full target query.
2148+
*
2149+
* @param object - Script object to inspect.
2150+
* @param targetTotalRecords - Effective target record count for the object query.
2151+
* @returns True when the full target query should be used.
2152+
*/
2153+
private _requiresFullTargetQuery(object: ScriptObject, targetTotalRecords: number): boolean {
2154+
void this;
2155+
const threshold = object.targetFullQueryRecordsThreshold ?? TARGET_FULL_QUERY_RECORDS_THRESHOLD;
2156+
return (
2157+
object.queryAllTarget ||
2158+
object.hasComplexExternalId ||
2159+
object.hasAutonumberExternalId ||
2160+
object.isSpecialObject ||
2161+
targetTotalRecords < threshold
2162+
);
2163+
}
2164+
21382165
/**
21392166
* Builds an ordered task chain using the legacy algorithm.
21402167
*

src/modules/models/script/ScriptObject.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,11 @@ export default class ScriptObject {
278278
*/
279279
public queryAllTarget = false;
280280

281+
/**
282+
* Target record count threshold for using full target query instead of filtered IN queries.
283+
*/
284+
public targetFullQueryRecordsThreshold: number | undefined;
285+
281286
/**
282287
* Skips source records that already exist in target.
283288
*/

test/modules/job/migration-job.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Common } from '../../../src/modules/common/Common.js';
1111
import LoggingContext from '../../../src/modules/logging/LoggingContext.js';
1212
import LoggingService from '../../../src/modules/logging/LoggingService.js';
1313
import { ADDON_EVENTS, DATA_MEDIA_TYPE, OPERATION } from '../../../src/modules/common/Enumerations.js';
14+
import { TARGET_FULL_QUERY_RECORDS_THRESHOLD } from '../../../src/modules/constants/Constants.js';
1415
import { UnresolvableWarning } from '../../../src/modules/models/common/UnresolvableWarning.js';
1516
import MigrationJob from '../../../src/modules/models/job/MigrationJob.js';
1617
import type { IMetadataProvider } from '../../../src/modules/models/job/IMetadataProvider.js';
@@ -22,6 +23,7 @@ import ScriptOrg from '../../../src/modules/models/script/ScriptOrg.js';
2223
import SFieldDescribe from '../../../src/modules/models/sf/SFieldDescribe.js';
2324
import SObjectDescribe from '../../../src/modules/models/sf/SObjectDescribe.js';
2425
import OrgConnectionAdapter from '../../../src/modules/org/OrgConnectionAdapter.js';
26+
import OrgDataService from '../../../src/modules/org/OrgDataService.js';
2527

2628
type FieldInitType = Partial<SFieldDescribe> & { name: string };
2729
type OrgConnectionStubType = {
@@ -99,6 +101,162 @@ describe('MigrationJob', () => {
99101
Common.logger = originalLogger;
100102
});
101103

104+
const prepareSingleObjectStrategyJobAsync = async (
105+
object: ScriptObject,
106+
describe: SObjectDescribe,
107+
targetCount: number
108+
): Promise<MigrationJob> => {
109+
const script = new Script();
110+
script.objectSets = [new ScriptObjectSet([object])];
111+
112+
const sourceOrg = new ScriptOrg();
113+
sourceOrg.name = 'source';
114+
sourceOrg.media = DATA_MEDIA_TYPE.Org;
115+
sourceOrg.script = script;
116+
const targetOrg = new ScriptOrg();
117+
targetOrg.name = 'target';
118+
targetOrg.media = DATA_MEDIA_TYPE.Org;
119+
targetOrg.script = script;
120+
script.sourceOrg = sourceOrg;
121+
script.targetOrg = targetOrg;
122+
123+
const metadataProvider = createMetadataProvider(new Map([[object.name, describe]]));
124+
const job = new MigrationJob({ script, metadataProvider });
125+
const originalConnection = OrgConnectionAdapter.getConnectionForAliasAsync.bind(OrgConnectionAdapter);
126+
const queryOrgAsyncDescriptor = Object.getOwnPropertyDescriptor(OrgDataService.prototype, 'queryOrgAsync');
127+
if (typeof queryOrgAsyncDescriptor?.value !== 'function') {
128+
throw new Error('OrgDataService.queryOrgAsync descriptor was not found.');
129+
}
130+
const originalQueryOrgAsync = queryOrgAsyncDescriptor.value as OrgDataService['queryOrgAsync'];
131+
OrgConnectionAdapter.getConnectionForAliasAsync = async () => createOrgConnectionStub() as never;
132+
OrgDataService.prototype.queryOrgAsync = async (
133+
query: string,
134+
org: ScriptOrg
135+
): Promise<Array<Record<string, unknown>>> => {
136+
if (!query.includes('COUNT')) {
137+
return [];
138+
}
139+
return [{ CNT: org.name === 'target' ? targetCount : 1 }];
140+
};
141+
142+
try {
143+
await job.loadAsync();
144+
await job.setupAsync();
145+
await job.prepareAsync();
146+
} finally {
147+
OrgConnectionAdapter.getConnectionForAliasAsync = originalConnection;
148+
OrgDataService.prototype.queryOrgAsync = originalQueryOrgAsync;
149+
}
150+
151+
return job;
152+
};
153+
154+
it('uses filtered target queries for master objects with simple external id above the target threshold', async () => {
155+
const account = new ScriptObject('Account');
156+
account.operation = OPERATION.Upsert;
157+
account.query = 'SELECT Id, External_Key__c FROM Account';
158+
account.externalId = 'External_Key__c';
159+
160+
const job = await prepareSingleObjectStrategyJobAsync(
161+
account,
162+
createDescribe('Account', [{ name: 'Id' }, { name: 'External_Key__c', updateable: true, creatable: true }]),
163+
TARGET_FULL_QUERY_RECORDS_THRESHOLD + 1
164+
);
165+
166+
const task = job.getTaskBySObjectName('Account');
167+
assert.equal(task?.scriptObject.processAllSource, true);
168+
assert.equal(task?.scriptObject.processAllTarget, false);
169+
});
170+
171+
it('uses full target query for simple external id below the target threshold', async () => {
172+
const account = new ScriptObject('Account');
173+
account.operation = OPERATION.Upsert;
174+
account.query = 'SELECT Id, External_Key__c FROM Account';
175+
account.externalId = 'External_Key__c';
176+
177+
const job = await prepareSingleObjectStrategyJobAsync(
178+
account,
179+
createDescribe('Account', [{ name: 'Id' }, { name: 'External_Key__c', updateable: true, creatable: true }]),
180+
TARGET_FULL_QUERY_RECORDS_THRESHOLD - 1
181+
);
182+
183+
const task = job.getTaskBySObjectName('Account');
184+
assert.equal(task?.scriptObject.processAllTarget, true);
185+
});
186+
187+
it('uses the object target full-query threshold override', async () => {
188+
const account = new ScriptObject('Account');
189+
account.operation = OPERATION.Upsert;
190+
account.query = 'SELECT Id, External_Key__c FROM Account';
191+
account.externalId = 'External_Key__c';
192+
account.targetFullQueryRecordsThreshold = TARGET_FULL_QUERY_RECORDS_THRESHOLD + 10;
193+
194+
const job = await prepareSingleObjectStrategyJobAsync(
195+
account,
196+
createDescribe('Account', [{ name: 'Id' }, { name: 'External_Key__c', updateable: true, creatable: true }]),
197+
TARGET_FULL_QUERY_RECORDS_THRESHOLD + 1
198+
);
199+
200+
const task = job.getTaskBySObjectName('Account');
201+
assert.equal(task?.scriptObject.processAllTarget, true);
202+
});
203+
204+
it('keeps queryAllTarget on full target query regardless of target count', async () => {
205+
const account = new ScriptObject('Account');
206+
account.operation = OPERATION.Upsert;
207+
account.query = 'SELECT Id, External_Key__c FROM Account';
208+
account.externalId = 'External_Key__c';
209+
account.queryAllTarget = true;
210+
211+
const job = await prepareSingleObjectStrategyJobAsync(
212+
account,
213+
createDescribe('Account', [{ name: 'Id' }, { name: 'External_Key__c', updateable: true, creatable: true }]),
214+
TARGET_FULL_QUERY_RECORDS_THRESHOLD + 1
215+
);
216+
217+
const task = job.getTaskBySObjectName('Account');
218+
assert.equal(task?.scriptObject.processAllTarget, true);
219+
});
220+
221+
it('uses full target query for complex external id regardless of target count', async () => {
222+
const account = new ScriptObject('Account');
223+
account.operation = OPERATION.Upsert;
224+
account.query = 'SELECT Id, External_Key__c, Name FROM Account';
225+
account.externalId = 'External_Key__c;Name';
226+
227+
const job = await prepareSingleObjectStrategyJobAsync(
228+
account,
229+
createDescribe('Account', [
230+
{ name: 'Id' },
231+
{ name: 'External_Key__c', updateable: true, creatable: true },
232+
{ name: 'Name', nameField: true, updateable: true, creatable: true },
233+
]),
234+
TARGET_FULL_QUERY_RECORDS_THRESHOLD + 1
235+
);
236+
237+
const task = job.getTaskBySObjectName('Account');
238+
assert.equal(task?.scriptObject.processAllTarget, true);
239+
});
240+
241+
it('uses full target query for special objects regardless of target count', async () => {
242+
const recordType = new ScriptObject('RecordType');
243+
recordType.query = 'SELECT Id, DeveloperName, NamespacePrefix, SobjectType FROM RecordType';
244+
245+
const job = await prepareSingleObjectStrategyJobAsync(
246+
recordType,
247+
createDescribe('RecordType', [
248+
{ name: 'Id' },
249+
{ name: 'DeveloperName' },
250+
{ name: 'NamespacePrefix' },
251+
{ name: 'SobjectType' },
252+
]),
253+
TARGET_FULL_QUERY_RECORDS_THRESHOLD + 1
254+
);
255+
256+
const task = job.getTaskBySObjectName('RecordType');
257+
assert.equal(task?.scriptObject.processAllTarget, true);
258+
});
259+
102260
it('orders tasks by RecordType, readonly, and lookup parents', async () => {
103261
const recordType = new ScriptObject('RecordType');
104262
recordType.operation = OPERATION.Readonly;

test/modules/script/script-loader.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,31 @@ describe('ScriptLoader', () => {
234234
}
235235
});
236236

237+
it('loads object target full-query threshold from export.json', async () => {
238+
const rootPath = createTempDir();
239+
const payload: ScriptPayloadType = {
240+
objects: [
241+
{
242+
query: 'SELECT Id, Name FROM Account',
243+
operation: 'Upsert',
244+
externalId: 'Name',
245+
targetFullQueryRecordsThreshold: 125,
246+
},
247+
],
248+
};
249+
writeExportJson(rootPath, payload);
250+
const logger = createLogger(rootPath);
251+
252+
try {
253+
const script = await ScriptLoader.loadFromPathAsync(rootPath, logger);
254+
const object = script.objectSets[0].objects[0];
255+
256+
assert.equal(object.targetFullQueryRecordsThreshold, 125);
257+
} finally {
258+
fs.rmSync(rootPath, { recursive: true, force: true });
259+
}
260+
});
261+
237262
it('preserves explicit null or empty values from export.json', async () => {
238263
const rootPath = createTempDir();
239264
const previousReadDelimiter = Common.csvReadFileDelimiter;

0 commit comments

Comments
 (0)