Skip to content

Commit e6f95a0

Browse files
feat(abba): manually save user assignments
1 parent 004ac0e commit e6f95a0

4 files changed

Lines changed: 219 additions & 2 deletions

File tree

packages/abba/src/abba.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { bucketDao } from './dao/bucket.dao.js'
66
import { experimentDao } from './dao/experiment.dao.js'
77
import { userAssignmentDao } from './dao/userAssignment.dao.js'
88
import { mockBucket, mockExperiment, mockUserAssignment, mockUserId1 } from './test/mocks.js'
9-
import { AssignmentStatus } from './types.js'
9+
import { AssignmentStatus, SegmentationRuleOperator } from './types.js'
1010
import type { DecoratedUserAssignment } from './types.js'
1111

1212
const db = new InMemoryDB({ logger: undefined })
@@ -530,3 +530,120 @@ describe('softDeleteExperiment', () => {
530530
expect(updatedExperiment2.exclusions).toEqual([])
531531
})
532532
})
533+
534+
describe('saveManualUserAssignments', () => {
535+
test('should create new assignments and overwrite existing ones in a single batch', async () => {
536+
const experiment1 = await experimentsDAO.save(mockExperiment({ key: 'EXP_1' }))
537+
const control1 = await bucketsDAO.save(mockBucket(experiment1.id, 'control', 50))
538+
const test1 = await bucketsDAO.save(mockBucket(experiment1.id, 'test', 50))
539+
const experiment2 = await experimentsDAO.save(mockExperiment({ key: 'EXP_2' }))
540+
const test2 = await bucketsDAO.save(mockBucket(experiment2.id, 'test', 100))
541+
const existing = await userAssignmentsDAO.save({
542+
userId: 'userA',
543+
experimentId: experiment1.id,
544+
bucketId: control1.id,
545+
})
546+
547+
const results = await abba.saveManualUserAssignments([
548+
{ userId: 'userA', experimentKey: 'EXP_1', bucketKey: 'test' },
549+
{ userId: 'userB', experimentKey: 'EXP_1', bucketKey: 'control' },
550+
{ userId: 'userA', experimentKey: 'EXP_2', bucketKey: 'test' },
551+
])
552+
553+
expect(results).toHaveLength(3)
554+
expect(results[0]).toMatchObject({ id: existing.id, userId: 'userA', bucketId: test1.id })
555+
expect(results[1]).toMatchObject({ userId: 'userB', bucketId: control1.id })
556+
expect(results[2]).toMatchObject({ userId: 'userA', bucketId: test2.id })
557+
const all = await userAssignmentsDAO.getBy('userId', 'userA')
558+
expect(all).toHaveLength(2)
559+
})
560+
561+
test('should return an empty array for an empty input', async () => {
562+
const result = await abba.saveManualUserAssignments([])
563+
expect(result).toEqual([])
564+
})
565+
566+
test('should apply last-wins dedupe for duplicate (userId, experimentKey) pairs', async () => {
567+
const experiment = await experimentsDAO.save(mockExperiment())
568+
await bucketsDAO.save(mockBucket(experiment.id, 'control', 50))
569+
const testBucket = await bucketsDAO.save(mockBucket(experiment.id, 'test', 50))
570+
571+
const result = await abba.saveManualUserAssignments([
572+
{ userId: mockUserId1, experimentKey: experiment.key, bucketKey: 'control' },
573+
{ userId: mockUserId1, experimentKey: experiment.key, bucketKey: 'test' },
574+
])
575+
576+
expect(result).toHaveLength(1)
577+
expect(result[0]!.bucketId).toBe(testBucket.id)
578+
const persisted = await userAssignmentsDAO.getBy('userId', mockUserId1)
579+
expect(persisted).toHaveLength(1)
580+
expect(persisted[0]!.bucketId).toBe(testBucket.id)
581+
})
582+
583+
test('should throw when any row references an unknown experiment', async () => {
584+
const experiment = await experimentsDAO.save(mockExperiment())
585+
await bucketsDAO.save(mockBucket(experiment.id, 'test', 100))
586+
587+
await expect(
588+
abba.saveManualUserAssignments([
589+
{ userId: 'userA', experimentKey: experiment.key, bucketKey: 'test' },
590+
{ userId: 'userB', experimentKey: 'NOPE', bucketKey: 'test' },
591+
]),
592+
).rejects.toThrow('Experiment does not exist: NOPE')
593+
const written = await userAssignmentsDAO.getBy('userId', 'userA')
594+
expect(written).toEqual([])
595+
})
596+
597+
test('should throw when the experiment is soft-deleted', async () => {
598+
const experiment = await experimentsDAO.save(mockExperiment({ deleted: true }))
599+
await bucketsDAO.save(mockBucket(experiment.id, 'test', 100))
600+
601+
await expect(
602+
abba.saveManualUserAssignments([
603+
{ userId: mockUserId1, experimentKey: experiment.key, bucketKey: 'test' },
604+
]),
605+
).rejects.toThrow(`Experiment does not exist: ${experiment.key}`)
606+
})
607+
608+
test('should throw when a bucket key does not belong to its experiment', async () => {
609+
const experiment = await experimentsDAO.save(mockExperiment())
610+
await bucketsDAO.save(mockBucket(experiment.id, 'test', 100))
611+
612+
await expect(
613+
abba.saveManualUserAssignments([
614+
{ userId: mockUserId1, experimentKey: experiment.key, bucketKey: 'control' },
615+
]),
616+
).rejects.toThrow(`Bucket does not exist on experiment ${experiment.key}: control`)
617+
})
618+
619+
test.each([
620+
['Active', AssignmentStatus.Active],
621+
['Paused', AssignmentStatus.Paused],
622+
['Inactive', AssignmentStatus.Inactive],
623+
])('should succeed when the experiment status is %s', async (_name, status) => {
624+
const experiment = await experimentsDAO.save(mockExperiment({ status }))
625+
const bucket = await bucketsDAO.save(mockBucket(experiment.id, 'test', 100))
626+
627+
const [result] = await abba.saveManualUserAssignments([
628+
{ userId: mockUserId1, experimentKey: experiment.key, bucketKey: bucket.key },
629+
])
630+
631+
expect(result!.bucketId).toBe(bucket.id)
632+
})
633+
634+
test('should ignore segmentation rules and sampling', async () => {
635+
const experiment = await experimentsDAO.save(
636+
mockExperiment({
637+
sampling: 0,
638+
rules: [{ key: 'country', operator: SegmentationRuleOperator.EqualsText, value: 'SE' }],
639+
}),
640+
)
641+
const bucket = await bucketsDAO.save(mockBucket(experiment.id, 'test', 100))
642+
643+
const [result] = await abba.saveManualUserAssignments([
644+
{ userId: mockUserId1, experimentKey: experiment.key, bucketKey: bucket.key },
645+
])
646+
647+
expect(result!.bucketId).toBe(bucket.id)
648+
})
649+
})

