Skip to content

Commit 5b82c98

Browse files
authored
Merge pull request #8390 from BitGo/WP-8343
fix(sdk-api): replace naive keychain batching with FFD bin packing
2 parents cdc4442 + 7b5eaf0 commit 5b82c98

2 files changed

Lines changed: 307 additions & 14 deletions

File tree

modules/sdk-api/src/bitgoAPI.ts

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2028,7 +2028,7 @@ export class BitGoAPI implements BitGoBase {
20282028
await this.processKeychainPasswordUpdatesInBatches(
20292029
updatePasswordParams.keychains,
20302030
updatePasswordParams.v2_keychains,
2031-
batchingFlowCheck.noOfBatches,
2031+
batchingFlowCheck.maxBatchSizeKB,
20322032
3
20332033
);
20342034
// Call changepassword API without keychains for batching flow
@@ -2287,30 +2287,80 @@ export class BitGoAPI implements BitGoBase {
22872287
}
22882288

22892289
/**
2290-
* Process keychain password updates in batches with retry logic
2290+
* Pack keychains into batches using First Fit Decreasing (FFD) algorithm.
2291+
*
2292+
* @param keychains - V1 keychains
2293+
* @param v2Keychains - V2 keychains
2294+
* @param maxBatchSizeBytes - Maximum byte size per batch
2295+
* @private
2296+
*/
2297+
private packKeychainsFFD(
2298+
keychains: Record<string, string>,
2299+
v2Keychains: Record<string, string>,
2300+
maxBatchSizeBytes: number
2301+
): Array<{ v1Batch: Record<string, string>; v2Batch: Record<string, string>; sizeBytes: number }> {
2302+
const entrySize = (id: string, value: string) => Buffer.byteLength(id, 'utf8') + Buffer.byteLength(value, 'utf8');
2303+
2304+
const items = [
2305+
...Object.entries(keychains).map(([id, value]) => ({ id, value, sizeBytes: entrySize(id, value), isV2: false })),
2306+
...Object.entries(v2Keychains).map(([id, value]) => ({ id, value, sizeBytes: entrySize(id, value), isV2: true })),
2307+
].sort((a, b) => b.sizeBytes - a.sizeBytes);
2308+
2309+
const bins: Array<{ v1Batch: Record<string, string>; v2Batch: Record<string, string>; sizeBytes: number }> = [];
2310+
2311+
for (const item of items) {
2312+
if (item.sizeBytes > maxBatchSizeBytes) {
2313+
throw new Error(`Keychain with id ${item.id} exceeds the maximum batch size and cannot be processed`);
2314+
}
2315+
2316+
const target = bins.find((bin) => bin.sizeBytes + item.sizeBytes <= maxBatchSizeBytes);
2317+
if (target) {
2318+
if (item.isV2) {
2319+
target.v2Batch[item.id] = item.value;
2320+
} else {
2321+
target.v1Batch[item.id] = item.value;
2322+
}
2323+
target.sizeBytes += item.sizeBytes;
2324+
} else {
2325+
const newBin = {
2326+
v1Batch: {} as Record<string, string>,
2327+
v2Batch: {} as Record<string, string>,
2328+
sizeBytes: item.sizeBytes,
2329+
};
2330+
if (item.isV2) {
2331+
newBin.v2Batch[item.id] = item.value;
2332+
} else {
2333+
newBin.v1Batch[item.id] = item.value;
2334+
}
2335+
bins.push(newBin);
2336+
}
2337+
}
2338+
2339+
return bins;
2340+
}
2341+
2342+
/**
2343+
* Process keychain password updates in batches with retry logic.
2344+
* Uses First Fit Decreasing (FFD) bin packing to ensure no batch exceeds
2345+
* maxBatchSizeKB
2346+
*
22912347
* @param keychains - The v1 keychains to update
22922348
* @param v2Keychains - The v2 keychains to update
2293-
* @param noOfBatches - Number of batches to split the keychains into
2349+
* @param maxBatchSizeKB - Maximum payload size per batch in kilobytes
22942350
* @param maxRetries - Maximum number of retries per batch
22952351
* @private
22962352
*/
22972353
private async processKeychainPasswordUpdatesInBatches(
22982354
keychains: Record<string, string>,
22992355
v2Keychains: Record<string, string>,
2300-
noOfBatches: number,
2356+
maxBatchSizeKB: number,
23012357
maxRetries: number
23022358
): Promise<void> {
2303-
// Split keychains into batches
2304-
const v1KeychainEntries = Object.entries(keychains);
2305-
const v2KeychainEntries = Object.entries(v2Keychains);
2306-
2307-
const v1BatchSize = Math.ceil(v1KeychainEntries.length / noOfBatches);
2308-
const v2BatchSize = Math.ceil(v2KeychainEntries.length / noOfBatches);
2359+
const maxBatchSizeBytes = maxBatchSizeKB * 1024;
2360+
const bins = this.packKeychainsFFD(keychains, v2Keychains, maxBatchSizeBytes);
23092361

2310-
// Call batching API for each batch with retry logic
2311-
for (let i = 0; i < noOfBatches; i++) {
2312-
const v1Batch = Object.fromEntries(v1KeychainEntries.slice(i * v1BatchSize, (i + 1) * v1BatchSize));
2313-
const v2Batch = Object.fromEntries(v2KeychainEntries.slice(i * v2BatchSize, (i + 1) * v2BatchSize));
2362+
for (let i = 0; i < bins.length; i++) {
2363+
const { v1Batch, v2Batch } = bins[i];
23142364

23152365
let retryCount = 0;
23162366
let success = false;

modules/sdk-api/test/unit/bitgoAPI.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,4 +794,247 @@ describe('Constructor', function () {
794794
nock.cleanAll();
795795
});
796796
});
797+
798+
describe('packKeychainsFFD', function () {
799+
let bitgo: BitGoAPI;
800+
801+
before(function () {
802+
bitgo = new BitGoAPI({ env: 'custom', customRootURI: 'https://app.example.local' });
803+
});
804+
805+
function ffd(
806+
keychains: Record<string, string>,
807+
v2Keychains: Record<string, string>,
808+
maxBatchSizeBytes: number
809+
): Array<{ v1Batch: Record<string, string>; v2Batch: Record<string, string>; sizeBytes: number }> {
810+
return (bitgo as any).packKeychainsFFD(keychains, v2Keychains, maxBatchSizeBytes);
811+
}
812+
813+
it('returns an empty array when both inputs are empty', function () {
814+
const bins = ffd({}, {}, 1024);
815+
bins.should.be.an.Array();
816+
bins.should.have.length(0);
817+
});
818+
819+
it('packs all items into a single bin when they fit within the limit', function () {
820+
// Each entry: 2-byte id + 10-byte value = 12 bytes; 5 entries = 60 bytes < 200 limit
821+
const keychains = { k1: 'aaaaaaaaaa', k2: 'bbbbbbbbbb', k3: 'cccccccccc' };
822+
const v2Keychains = { v1: 'dddddddddd', v2: 'eeeeeeeeee' };
823+
const bins = ffd(keychains, v2Keychains, 200);
824+
bins.should.have.length(1);
825+
Object.keys(bins[0].v1Batch).should.have.length(3);
826+
Object.keys(bins[0].v2Batch).should.have.length(2);
827+
});
828+
829+
it('splits into multiple bins when a single bin cannot fit all items', function () {
830+
// id='k1' (2 bytes) + 600-char value = 602 bytes; 2 entries exceed 700-byte limit
831+
const keychains = { k1: 'x'.repeat(600), k2: 'y'.repeat(600) };
832+
const bins = ffd(keychains, {}, 700);
833+
bins.should.have.length(2);
834+
});
835+
836+
it('throws when a single item exceeds maxBatchSizeBytes', function () {
837+
// id='k1' (2 bytes) + 200-char value = 202 bytes > 100-byte limit
838+
const keychains = { k1: 'x'.repeat(200) };
839+
try {
840+
ffd(keychains, {}, 100);
841+
throw new Error('Expected error not thrown');
842+
} catch (e) {
843+
e.message.should.match(/exceeds the maximum batch size/);
844+
}
845+
});
846+
847+
it('places V1 keychains in v1Batch and V2 keychains in v2Batch', function () {
848+
const keychains = { k1: 'v1value' };
849+
const v2Keychains = { k2: 'v2value' };
850+
const bins = ffd(keychains, v2Keychains, 10000);
851+
bins.should.have.length(1);
852+
bins[0].v1Batch.should.have.property('k1', 'v1value');
853+
bins[0].v2Batch.should.have.property('k2', 'v2value');
854+
bins[0].v1Batch.should.not.have.property('k2');
855+
bins[0].v2Batch.should.not.have.property('k1');
856+
});
857+
858+
it('uses FFD ordering so the largest item is packed first', function () {
859+
// 'big': id(3) + 497 bytes = 500 bytes; 's1','s2': id(2) + 98 bytes = 100 bytes each
860+
// maxBatchSizeBytes = 600
861+
// FFD order (descending): big(500), s1(100), s2(100)
862+
// bin0: big(500). s1: 500+100=600 <=600, fits. s2: 600+100=700 > 600, new bin1.
863+
// Result: bin0={big,s1}, bin1={s2}
864+
const keychains = { big: 'x'.repeat(497), s1: 'y'.repeat(98), s2: 'z'.repeat(98) };
865+
const bins = ffd(keychains, {}, 600);
866+
bins.should.have.length(2);
867+
// 'big' should share its bin with one small item (FFD packs the largest first)
868+
const binWithBig = bins.find((b) => 'big' in b.v1Batch)!;
869+
Object.keys(binWithBig.v1Batch).should.have.length(2);
870+
});
871+
});
872+
873+
describe('processKeychainPasswordUpdatesInBatches', function () {
874+
const ROOT = 'https://app.example.local';
875+
let bitgo: BitGoAPI;
876+
877+
beforeEach(function () {
878+
const strategy: IHmacAuthStrategy = {
879+
calculateRequestHeaders: sinon.stub().resolves({ hmac: 'hmac', timestamp: Date.now(), tokenHash: 'hash' }),
880+
verifyResponse: sinon.stub().resolves({
881+
isValid: true,
882+
expectedHmac: 'hmac',
883+
signatureSubject: '',
884+
isInResponseValidityWindow: true,
885+
verificationTime: Date.now(),
886+
}),
887+
calculateHMAC: sinon.stub().resolves('hashed-pw'),
888+
setToken: sinon.stub().resolves(),
889+
clearToken: sinon.stub().resolves(),
890+
};
891+
bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
892+
});
893+
894+
afterEach(function () {
895+
nock.cleanAll();
896+
sinon.restore();
897+
});
898+
899+
async function runBatches(
900+
keychains: Record<string, string>,
901+
v2Keychains: Record<string, string>,
902+
maxBatchSizeKB: number,
903+
maxRetries = 3
904+
): Promise<void> {
905+
return (bitgo as any).processKeychainPasswordUpdatesInBatches(keychains, v2Keychains, maxBatchSizeKB, maxRetries);
906+
}
907+
908+
it('makes a single PUT request for a small payload', async function () {
909+
const scope = nock(ROOT).put('/api/v2/user/keychains').reply(200, {});
910+
await runBatches({ k1: 'small' }, {}, 1024);
911+
scope.isDone().should.be.true();
912+
});
913+
914+
it('makes two PUT requests when the payload spans two bins', async function () {
915+
// id='k1'(2) + 600 = 602 bytes each; 2 entries exceed 1 KB limit => 2 bins
916+
const keychains = { k1: 'x'.repeat(600), k2: 'y'.repeat(600) };
917+
const scope = nock(ROOT).put('/api/v2/user/keychains').twice().reply(200, {});
918+
await runBatches(keychains, {}, 1);
919+
scope.isDone().should.be.true();
920+
});
921+
922+
it('retries a failed batch and succeeds on the second attempt', async function () {
923+
nock(ROOT).put('/api/v2/user/keychains').reply(500, { error: 'internal error' });
924+
nock(ROOT).put('/api/v2/user/keychains').reply(200, {});
925+
// Should not throw
926+
await runBatches({ k1: 'value' }, {}, 1024, 3);
927+
});
928+
929+
it('throws after exhausting all retries on persistent HTTP errors', async function () {
930+
nock(ROOT).put('/api/v2/user/keychains').times(3).reply(500, { error: 'internal error' });
931+
let thrownError: Error | undefined;
932+
try {
933+
await runBatches({ k1: 'value' }, {}, 1024, 3);
934+
} catch (e) {
935+
thrownError = e;
936+
}
937+
thrownError!.message.should.match(/failed after 3 retries/);
938+
});
939+
940+
it('throws after exhausting all retries when the server reports failed keychains', async function () {
941+
nock(ROOT)
942+
.put('/api/v2/user/keychains')
943+
.times(3)
944+
.reply(200, { failed: { v1: { k1: 'encryption error' } } });
945+
let thrownError: Error | undefined;
946+
try {
947+
await runBatches({ k1: 'value' }, {}, 1024, 3);
948+
} catch (e) {
949+
thrownError = e;
950+
}
951+
thrownError!.message.should.match(/had failed keychains/);
952+
});
953+
});
954+
955+
describe('changePassword batching flow', function () {
956+
const ROOT = 'https://app.example.local';
957+
let bitgo: BitGoAPI;
958+
let sandbox: sinon.SinonSandbox;
959+
960+
beforeEach(function () {
961+
sandbox = sinon.createSandbox();
962+
963+
const strategy: IHmacAuthStrategy = {
964+
calculateRequestHeaders: sandbox.stub().resolves({ hmac: 'hmac', timestamp: Date.now(), tokenHash: 'hash' }),
965+
verifyResponse: sandbox.stub().resolves({
966+
isValid: true,
967+
expectedHmac: 'hmac',
968+
signatureSubject: '',
969+
isInResponseValidityWindow: true,
970+
verificationTime: Date.now(),
971+
}),
972+
calculateHMAC: sandbox.stub().resolves('hashed-pw'),
973+
setToken: sandbox.stub().resolves(),
974+
clearToken: sandbox.stub().resolves(),
975+
};
976+
977+
bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy });
978+
(bitgo as any)._user = { username: 'test@bitgo.com' };
979+
980+
sandbox.stub(bitgo, 'verifyPassword').resolves(true);
981+
982+
sandbox.stub(bitgo, 'keychains').returns({
983+
updatePassword: sandbox.stub().resolves({ keychains: { k1: 'v1enc' }, version: 25 }),
984+
} as any);
985+
986+
sandbox.stub(bitgo, 'coin').returns({
987+
keychains: () => ({
988+
updatePassword: sandbox.stub().resolves({ v2k1: 'v2enc' }),
989+
}),
990+
} as any);
991+
});
992+
993+
afterEach(function () {
994+
nock.cleanAll();
995+
sandbox.restore();
996+
});
997+
998+
it('calls PUT /user/keychains and POST /user/changepassword without keychains when batching is enabled', async function () {
999+
nock(ROOT)
1000+
.get('/api/v2/user/checkBatchingPasswordFlow')
1001+
.query(true)
1002+
.reply(200, { isBatchingFlowEnabled: true, maxBatchSizeKB: 900 });
1003+
1004+
const batchPutScope = nock(ROOT).put('/api/v2/user/keychains').reply(200, {});
1005+
1006+
const changePassScope = nock(ROOT)
1007+
.post('/api/v1/user/changepassword', (body: any) => !body.keychains && !body.v2_keychains)
1008+
.reply(200, {});
1009+
1010+
await bitgo.changePassword({ oldPassword: 'oldpw', newPassword: 'newpw' });
1011+
1012+
batchPutScope.isDone().should.be.true();
1013+
changePassScope.isDone().should.be.true();
1014+
});
1015+
1016+
it('sends keychains in the POST body (legacy flow) when the server disables batching', async function () {
1017+
nock(ROOT).get('/api/v2/user/checkBatchingPasswordFlow').query(true).reply(200, { isBatchingFlowEnabled: false });
1018+
1019+
const legacyScope = nock(ROOT)
1020+
.post('/api/v1/user/changepassword', (body: any) => !!body.keychains && !!body.v2_keychains)
1021+
.reply(200, {});
1022+
1023+
await bitgo.changePassword({ oldPassword: 'oldpw', newPassword: 'newpw' });
1024+
1025+
legacyScope.isDone().should.be.true();
1026+
});
1027+
1028+
it('falls back to legacy flow when the batching check request fails', async function () {
1029+
nock(ROOT).get('/api/v2/user/checkBatchingPasswordFlow').query(true).reply(503, { error: 'service unavailable' });
1030+
1031+
const legacyScope = nock(ROOT)
1032+
.post('/api/v1/user/changepassword', (body: any) => !!body.keychains && !!body.v2_keychains)
1033+
.reply(200, {});
1034+
1035+
await bitgo.changePassword({ oldPassword: 'oldpw', newPassword: 'newpw' });
1036+
1037+
legacyScope.isDone().should.be.true();
1038+
});
1039+
});
7971040
});

0 commit comments

Comments
 (0)