-
Notifications
You must be signed in to change notification settings - Fork 63
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
- Loading branch information
Showing
15 changed files
with
442 additions
and
73 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import VError from 'verror'; | ||
|
||
export class BucketSet { | ||
private readonly sortedBuckets: ReadonlyArray<number>; | ||
|
||
constructor( | ||
bucketTotal: number, | ||
bucketRanges: string | ReadonlyArray<string> | ||
) { | ||
if (bucketTotal < 1) { | ||
throw new VError('bucket_total must be a positive integer'); | ||
} | ||
|
||
const rangeStrings = ( | ||
typeof bucketRanges === 'string' ? bucketRanges.split(',') : bucketRanges | ||
).filter((s) => s.trim()); | ||
|
||
if (!rangeStrings?.length) { | ||
throw new VError('bucket_ranges cannot be empty'); | ||
} | ||
|
||
const buckets = new Set<number>(); | ||
|
||
for (const range of rangeStrings) { | ||
const [start, end] = this.parseRange(range); | ||
|
||
if (start < 1 || end > bucketTotal) { | ||
throw new VError( | ||
`Invalid bucket range ${range}: values must be between 1 and ${bucketTotal}` | ||
); | ||
} | ||
|
||
for (let i = start; i <= end; i++) { | ||
buckets.add(i); | ||
} | ||
} | ||
|
||
this.sortedBuckets = Array.from(buckets).sort((a, b) => a - b); | ||
} | ||
|
||
private parseRange(range: string): [number, number] { | ||
const parts = range.split('-').map((p) => p.trim()); | ||
|
||
if (parts.length > 2) { | ||
throw new VError( | ||
`Invalid range format: ${range}. Valid formats are: single number (e.g., '7') or number range (e.g., '3-5')` | ||
); | ||
} | ||
|
||
const start = parseInt(parts[0]); | ||
if (isNaN(start)) { | ||
throw new VError(`Invalid number in range: ${parts[0]}`); | ||
} | ||
|
||
// If it's a single number, both start and end are the same | ||
if (parts.length === 1) { | ||
return [start, start]; | ||
} | ||
|
||
const end = parseInt(parts[1]); | ||
if (isNaN(end)) { | ||
throw new VError(`Invalid number in range: ${parts[1]}`); | ||
} | ||
|
||
if (end < start) { | ||
throw new VError(`Invalid range ${range}: end cannot be less than start`); | ||
} | ||
|
||
return [start, end]; | ||
} | ||
|
||
next(bucketId: number): number { | ||
let low = 0; | ||
let high = this.sortedBuckets.length - 1; | ||
while (low <= high) { | ||
const mid = Math.floor((low + high) / 2); | ||
if (this.sortedBuckets[mid] <= bucketId) { | ||
low = mid + 1; | ||
} else { | ||
high = mid - 1; | ||
} | ||
} | ||
if (low < this.sortedBuckets.length) { | ||
return this.sortedBuckets[low]; | ||
} | ||
// Wrap around to first bucket if no larger bucket found | ||
return this.sortedBuckets[0]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
import {BucketSet} from '../../src/common/bucket-set'; | ||
|
||
describe('BucketSet', () => { | ||
describe('constructor', () => { | ||
test('should create bucket set with valid ranges', () => { | ||
expect(() => new BucketSet(5, ['1', '3-5'])).not.toThrow(); | ||
expect(() => new BucketSet(10, ['1-3', '5', '7-8'])).not.toThrow(); | ||
expect(() => new BucketSet(5, '1,3-5')).not.toThrow(); | ||
}); | ||
|
||
test('should throw for invalid bucket_total', () => { | ||
expect(() => new BucketSet(0, ['1'])).toThrow( | ||
'bucket_total must be a positive integer' | ||
); | ||
expect(() => new BucketSet(-1, ['1'])).toThrow( | ||
'bucket_total must be a positive integer' | ||
); | ||
}); | ||
|
||
test('should throw for empty bucket ranges', () => { | ||
expect(() => new BucketSet(5, [])).toThrow( | ||
'bucket_ranges cannot be empty' | ||
); | ||
expect(() => new BucketSet(5, '')).toThrow( | ||
'bucket_ranges cannot be empty' | ||
); | ||
}); | ||
|
||
test('should throw for invalid range format', () => { | ||
expect(() => new BucketSet(5, ['1-2-3'])).toThrow( | ||
'Invalid range format: 1-2-3' | ||
); | ||
expect(() => new BucketSet(5, ['a'])).toThrow( | ||
'Invalid number in range: a' | ||
); | ||
expect(() => new BucketSet(5, ['1-b'])).toThrow( | ||
'Invalid number in range: b' | ||
); | ||
}); | ||
|
||
test('should throw for out of bounds ranges', () => { | ||
expect(() => new BucketSet(5, ['0-1'])).toThrow( | ||
'Invalid bucket range 0-1: values must be between 1 and 5' | ||
); | ||
expect(() => new BucketSet(5, ['6'])).toThrow( | ||
'Invalid bucket range 6: values must be between 1 and 5' | ||
); | ||
expect(() => new BucketSet(5, ['1-6'])).toThrow( | ||
'Invalid bucket range 1-6: values must be between 1 and 5' | ||
); | ||
}); | ||
|
||
test('should throw for invalid range order', () => { | ||
expect(() => new BucketSet(5, ['3-1'])).toThrow( | ||
'Invalid range 3-1: end cannot be less than start' | ||
); | ||
}); | ||
}); | ||
|
||
describe('next', () => { | ||
test('should return next bucket in sequence', () => { | ||
const bucketSet = new BucketSet(10, ['1-3', '5', '7-8']); | ||
expect(bucketSet.next(1)).toBe(2); | ||
expect(bucketSet.next(2)).toBe(3); | ||
expect(bucketSet.next(3)).toBe(5); | ||
expect(bucketSet.next(5)).toBe(7); | ||
expect(bucketSet.next(7)).toBe(8); | ||
}); | ||
|
||
test('should wrap around to first bucket when reaching the end', () => { | ||
const bucketSet = new BucketSet(10, ['1-3', '5', '7-8']); | ||
expect(bucketSet.next(8)).toBe(1); | ||
}); | ||
|
||
test('should handle single bucket range', () => { | ||
const bucketSet = new BucketSet(5, ['3']); | ||
expect(bucketSet.next(1)).toBe(3); | ||
expect(bucketSet.next(3)).toBe(3); | ||
}); | ||
|
||
test('should handle non-consecutive ranges', () => { | ||
const bucketSet = new BucketSet(10, '2,4,6,8'); | ||
expect(bucketSet.next(2)).toBe(4); | ||
expect(bucketSet.next(4)).toBe(6); | ||
expect(bucketSet.next(6)).toBe(8); | ||
expect(bucketSet.next(8)).toBe(2); | ||
}); | ||
|
||
test('should handle bucket id not in set', () => { | ||
const bucketSet = new BucketSet(10, ['2-4', '7-8']); | ||
expect(bucketSet.next(1)).toBe(2); | ||
expect(bucketSet.next(5)).toBe(7); | ||
expect(bucketSet.next(9)).toBe(2); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.