packages/abba/src/abba.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { _shuffle } from '@naturalcycles/js-lib/array/array.util.js'
1+
import { _mapBy, _shuffle, _uniq } from '@naturalcycles/js-lib/array/array.util.js'
22
import { localTime } from '@naturalcycles/js-lib/datetime/localTime.js'
33
import { _Memo } from '@naturalcycles/js-lib/decorators/memo.decorator.js'
44
import { _assert } from '@naturalcycles/js-lib/error/assert.js'
@@ -19,6 +19,7 @@ import type {
1919
ExperimentAssignmentStatistics,
2020
ExperimentInput,
2121
ExperimentWithBuckets,
22+
ManualUserAssignmentInput,
2223
SegmentationData,
2324
UserAssignment,
2425
UserExperiment,
@@ -328,6 +329,84 @@ export class Abba {
328329
}
329330
}
330331

332+
/**
333+
* Manually assigns users to specific buckets, overwriting any existing assignments.
334+
* Bypasses sampling, segmentation, exclusions, and experiment status. Intended for
335+
* QA / internal testing where deterministic bucket placement is required.
336+
*
337+
* An empty input returns an empty array. If the input contains duplicate
338+
* (userId, experimentKey) pairs, the last occurrence wins.
339+
*
340+
* Throws AbbaErrorCode.ExperimentNotFound or AbbaErrorCode.BucketNotFound on the
341+
* first invalid row; no rows are written if validation fails.
342+
*
343+
* Cold method.
344+
*/
345+
async saveManualUserAssignments(
346+
inputs: readonly ManualUserAssignmentInput[],
347+
): Promise<DecoratedUserAssignment[]> {
348+
if (!inputs.length) return []
349+
350+
const dedupedInputs = Array.from(
351+
_mapBy(inputs, input => `${input.userId}|${input.experimentKey}`).values(),
352+
)
353+
354+
const experimentKeys = _uniq(dedupedInputs.map(input => input.experimentKey))
355+
const experiments = await pMap(experimentKeys, async experimentKey => {
356+
const experiment = await this.experimentDao.getByKey(experimentKey)
357+
_assert(experiment && !experiment.deleted, `Experiment does not exist: ${experimentKey}`, {
358+
code: AbbaErrorCode.ExperimentNotFound,
359+
})
360+
const buckets = await this.bucketDao.getByExperimentId(experiment.id)
361+
return { ...experiment, buckets }
362+
})
363+
const experimentByKey = _mapBy(experiments, experiment => experiment.key)
364+
365+
const resolvedInputs = dedupedInputs.map(input => {
366+
const experiment = experimentByKey.get(input.experimentKey)!
367+
const bucket = experiment.buckets.find(bucket => bucket.key === input.bucketKey)
368+
_assert(
369+
bucket,
370+
`Bucket does not exist on experiment ${input.experimentKey}: ${input.bucketKey}`,
371+
{ code: AbbaErrorCode.BucketNotFound },
372+
)
373+
return { input, experiment, bucket }
374+
})
375+
376+
const userIds = _uniq(dedupedInputs.map(input => input.userId))
377+
const experimentIds = experiments.map(experiment => experiment.id)
378+
const existingAssignments = await this.userAssignmentDao.getByUserIdsAndExperimentIds(
379+
userIds,
380+
experimentIds,
381+
)
382+
const existingByKey = _mapBy(
383+
existingAssignments,
384+
assignment => `${assignment.userId}|${assignment.experimentId}`,
385+
)
386+
387+
const toSave: Unsaved<UserAssignment>[] = resolvedInputs.map(
388+
({ input, experiment, bucket }) => ({
389+
...existingByKey.get(`${input.userId}|${experiment.id}`),
390+
userId: input.userId,
391+
experimentId: experiment.id,
392+
bucketId: bucket.id,
393+
}),
394+
)
395+
396+
const savedAssignments = await this.userAssignmentDao.saveBatch(toSave)
397+
398+
return savedAssignments.map((savedAssignment, i) => {
399+
const resolvedInput = resolvedInputs[i]!
400+
return {
401+
...savedAssignment,
402+
experimentKey: resolvedInput.experiment.key,
403+
experimentData: resolvedInput.experiment.data,
404+
bucketKey: resolvedInput.bucket.key,
405+
bucketData: resolvedInput.bucket.data,
406+
}
407+
})
408+
}
409+
331410
/**
332411
* Get all existing user assignments.
333412
* Hot method.

packages/abba/src/dao/userAssignment.dao.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ export class UserAssignmentDao extends CommonDao<UserAssignment> {
2121
return await this.runQuery(query)
2222
}
2323

24+
/**
25+
* Returns every UserAssignment whose `userId` is in `userIds` AND whose `experimentId`
26+
* is in `experimentIds`. This is the cross-product, not paired lookup: callers must
27+
* filter the result by the specific (userId, experimentId) pairs they care about.
28+
*/
29+
async getByUserIdsAndExperimentIds(
30+
userIds: string[],
31+
experimentIds: string[],
32+
): Promise<UserAssignment[]> {
33+
if (!userIds.length || !experimentIds.length) return []
34+
const query = this.query().filterIn('userId', userIds).filterIn('experimentId', experimentIds)
35+
return await this.runQuery(query)
36+
}
37+
2438
async deleteByExperimentId(experimentId: string): Promise<void> {
2539
await this.query().filterEq('experimentId', experimentId).deleteByQuery()
2640
}

packages/abba/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const AbbaErrorCode = {
77
SegmentationDataRequired: 'abba/segmentationDataRequired',
88
InvalidBucketRatio: 'abba/invalidBucketRatio',
99
BucketDeterminationFailed: 'abba/bucketDeterminationFailed',
10+
BucketNotFound: 'abba/bucketNotFound',
1011
} as const
1112

1213
export interface AbbaConfig {
@@ -92,6 +93,12 @@ export type UserAssignment = BaseDBEntity & {
9293
bucketId: string | null
9394
}
9495

96+
export interface ManualUserAssignmentInput {
97+
userId: string
98+
experimentKey: string
99+
bucketKey: string
100+
}
101+
95102
export type DecoratedUserAssignment = UserAssignment & {
96103
experimentKey: Experiment['key']
97104
experimentData: Experiment['data']

0 commit comments

Comments
 (0)