Skip to content

Commit 3e57fa8

Browse files
author
naman-contentstack
committed
chore: update logic to stay consistent with CLI export package
1 parent ccf0a90 commit 3e57fa8

5 files changed

Lines changed: 156 additions & 16 deletions

File tree

.talismanrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
fileignoreconfig:
22
- filename: pnpm-lock.yaml
33
checksum: cdead0797199d22bbc55b9e5b6b86983f28eb760fabe5e1f2d5139c4456a9131
4+
- filename: packages/contentstack-asset-management/test/unit/utils/concurrent-batch.test.ts
5+
checksum: d6e5995e0bbdc862a2d21c0ebfead30d9939021da6f7f62c82027de1eed7fd76
46
version: '1.0'

packages/contentstack-asset-management/src/utils/concurrent-batch.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
/**
2+
* Batched concurrency primitive for the package. All concurrent work — pagination
3+
* fan-out, asset downloads, uploads, and folder/asset-type/field creation — runs
4+
* through these helpers. Do not hand-roll `Promise.all`/`Promise.allSettled`
5+
* batching elsewhere; use `runInBatches` (void, fault-tolerant) for bulk
6+
* side-effects and `mapInBatches` (collects results, fail-fast) for fetches.
7+
*/
8+
19
/**
210
* Split an array into chunks of at most `size` elements.
311
*/
@@ -32,3 +40,29 @@ export async function runInBatches<T>(
3240
offset += batch.length;
3341
}
3442
}
43+
44+
/**
45+
* Run async work in batches of at most `concurrency` tasks, collecting results
46+
* in input order. Uses Promise.all per batch (fail-fast: a rejected task aborts
47+
* the whole call). Use this for fetches where a dropped result would silently
48+
* yield incomplete data (e.g. pagination fan-out).
49+
*/
50+
export async function mapInBatches<T, R>(
51+
items: T[],
52+
concurrency: number,
53+
fn: (item: T, index: number) => Promise<R>,
54+
): Promise<R[]> {
55+
if (items.length === 0) {
56+
return [];
57+
}
58+
const limit = Math.max(1, concurrency);
59+
const batches = chunkArray(items, limit);
60+
const results: R[] = [];
61+
let offset = 0;
62+
for (const batch of batches) {
63+
const settled = await Promise.all(batch.map((item, j) => fn(item, offset + j)));
64+
results.push(...settled);
65+
offset += batch.length;
66+
}
67+
return results;
68+
}

packages/contentstack-asset-management/src/utils/cs-assets-api-adapter.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
22
import { basename } from 'node:path';
33
import { HttpClient, log, authenticationHandler, handleAndLogError } from '@contentstack/cli-utilities';
44

5-
import { chunkArray } from './concurrent-batch';
5+
import { mapInBatches } from './concurrent-batch';
66
import { FALLBACK_AM_API_FETCH_CONCURRENCY, FALLBACK_AM_API_PAGE_SIZE } from '../constants/index';
77

88
import type {
@@ -265,21 +265,13 @@ export class CSAssetsAdapter implements ICSAssetsAdapter {
265265
(_, i) => (i + 1) * pageSize,
266266
);
267267

268-
const skipBatches = chunkArray(skips, concurrency);
269-
const rest: unknown[] = [];
270-
271-
for (const batch of skipBatches) {
272-
const pages = await Promise.all(
273-
batch.map((skip) =>
274-
this.getSpaceLevel<Record<string, unknown>>(spaceUid, path, {
275-
...baseParams, limit: String(pageSize), skip: String(skip),
276-
}).then((r) => (Array.isArray(r?.[itemsKey]) ? (r[itemsKey] as unknown[]) : [])),
277-
),
278-
);
279-
rest.push(...pages.flat());
280-
}
268+
const pages = await mapInBatches(skips, concurrency, (skip) =>
269+
this.getSpaceLevel<Record<string, unknown>>(spaceUid, path, {
270+
...baseParams, limit: String(pageSize), skip: String(skip),
271+
}).then((r) => (Array.isArray(r?.[itemsKey]) ? (r[itemsKey] as unknown[]) : [])),
272+
);
281273

282-
return [...firstItems, ...rest];
274+
return [...firstItems, ...pages.flat()];
283275
}
284276

