Skip to content

Commit 89e1e5e

Browse files
authored
Merge pull request #1172 from salesforcecli/cd/fix-bulk-upsert
fix(bulk-v1): properly handle +1 job batches
2 parents 6f1445b + a14468f commit 89e1e5e

9 files changed

Lines changed: 86 additions & 55 deletions

File tree

.github/workflows/onRelease.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
uses: salesforcecli/github-workflows/.github/workflows/npmPublish.yml@main
2626
needs: [getDistTag]
2727
with:
28-
ctc: true
28+
# ctc: true
2929
sign: true
3030
tag: ${{ needs.getDistTag.outputs.tag || 'latest' }}
3131
githubTag: ${{ github.event.release.tag_name || inputs.tag }}

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ jobs:
3131
- 'yarn test:nuts:data:record'
3232
- 'yarn test:nuts:data:search'
3333
- 'yarn test:nuts:data:tree'
34+
- 'yarn test:nuts:force:data:bulk-upsert-delete-status'
3435
fail-fast: false
3536
with:
3637
os: ${{ matrix.os }}

messages/batcher.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Batch Status
1818

1919
Will poll the batch statuses every %s seconds.
2020
To fetch the status on your own, press CTRL+C and use the command:
21-
sf force data bulk status -i %s -b [<batchId>]
21+
sf force data bulk status -i %s -b %s
2222

