From 74b7fdf7542b773fa4b5dea6ee193165d9627831 Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Tue, 14 Nov 2023 18:14:08 -0600 Subject: [PATCH] Randomize suggestions (#1844) * randomize suggestions * fix snap * cursor fix * pr feedback --- .../src/api/app/bsky/actor/getSuggestions.ts | 66 +++++++++++++------ packages/bsky/tests/views/suggestions.test.ts | 22 +++++-- .../proxied/__snapshots__/views.test.ts.snap | 2 +- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index 18ab99debe2..f68ba68eb66 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -42,12 +42,12 @@ const skeleton = async ( ctx: Context, ): Promise => { const { db } = ctx - const { limit, cursor, viewer } = params + const { viewer } = params + const alreadyIncluded = parseCursor(params.cursor) const { ref } = db.db.dynamic - let suggestionsQb = db.db + const suggestions = await db.db .selectFrom('suggested_follow') .innerJoin('actor', 'actor.did', 'suggested_follow.did') - .innerJoin('profile_agg', 'profile_agg.did', 'actor.did') .where(notSoftDeletedClause(ref('actor'))) .where('suggested_follow.did', '!=', viewer ?? '') .whereNotExists((qb) => @@ -57,27 +57,30 @@ const skeleton = async ( .where('creator', '=', viewer ?? '') .whereRef('subjectDid', '=', ref('actor.did')), ) + .if(alreadyIncluded.length > 0, (qb) => + qb.where('suggested_follow.order', 'not in', alreadyIncluded), + ) .selectAll() - .select('profile_agg.postsCount as postsCount') - .limit(limit) .orderBy('suggested_follow.order', 'asc') + .execute() - if (cursor) { - const cursorRow = await db.db - .selectFrom('suggested_follow') - .where('did', '=', cursor) - .selectAll() - .executeTakeFirst() - if (cursorRow) { - suggestionsQb = suggestionsQb.where( - 'suggested_follow.order', - '>', - cursorRow.order, - ) - } - } - const suggestions = await suggestionsQb.execute() - return { params, suggestions, cursor: suggestions.at(-1)?.did } + // always include first two + const firstTwo = suggestions.filter( + (row) => row.order === 1 || row.order === 2, + ) + const rest = suggestions.filter((row) => row.order !== 1 && row.order !== 2) + const limited = firstTwo.concat(shuffle(rest)).slice(0, params.limit) + + // if the result set ends up getting larger, consider using a seed included in the cursor for for the randomized shuffle + const cursor = + limited.length > 0 + ? limited + .map((row) => row.order.toString()) + .concat(alreadyIncluded.map((id) => id.toString())) + .join(':') + : undefined + + return { params, suggestions: limited, cursor } } const hydration = async (state: SkeletonState, ctx: Context) => { @@ -110,6 +113,27 @@ const presentation = (state: HydrationState) => { return { actors: suggestedActors, cursor } } +const parseCursor = (cursor?: string): number[] => { + if (!cursor) { + return [] + } + try { + return cursor + .split(':') + .map((id) => parseInt(id, 10)) + .filter((id) => !isNaN(id)) + } catch { + return [] + } +} + +const shuffle = (arr: T[]): T[] => { + return arr + .map((value) => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value) +} + type Context = { db: Database actorService: ActorService diff --git a/packages/bsky/tests/views/suggestions.test.ts b/packages/bsky/tests/views/suggestions.test.ts index 2dcadf9e6ad..4253f528b13 100644 --- a/packages/bsky/tests/views/suggestions.test.ts +++ b/packages/bsky/tests/views/suggestions.test.ts @@ -19,10 +19,12 @@ describe('pds user search views', () => { await network.bsky.processAll() const suggestions = [ - { did: sc.dids.bob, order: 1 }, - { did: sc.dids.carol, order: 2 }, - { did: sc.dids.dan, order: 3 }, + { did: sc.dids.alice, order: 1 }, + { did: sc.dids.bob, order: 2 }, + { did: sc.dids.carol, order: 3 }, + { did: sc.dids.dan, order: 4 }, ] + await network.bsky.ctx.db .getPrimary() .db.insertInto('suggested_follow') @@ -63,16 +65,22 @@ describe('pds user search views', () => { { limit: 1 }, { headers: await network.serviceHeaders(sc.dids.carol) }, ) + expect(result1.data.actors.length).toBe(1) + expect(result1.data.actors[0].handle).toEqual('bob.test') + const result2 = await agent.api.app.bsky.actor.getSuggestions( { limit: 1, cursor: result1.data.cursor }, { headers: await network.serviceHeaders(sc.dids.carol) }, ) - - expect(result1.data.actors.length).toBe(1) - expect(result1.data.actors[0].handle).toEqual('bob.test') - expect(result2.data.actors.length).toBe(1) expect(result2.data.actors[0].handle).toEqual('dan.test') + + const result3 = await agent.api.app.bsky.actor.getSuggestions( + { limit: 1, cursor: result2.data.cursor }, + { headers: await network.serviceHeaders(sc.dids.carol) }, + ) + expect(result3.data.actors.length).toBe(0) + expect(result3.data.cursor).toBeUndefined() }) it('fetches suggestions unauthed', async () => { diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index fcf1063954c..0dbe9b5498d 100644 --- a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap @@ -114,7 +114,7 @@ Object { }, }, ], - "cursor": "user(2)", + "cursor": "1:3", } `;