285277
async getWorkspaceAssets(spaceUid: string, workspaceUid?: string, pageSize = FALLBACK_AM_API_PAGE_SIZE, fetchConcurrency = FALLBACK_AM_API_FETCH_CONCURRENCY): Promise<unknown> {

packages/contentstack-asset-management/src/utils/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ export {
77
getReadableStreamFromDownloadResponse,
88
writeStreamToFile,
99
} from './export-helpers';
10-
export { chunkArray, runInBatches } from './concurrent-batch';
10+
export { chunkArray, runInBatches, mapInBatches } from './concurrent-batch';
1111
export { detectAssetManagementExportFromContentDir } from './detect-asset-management-export';
1212
export type { AssetManagementExportFlags } from '../types/asset-management-export-flags';
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { expect } from 'chai';
2+
3+
import { chunkArray, mapInBatches, runInBatches } from '../../../src/utils/concurrent-batch';
4+
5+
describe('concurrent-batch', () => {
6+
describe('chunkArray', () => {
7+
it('should split an array into chunks of at most `size`', () => {
8+
expect(chunkArray([1, 2, 3, 4, 5], 2)).to.deep.equal([[1, 2], [3, 4], [5]]);
9+
});
10+
11+
it('should return a single chunk when size >= length', () => {
12+
expect(chunkArray([1, 2, 3], 10)).to.deep.equal([[1, 2, 3]]);
13+
});
14+
15+
it('should return the whole array as one chunk when size <= 0', () => {
16+
expect(chunkArray([1, 2, 3], 0)).to.deep.equal([[1, 2, 3]]);
17+
});
18+
19+
it('should return [] for an empty array', () => {
20+
expect(chunkArray([], 3)).to.deep.equal([]);
21+
});
22+
});
23+
24+
describe('mapInBatches', () => {
25+
it('should collect results in input order', async () => {
26+
const results = await mapInBatches([1, 2, 3, 4, 5], 2, async (n) => n * 10);
27+
expect(results).to.deep.equal([10, 20, 30, 40, 50]);
28+
});
29+
30+
it('should pass the correct absolute index across batches', async () => {
31+
const indexes: number[] = [];
32+
await mapInBatches(['a', 'b', 'c', 'd', 'e'], 2, async (_item, index) => {
33+
indexes.push(index);
34+
return index;
35+
});
36+
expect([...indexes].sort((a, b) => a - b)).to.deep.equal([0, 1, 2, 3, 4]);
37+
});
38+
39+
it('should never run more than `concurrency` tasks at once', async () => {
40+
let inFlight = 0;
41+
let maxInFlight = 0;
42+
await mapInBatches(Array.from({ length: 10 }, (_, i) => i), 3, async (n) => {
43+
inFlight += 1;
44+
maxInFlight = Math.max(maxInFlight, inFlight);
45+
await new Promise((resolve) => setImmediate(resolve));
46+
inFlight -= 1;
47+
return n;
48+
});
49+
expect(maxInFlight).to.be.at.most(3);
50+
});
51+
52+
it('should return [] for an empty array without invoking fn', async () => {
53+
let called = false;
54+
const results = await mapInBatches([], 5, async (n) => {
55+
called = true;
56+
return n;
57+
});
58+
expect(results).to.deep.equal([]);
59+
expect(called).to.equal(false);
60+
});
61+
62+
it('should fail fast when a task rejects', async () => {
63+
let error: Error | undefined;
64+
try {
65+
await mapInBatches([1, 2, 3], 2, async (n) => {
66+
if (n === 2) throw new Error('boom');
67+
return n;
68+
});
69+
} catch (e) {
70+
error = e as Error;
71+
}
72+
expect(error).to.be.instanceOf(Error);
73+
expect(error?.message).to.equal('boom');
74+
});
75+
76+
it('should treat concurrency < 1 as 1', async () => {
77+
const results = await mapInBatches([1, 2, 3], 0, async (n) => n);
78+
expect(results).to.deep.equal([1, 2, 3]);
79+
});
80+
});
81+
82+
describe('runInBatches', () => {
83+
it('should invoke fn for every item with the correct absolute index', async () => {
84+
const seen: Array<{ item: string; index: number }> = [];
85+
await runInBatches(['a', 'b', 'c'], 2, async (item, index) => {
86+
seen.push({ item, index });
87+
});
88+
expect(seen.sort((a, b) => a.index - b.index)).to.deep.equal([
89+
{ item: 'a', index: 0 },
90+
{ item: 'b', index: 1 },
91+
{ item: 'c', index: 2 },
92+
]);
93+
});
94+
95+
it('should not abort the batch when one task rejects (fault-tolerant)', async () => {
96+
const completed: number[] = [];
97+
await runInBatches([1, 2, 3, 4], 2, async (n) => {
98+
if (n === 2) throw new Error('boom');
99+
completed.push(n);
100+
});
101+
expect(completed.sort((a, b) => a - b)).to.deep.equal([1, 3, 4]);
102+
});
103+
104+
it('should be a no-op for an empty array', async () => {
105+
let called = false;
106+
await runInBatches([], 5, async () => {
107+
called = true;
108+
});
109+
expect(called).to.equal(false);
110+
});
111+
});
112+
});

0 commit comments

Comments
 (0)