2323
# ExternalIdRequired
2424

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,12 @@
114114
"test:nuts:data:search": "nyc mocha \"./test/commands/data/search.nut.ts\" --slow 4500 --timeout 600000 --parallel --jobs 20",
115115
"test:nuts:data:create": "nyc mocha \"./test/commands/data/create/*.nut.ts\" --slow 4500 --timeout 600000 --parallel --jobs 20",
116116
"test:nuts:data:bulk-upsert-delete": "nyc mocha \"./test/commands/data/dataBulk.nut.ts\" --slow 4500 --timeout 600000 --parallel --jobs 20",
117+
"test:nuts:force:data:bulk-upsert-delete-status": "nyc mocha \"./test/commands/force/data/bulk/dataBulk.nut.ts\" --slow 4500 --timeout 600000 --parallel --jobs 20",
117118
"test:only": "wireit",
118119
"version": "oclif readme"
119120
},
120121
"dependencies": {
121-
"@jsforce/jsforce-node": "^3.6.3",
122+
"@jsforce/jsforce-node": "^3.6.4",
122123
"@oclif/multi-stage-output": "^0.8.5",
123124
"@salesforce/core": "^8.6.1",
124125
"@salesforce/kit": "^3.2.2",

src/batcher.ts

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { ReadStream } from 'node:fs';
9+
import { EOL } from 'node:os';
910
import { Connection, Messages, SfError } from '@salesforce/core';
1011
import { Ux } from '@salesforce/sf-plugins-core';
1112
import type { Schema } from '@jsforce/jsforce-node';
@@ -31,7 +32,7 @@ const messages = Messages.loadMessages('@salesforce/plugin-data', 'batcher');
3132
type BatchEntry = Record<string, string>;
3233
type Batches = BatchEntry[][];
3334

34-
type BulkResult = {
35+
export type BulkResult = {
3536
$: {
3637
xmlns: string;
3738
};
@@ -58,19 +59,12 @@ export class Batcher {
5859
* @param jobId {string}
5960
* @param doneCallback
6061
*/
61-
public async fetchAndDisplayJobStatus(
62-
jobId: string,
63-
doneCallback?: (...args: [{ job: JobInfo }]) => void
64-
): Promise<JobInfo> {
62+
public async fetchAndDisplayJobStatus(jobId: string): Promise<JobInfo> {
6563
const job = this.conn.bulk.job(jobId);
6664
const jobInfo = await job.check();
6765

6866
this.bulkStatus(jobInfo, undefined, undefined, true);
6967

70-
if (doneCallback) {
71-
doneCallback({ job: jobInfo });
72-
}
73-
7468
return jobInfo;
7569
}
7670

@@ -120,8 +114,7 @@ export class Batcher {
120114
records: ReadStream,
121115
sobjectType: string,
122116
wait?: number
123-
): Promise<BulkResult[] | JobInfo[]> {
124-
const batchesCompleted = 0;
117+
): Promise<BulkResult[] | JobInfo[] | undefined> {
125118
let batchesQueued = 0;
126119
const overallInfo = false;
127120

@@ -130,7 +123,10 @@ export class Batcher {
130123
// The error handling for this gets quite tricky when there are multiple batches
131124
// Currently, we bail out early by calling an Error.exit
132125
// But, we might want to actually continue to the next batch.
133-
return (await Promise.all(
126+
//
127+
// async: batches are created and return array of batch info.
128+
// sync: batches are created, it waits until they all finish and Promise.all resolves `undefined`.
129+
const batchInfos = (await Promise.all(
134130
batches.map(
135131
async (batch: Array<Record<string, string>>, i: number): Promise<BulkResult | BatchInfo | void | JobInfo> => {
136132
const newBatch = job.createBatch();
@@ -186,14 +182,16 @@ export class Batcher {
186182
}
187183
);
188184
} else {
189-
resolve(this.waitForCompletion(newBatch, batchesCompleted, overallInfo, i + 1, batches.length, wait));
185+
resolve(this.waitForCompletion(newBatch, overallInfo, i + 1, wait));
190186
}
191187

192188
void newBatch.execute(batch);
193189
});
194190
}
195191
)
196-
)) as BulkResult[];
192+
)) as BulkResult[] | undefined;
193+
194+
return wait ? [await this.fetchAndDisplayJobStatus(job.id!)] : batchInfos;
197195
}
198196

199197
/**
@@ -229,12 +227,10 @@ export class Batcher {
229227
*/
230228
private async waitForCompletion<J extends Schema, T extends BulkOperation>(
231229
newBatch: Batch<J, T>,
232-
batchesCompleted: number,
233230
overallInfo: boolean,
234231
batchNum: number,
235-
totalNumBatches: number,
236232
waitMins: number
237-
): Promise<JobInfo> {
233+
): Promise<void> {
238234
return new Promise((resolve, reject) => {
239235
void newBatch.on(
240236
'queue',
@@ -245,10 +241,10 @@ export class Batcher {
245241
if (result.state === 'Failed') {
246242
reject(result.stateMessage);
247243
} else if (!overallInfo) {
248-
this.ux.log(messages.getMessage('PollingInfo', [POLL_FREQUENCY_MS / 1000, batchInfo.jobId]));
244+
this.ux.log(messages.getMessage('PollingInfo', [POLL_FREQUENCY_MS / 1000, batchInfo.jobId, batchInfo.id]));
249245
overallInfo = true;
250246
}
251-
this.ux.log(messages.getMessage('BatchQueued', [batchNum, batchInfo.id]));
247+
this.ux.log(messages.getMessage('BatchQueued', [batchNum, batchInfo.id]), EOL);
252248
newBatch.poll(POLL_FREQUENCY_MS, waitMins * 60_000);
253249
}
254250
);
@@ -257,10 +253,7 @@ export class Batcher {
257253
void newBatch.on('response', async (results: BulkIngestBatchResult): Promise<void> => {
258254
const summary: BatchInfo = await newBatch.check();
259255
this.bulkStatus(summary, results, batchNum);
260-
batchesCompleted++;
261-
if (batchesCompleted === totalNumBatches) {
262-
resolve(await this.fetchAndDisplayJobStatus(summary.jobId));
263-
}
256+
resolve();
264257
});
265258
});
266259
}

test/commands/batcher.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ let ux: Ux;
2424
describe('batcher', () => {
2525
const $$ = sinon.createSandbox();
2626
describe('bulkBatchStatus', () => {
27+
// @ts-ignore this test doesn't nedd all JobInfo props.
2728
const summary: JobInfo = { id: '123', operation: 'upsert', state: 'Closed', object: 'Account' };
2829
beforeEach(() => {
2930
const conn = $$.stub(Connection.prototype);

test/commands/data/tree/dataTreeMoreThan200.nut.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe('data:tree commands with more than 200 records are batches in safe grou
4747
ensureExitCode: 0,
4848
}
4949
);
50-
expect(importResult.jsonOutput?.result.length).to.equal(10000, 'Expected 10000 records to be imported');
50+
expect(importResult.jsonOutput?.result.length).to.equal(10_000, 'Expected 10000 records to be imported');
5151

5252
execCmd(
5353
`data:export:tree --query "${query}" --prefix ${prefix} --output-dir ${path.join(
@@ -75,7 +75,7 @@ describe('data:tree commands with more than 200 records are batches in safe grou
7575
}).jsonOutput;
7676

7777
expect(queryResults?.result.totalSize).to.equal(
78-
10000,
78+
10_000,
7979
`Expected 10000 Account objects returned by the query to org: ${importAlias}`
8080
);
8181
});

test/commands/force/data/bulk/dataBulk.nut.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import fs from 'node:fs';
1010
import { expect } from 'chai';
1111
import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit';
1212
import { sleep } from '@salesforce/kit';
13-
import { BatcherReturnType } from '../../../../../src/batcher.js';
13+
import { JobInfo } from '@jsforce/jsforce-node/lib/api/bulk.js';
14+
import { BatcherReturnType, BulkResult } from '../../../../../src/batcher.js';
1415
import { StatusResult } from '../../../../../src/types.js';
1516
import { QueryResult } from '../../../data/query/query.nut.js';
17+
import { DataExportBulkResult } from '../../../../../src/commands/data/export/bulk.js';
1618

1719
let testSession: TestSession;
1820

@@ -121,6 +123,55 @@ describe('force:data:bulk commands', () => {
121123
checkBulkStatusJsonResponse(bulkDeleteResult.jobId, bulkDeleteResult.id);
122124
});
123125

126+
// Bulk v1 batch limit is 10K records so this NUT ensures we handle multiple batches correctly.
127+
it('should upsert, query and delete 60K accounts (sync)', async () => {
128+
// bulk v1 upsert
129+
const cmdRes = execCmd<BatcherReturnType>(
130+
`force:data:bulk:upsert --sobject Account --file ${path.join(
131+
'.',
132+
'data',
133+
'bulkUpsertLarge.csv'
134+
)} --externalid Id --wait 10 --json`,
135+
{ ensureExitCode: 0 }
136+
).jsonOutput?.result;
137+
assert.equal(cmdRes?.length, 1);
138+
// guaranteed by the assertion, done for ts
139+
const upsertJobResult = cmdRes[0];
140+
141+
if (isBulkJob(upsertJobResult)) {
142+
assert.equal(upsertJobResult.numberBatchesCompleted, '8');
143+
assert.equal(upsertJobResult.numberBatchesFailed, '0');
144+
assert.equal(upsertJobResult.numberRecordsProcessed, '76380');
145+
} else {
146+
assert.fail('upsertJobResult does not contain bulk job info.');
147+
}
148+
149+
// bulk v2 export to get IDs of accounts to delete
150+
const outputFile = 'export-accounts.csv';
151+
const result = execCmd<DataExportBulkResult>(
152+
`data export bulk -q "select id from account where phone = '415-555-0000'" --output-file ${outputFile} --wait 10 --json`,
153+
{ ensureExitCode: 0 }
154+
).jsonOutput?.result;
155+
expect(result?.totalSize).to.equal(76_380);
156+
expect(result?.filePath).to.equal(outputFile);
157+
158+
// bulk v1 delete
159+
const cmdDeleteRes = execCmd<BatcherReturnType>(
160+
`force:data:bulk:delete --sobject Account --file ${outputFile} --wait 10 --json`,
161+
{ ensureExitCode: 0 }
162+
).jsonOutput?.result;
163+
assert.equal(cmdDeleteRes?.length, 1);
164+
// guaranteed by the assertion, done for ts
165+
const deleteJobResult = cmdDeleteRes[0];
166+
if (isBulkJob(deleteJobResult)) {
167+
assert.equal(deleteJobResult.numberBatchesCompleted, '8');
168+
assert.equal(deleteJobResult.numberBatchesFailed, '0');
169+
assert.equal(deleteJobResult.numberRecordsProcessed, '76380');
170+
} else {
171+
assert.fail('deleteJobResult does not contain bulk job info.');
172+
}
173+
});
174+
124175
it('should upsert, query, and delete 10 accounts all serially', async () => {
125176
const bulkUpsertResult = bulkInsertAccounts();
126177
await isCompleted(
@@ -195,3 +246,7 @@ const bulkInsertAccounts = () => {
195246
assert('jobId' in bulkUpsertResult);
196247
return bulkUpsertResult;
197248
};
249+
250+
function isBulkJob(info: JobInfo | BulkResult): info is JobInfo {
251+
return (info as JobInfo).numberBatchesCompleted !== undefined;
252+
}

yarn.lock

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -939,22 +939,14 @@
939939
"@smithy/types" "^3.7.2"
940940
tslib "^2.6.2"
941941

942-
"@aws-sdk/types@3.723.0":
942+
"@aws-sdk/types@3.723.0", "@aws-sdk/types@^3.222.0":
943943
version "3.723.0"
944944
resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.723.0.tgz#f0c5a6024a73470421c469b6c1dd5bc4b8fb851b"
945945
integrity sha512-LmK3kwiMZG1y5g3LGihT9mNkeNOmwEyPk6HGcJqh0wOSV4QpWoKu2epyKE4MLQNUUlz2kOVbVbOrwmI6ZcteuA==
946946
dependencies:
947947
"@smithy/types" "^4.0.0"
948948
tslib "^2.6.2"
949949

950-
"@aws-sdk/types@^3.222.0":
951-
version "3.692.0"
952-
resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.692.0.tgz#c8f6c75b6ad659865b72759796d4d92c1b72069b"
953-
integrity sha512-RpNvzD7zMEhiKgmlxGzyXaEcg2khvM7wd5sSHVapOcrde1awQSOMGI4zKBQ+wy5TnDfrm170ROz/ERLYtrjPZA==
954-
dependencies:
955-
"@smithy/types" "^3.7.0"
956-
tslib "^2.6.2"
957-
958950
"@aws-sdk/util-arn-parser@3.723.0":
959951
version "3.723.0"
960952
resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.723.0.tgz#e9bff2b13918a92d60e0012101dad60ed7db292c"
@@ -1503,12 +1495,7 @@
15031495
"@inquirer/type" "^3.0.2"
15041496
yoctocolors-cjs "^2.1.2"
15051497

1506-
"@inquirer/figures@^1.0.5", "@inquirer/figures@^1.0.6":
1507-
version "1.0.8"
1508-
resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-1.0.8.tgz#d9e414a1376a331a0e71b151fea27c48845788b0"
1509-
integrity sha512-tKd+jsmhq21AP1LhexC0pPwsCxEhGgAkg28byjJAd+xhmIs8LUX8JbUc3vBf3PhLxWiB5EvyBE5X7JSPAqMAqg==
1510-
1511-
"@inquirer/figures@^1.0.9":
1498+
"@inquirer/figures@^1.0.5", "@inquirer/figures@^1.0.6", "@inquirer/figures@^1.0.9":
15121499
version "1.0.9"
15131500
resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-1.0.9.tgz#9d8128f8274cde4ca009ca8547337cab3f37a4a3"
15141501
integrity sha512-BXvGj0ehzrngHTPTDqUoDT3NXL8U0RxUk2zJm2A66RhCEIWdtU1v6GuUqNAgArW4PQ9CinqIWyHdQgdwOj06zQ==
@@ -1699,10 +1686,10 @@
16991686
"@jridgewell/resolve-uri" "^3.1.0"
17001687
"@jridgewell/sourcemap-codec" "^1.4.14"
17011688

1702-
"@jsforce/jsforce-node@^3.6.1", "@jsforce/jsforce-node@^3.6.3":
1703-
version "3.6.3"
1704-
resolved "https://registry.yarnpkg.com/@jsforce/jsforce-node/-/jsforce-node-3.6.3.tgz#a5c984b6deffac01ddabc3f4b48374408c5cd194"
1705-
integrity sha512-sNUeBzfUv57uH0AiYuAOO8yjBP7lNY33mWybrjvBud8gMFVWozY6UAWU1DUk/dpqZ0+FK3iqB++nOQRczj1nSg==
1689+
"@jsforce/jsforce-node@^3.6.1", "@jsforce/jsforce-node@^3.6.4":
1690+
version "3.6.4"
1691+
resolved "https://registry.yarnpkg.com/@jsforce/jsforce-node/-/jsforce-node-3.6.4.tgz#c1055e2064a501633e9d86f6f2fe1b287c6ce9ce"
1692+
integrity sha512-9IZL5lFDE1nUnPYnzOleH0xaEE3Sc9sQcLKwx1LQeSyAI/KvnxySadlIpZAdTrJap4hDRFQkXiEFiJ3oFwj/zg==
17061693
dependencies:
17071694
"@sindresorhus/is" "^4"
17081695
base64url "^3.0.1"
@@ -2654,13 +2641,6 @@
26542641
"@smithy/util-stream" "^4.0.1"
26552642
tslib "^2.6.2"
26562643

2657-
"@smithy/types@^3.7.0":
2658-
version "3.7.1"
2659-
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-3.7.1.tgz#4af54c4e28351e9101996785a33f2fdbf93debe7"
2660-
integrity sha512-XKLcLXZY7sUQgvvWyeaL/qwNPp6V3dWcUjqrQKjSb+tzYiCy340R/c64LV5j+Tnb2GhmunEX0eou+L+m2hJNYA==
2661-
dependencies:
2662-
tslib "^2.6.2"
2663-
26642644
"@smithy/types@^3.7.2":
26652645
version "3.7.2"
26662646
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-3.7.2.tgz#05cb14840ada6f966de1bf9a9c7dd86027343e10"

0 commit comments

Comments
 (0)