Skip to content

Commit 9084370

Browse files
committed
fix: release consumed result rows and normalize buffered limits
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
1 parent 0a0b576 commit 9084370

2 files changed

Lines changed: 89 additions & 8 deletions

File tree

‎lib/result/ResultSlicer.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,23 @@ export default class ResultSlicer<T> implements IResultsProvider<Array<T>> {
4848
return this.remainingResults.length - this.remainingOffset;
4949
}
5050

51+
private compactRemaining(): void {
52+
if (this.remainingCount === 0) {
53+
this.remainingResults = [];
54+
this.remainingOffset = 0;
55+
} else if (this.remainingOffset > this.remainingResults.length / 2) {
56+
// Release consumed rows without repeatedly copying a large tail.
57+
this.remainingResults = this.remainingResults.slice(this.remainingOffset);
58+
this.remainingOffset = 0;
59+
}
60+
}
61+
5162
private takeRemaining(maxCount: number): Array<T> {
5263
const count = Number.isNaN(maxCount) ? this.remainingCount : Math.max(0, Math.trunc(maxCount));
5364
const end = Math.min(this.remainingResults.length, this.remainingOffset + count);
5465
const result = this.remainingResults.slice(this.remainingOffset, end);
5566
this.remainingOffset = end;
56-
if (this.remainingCount === 0) {
57-
this.remainingResults = [];
58-
this.remainingOffset = 0;
59-
}
67+
this.compactRemaining();
6068
return result;
6169
}
6270

@@ -78,12 +86,14 @@ export default class ResultSlicer<T> implements IResultsProvider<Array<T>> {
7886
return this.source.fetchNext(options);
7987
}
8088

89+
const limit = Number(options.limit);
90+
const sourceOptions = { ...options, limit };
8191
const result: Array<Array<T>> = [];
8292
let resultsCount = 0;
8393

8494
// First, use remaining items from the previous fetch
8595
if (this.remainingCount > 0) {
86-
const remaining = this.takeRemaining(options.limit);
96+
const remaining = this.takeRemaining(limit);
8797
if (this.remainingCount > 0) {
8898
return remaining;
8999
}
@@ -92,21 +102,22 @@ export default class ResultSlicer<T> implements IResultsProvider<Array<T>> {
92102
}
93103

94104
// Fetch items from source results provider until we reach a requested count
95-
while (resultsCount < options.limit) {
105+
while (resultsCount < limit) {
96106
// eslint-disable-next-line no-await-in-loop
97107
const hasMore = await this.source.hasMore();
98108
if (!hasMore) {
99109
break;
100110
}
101111

102112
// eslint-disable-next-line no-await-in-loop
103-
const chunk = await this.source.fetchNext(options);
104-
const neededCount = options.limit - resultsCount;
113+
const chunk = await this.source.fetchNext(sourceOptions);
114+
const neededCount = limit - resultsCount;
105115
if (chunk.length > neededCount) {
106116
const taken = chunk.slice(0, neededCount);
107117
result.push(taken);
108118
this.remainingResults = chunk;
109119
this.remainingOffset = taken.length;
120+
this.compactRemaining();
110121
resultsCount += neededCount;
111122
} else {
112123
result.push(chunk);

‎tests/unit/result/ResultSlicer.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ import ResultsProviderStub from '../.stubs/ResultsProviderStub';
66
import ClientContextStub from '../.stubs/ClientContextStub';
77

88
describe('ResultSlicer', () => {
9+
it('should retain at most twice the unread rows while draining a batch', async () => {
10+
const rows = Array.from({ length: 256 }, (_, value) => ({ value }));
11+
Object.freeze(rows);
12+
const provider = new ResultsProviderStub([rows], []);
13+
const slicer = new ResultSlicer(new ClientContextStub(), provider);
14+
let consumed = 0;
15+
16+
for (const limit of [150, 25, 25, 25, 25, 25]) {
17+
// eslint-disable-next-line no-await-in-loop
18+
const chunk = await slicer.fetchNext({ limit });
19+
expect(chunk).to.deep.eq(rows.slice(consumed, consumed + limit));
20+
consumed += chunk.length;
21+
expect(slicer['remainingResults'].length).to.be.at.most(2 * (rows.length - consumed));
22+
}
23+
24+
expect(consumed).to.eq(rows.length);
25+
expect(await slicer.hasMore()).to.be.false;
26+
});
27+
928
it('should carry leftovers across several calls without mutating the source batch', async () => {
1029
const rows = Array.from({ length: 250 }, (_, index) => index);
1130
Object.freeze(rows);
@@ -135,6 +154,57 @@ describe('ResultSlicer', () => {
135154
expect(await slicer.fetchNext({ limit: 3 })).to.deep.eq([1, 2, 3]);
136155
});
137156

157+
it('should preserve leftovers for a non-numeric limit from a JavaScript caller', async () => {
158+
const provider = new ResultsProviderStub(
159+
[
160+
[1, 2, 3, 4, 5, 6],
161+
[7, 8, 9],
162+
],
163+
[],
164+
);
165+
const fetchNext = sinon.spy(provider, 'fetchNext');
166+
const slicer = new ResultSlicer(new ClientContextStub(), provider);
167+
const options = { limit: 'abc' as unknown as number };
168+
169+
expect(await slicer.fetchNext(options)).to.deep.eq([]);
170+
expect(fetchNext.called).to.be.false;
171+
expect(await slicer.fetchNext({ limit: 3 })).to.deep.eq([1, 2, 3]);
172+
expect(await slicer.fetchNext(options)).to.deep.eq([4, 5, 6]);
173+
expect(fetchNext.callCount).to.eq(1);
174+
expect(await slicer.hasMore()).to.be.true;
175+
expect(await slicer.fetchNext({ limit: 3 })).to.deep.eq([7, 8, 9]);
176+
expect(await slicer.hasMore()).to.be.false;
177+
});
178+
179+
it('should normalize numeric string limits before passing them to the source', async () => {
180+
const provider = new ResultsProviderStub(
181+
[
182+
[1, 2],
183+
[3, 4, 5],
184+
],
185+
[],
186+
);
187+
const fetchNext = sinon.spy(provider, 'fetchNext');
188+
const slicer = new ResultSlicer(new ClientContextStub(), provider);
189+
190+
expect(await slicer.fetchNext({ limit: '3.5' as unknown as number })).to.deep.eq([1, 2, 3]);
191+
expect(fetchNext.firstCall.args).to.deep.eq([{ limit: 3.5 }]);
192+
expect(await slicer.fetchNext({ limit: '1.5' as unknown as number })).to.deep.eq([4]);
193+
expect(await slicer.fetchNext({ limit: 3 })).to.deep.eq([5]);
194+
expect(await slicer.hasMore()).to.be.false;
195+
});
196+
197+
it('should proxy unbuffered fetches without coercing the limit', async () => {
198+
const rows = [1, 2, 3];
199+
const provider = new ResultsProviderStub([rows], []);
200+
const fetchNext = sinon.spy(provider, 'fetchNext');
201+
const slicer = new ResultSlicer(new ClientContextStub(), provider);
202+
const options = { limit: Symbol('ignored') as unknown as number, disableBuffering: true };
203+
204+
expect(await slicer.fetchNext(options)).to.eq(rows);
205+
expect(fetchNext.lastCall.args).to.deep.eq([options]);
206+
});
207+
138208
for (const limit of [3, 3.5]) {
139209
it(`should truncate fractional counts after fetching with limit ${limit}`, async () => {
140210
const provider = new ResultsProviderStub([[1, 2, 3, 4, 5, 6]], []);

0 commit comments

Comments
 (0)