From 3c4de9c59ebd04c9c829172e89710201ac739169 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Tue, 15 Sep 2026 12:21:54 -0700 Subject: [PATCH] Implement bounded thread query for /posts/:txid/thread Scope the thread endpoint to per-thread reads: - Count likes for only the thread's txids via countLikesForTxids instead of the whole-database buildLikeCountMap scan. - Prefix-scan postChildren by parent txid in loadChildTxids instead of walking the entire store for every node. - Add unit tests pinning bounded postChildren reads and thread-scoped likes. By coder. --- psf-memo-db/src/use-cases/get-post-thread.js | 27 ++++- .../unit/use-cases/get-post-thread.unit.js | 111 +++++++++++++----- 2 files changed, 105 insertions(+), 33 deletions(-) diff --git a/psf-memo-db/src/use-cases/get-post-thread.js b/psf-memo-db/src/use-cases/get-post-thread.js index 5abd6fd..71d35ba 100644 --- a/psf-memo-db/src/use-cases/get-post-thread.js +++ b/psf-memo-db/src/use-cases/get-post-thread.js @@ -14,6 +14,7 @@ class GetPostThread { this.execute = this.execute.bind(this) this.buildThreadNode = this.buildThreadNode.bind(this) + this.collectThreadTxids = this.collectThreadTxids.bind(this) this.attachLikeCounts = this.attachLikeCounts.bind(this) this.fetchPostOrNull = this.fetchPostOrNull.bind(this) this.loadChildTxids = this.loadChildTxids.bind(this) @@ -27,10 +28,7 @@ class GetPostThread { throw err } - const [likeCounts, rootPost] = await Promise.all([ - this.adapters.postQuery.buildLikeCountMap(), - this.buildThreadNode(txid) - ]) + const rootPost = await this.buildThreadNode(txid) if (!rootPost) { const err = new Error('Post not found.') @@ -38,6 +36,12 @@ class GetPostThread { throw err } + // Build the thread first, then count likes for only its txids. This keeps + // the endpoint's work proportional to the thread, not the whole database. + const threadTxids = [] + this.collectThreadTxids(rootPost, threadTxids) + const likeCounts = await this.adapters.postQuery.countLikesForTxids(threadTxids) + this.attachLikeCounts(rootPost, likeCounts) return { @@ -45,6 +49,15 @@ class GetPostThread { } } + collectThreadTxids (node, txids) { + txids.push(node.txid) + if (Array.isArray(node.replies)) { + for (const reply of node.replies) { + this.collectThreadTxids(reply, txids) + } + } + } + attachLikeCounts (node, likeCounts) { node.likeCount = likeCounts.get(node.txid) ?? 0 if (Array.isArray(node.replies)) { @@ -66,12 +79,16 @@ class GetPostThread { } } + // Prefix-scan the postChildren index for this parent instead of walking the + // whole store, so thread loading stays proportional to the thread size. async loadChildTxids (txid) { const childTxids = [] + const prefix = `${txid}:` + const end = ':\uffff' for await ( const [, child] - of this.adapters.postQuery.postChildrenDb.iterator() + of this.adapters.postQuery.postChildrenDb.iterator({ gte: prefix, lte: `${txid}${end}` }) ) { if (child?.parentTxid !== txid) continue if (!child?.childTxid) continue diff --git a/psf-memo-db/test/unit/use-cases/get-post-thread.unit.js b/psf-memo-db/test/unit/use-cases/get-post-thread.unit.js index ad9d33c..e7b87e1 100644 --- a/psf-memo-db/test/unit/use-cases/get-post-thread.unit.js +++ b/psf-memo-db/test/unit/use-cases/get-post-thread.unit.js @@ -6,6 +6,7 @@ describe('#GetPostThread', () => { let uut let sandbox let postQuery + let postsGetCounter const mockPosts = { 'root-1': { addr: 'addr-a', text: 'root', seen: 100, blockHeight: 600100 }, @@ -13,8 +14,35 @@ describe('#GetPostThread', () => { 'reply-2': { addr: 'addr-b', text: 'reply b', seen: 80, blockHeight: 600080 } } + // An in-memory postChildren index that honors the gte/lte prefix bounds the + // production code sends, so a full-store scan is observably different from a + // bounded prefix scan. + function createPostChildrenDb (entries) { + return { + iterator: sandbox.stub().callsFake((options = {}) => { + const { gte, lte } = options + return (async function * () { + for (const [key, value] of entries) { + if (gte !== undefined && key < gte) continue + if (lte !== undefined && key > lte) continue + yield [key, value] + } + })() + }) + } + } + + function rootThreadChildren () { + return createPostChildrenDb([ + ['root-1:reply-1', { parentTxid: 'root-1', childTxid: 'reply-1' }], + ['root-1:reply-2', { parentTxid: 'root-1', childTxid: 'reply-2' }], + ['other-root:reply-x', { parentTxid: 'other-root', childTxid: 'reply-x' }] + ]) + } + beforeEach(() => { sandbox = sinon.createSandbox() + postsGetCounter = { calls: 0 } Object.assign(mockPosts, { 'root-1': { addr: 'addr-a', text: 'root', seen: 100, blockHeight: 600100 }, 'reply-1': { addr: 'addr-a', text: 'reply', seen: 90, blockHeight: 600090 }, @@ -23,22 +51,20 @@ describe('#GetPostThread', () => { postQuery = { postsDb: { get: sandbox.stub().callsFake(async (txid) => { + postsGetCounter.calls++ if (mockPosts[txid]) return mockPosts[txid] const err = new Error('not found') err.notFound = true throw err }) }, - postChildrenDb: { - iterator: sandbox.stub().callsFake(function * () { - yield ['root-1:reply-1', { parentTxid: 'root-1', childTxid: 'reply-1' }] - yield ['root-1:reply-2', { parentTxid: 'root-1', childTxid: 'reply-2' }] - }) - }, - buildLikeCountMap: sandbox.stub().resolves(new Map([ + postChildrenDb: rootThreadChildren(), + countLikesForTxids: sandbox.stub().resolves(new Map([ ['root-1', 4], ['reply-1', 2] - ])) + ])), + // The thread endpoint must not fall back to a whole-database like scan. + buildLikeCountMap: sandbox.stub().rejects(new Error('buildLikeCountMap must not be called')) } uut = new GetPostThread({ adapters: { postQuery } }) }) @@ -88,6 +114,55 @@ describe('#GetPostThread', () => { assert.equal(reply2.likeCount, 0) }) + it('should count likes only for the txids in the thread', async () => { + await uut.execute({ txid: 'root-1' }) + + assert.isTrue(postQuery.countLikesForTxids.calledOnce) + const txids = postQuery.countLikesForTxids.firstCall.args[0] + assert.deepEqual([...txids].sort(), ['reply-1', 'reply-2', 'root-1']) + }) + + it('should not build a whole-database like count map', async () => { + await uut.execute({ txid: 'root-1' }) + + assert.isTrue(postQuery.buildLikeCountMap.notCalled) + }) + + it('should only load posts that belong to the thread', async () => { + await uut.execute({ txid: 'root-1' }) + + // root-1 plus the two replies; no per-like post lookups. + assert.equal(postsGetCounter.calls, 3) + }) + + it('should prefix-scan postChildren for each thread node', async () => { + await uut.execute({ txid: 'root-1' }) + + const bounds = postQuery.postChildrenDb.iterator.args.map(([options]) => options) + assert.deepInclude(bounds, { gte: 'root-1:', lte: 'root-1:\uffff' }) + assert.deepInclude(bounds, { gte: 'reply-1:', lte: 'reply-1:\uffff' }) + assert.deepInclude(bounds, { gte: 'reply-2:', lte: 'reply-2:\uffff' }) + }) + + it('should never scan the whole postChildren store', async () => { + await uut.execute({ txid: 'root-1' }) + + for (const [options] of postQuery.postChildrenDb.iterator.args) { + assert.isString(options?.gte) + assert.isString(options?.lte) + } + }) + + it('should ignore postChildren entries outside the requested parent prefix', async () => { + const result = await uut.execute({ txid: 'root-1' }) + + assert.equal(result.post.replyCount, 2) + assert.deepEqual( + result.post.replies.map((r) => r.txid).sort(), + ['reply-1', 'reply-2'] + ) + }) + it('should sort replies by blockHeight ascending then seen ascending', async () => { const result = await uut.execute({ txid: 'root-1' }) @@ -97,10 +172,6 @@ describe('#GetPostThread', () => { }) it('should tie-break replies with equal blockHeight by seen ascending', async () => { - postQuery.postChildrenDb.iterator = sandbox.stub().callsFake(function * () { - yield ['root-1:reply-1', { parentTxid: 'root-1', childTxid: 'reply-1' }] - yield ['root-1:reply-2', { parentTxid: 'root-1', childTxid: 'reply-2' }] - }) mockPosts['reply-1'] = { addr: 'addr-a', text: 'reply', seen: 90, blockHeight: 600100 } mockPosts['reply-2'] = { addr: 'addr-b', text: 'reply b', seen: 80, blockHeight: 600100 } @@ -125,10 +196,6 @@ describe('#GetPostThread', () => { }) it('should default missing seen to 0 when comparing replies', async () => { - postQuery.postChildrenDb.iterator = sandbox.stub().callsFake(function * () { - yield ['root-1:reply-1', { parentTxid: 'root-1', childTxid: 'reply-1' }] - yield ['root-1:reply-2', { parentTxid: 'root-1', childTxid: 'reply-2' }] - }) mockPosts['reply-1'] = { addr: 'addr-a', text: 'reply', blockHeight: 600100 } mockPosts['reply-2'] = { addr: 'addr-b', text: 'reply b', seen: 0, blockHeight: 600100 } @@ -141,10 +208,6 @@ describe('#GetPostThread', () => { }) it('should default missing blockHeight to 0 when comparing replies', async () => { - postQuery.postChildrenDb.iterator = sandbox.stub().callsFake(function * () { - yield ['root-1:reply-1', { parentTxid: 'root-1', childTxid: 'reply-1' }] - yield ['root-1:reply-2', { parentTxid: 'root-1', childTxid: 'reply-2' }] - }) mockPosts['reply-1'] = { addr: 'addr-a', text: 'reply', seen: 100 } mockPosts['reply-2'] = { addr: 'addr-b', text: 'reply b', seen: 100, blockHeight: 0 } @@ -157,10 +220,6 @@ describe('#GetPostThread', () => { }) it('should treat a missing blockHeight on the second operand as 0', async () => { - postQuery.postChildrenDb.iterator = sandbox.stub().callsFake(function * () { - yield ['root-1:reply-1', { parentTxid: 'root-1', childTxid: 'reply-1' }] - yield ['root-1:reply-2', { parentTxid: 'root-1', childTxid: 'reply-2' }] - }) mockPosts['reply-1'] = { addr: 'addr-a', text: 'reply', seen: 100, blockHeight: 1 } mockPosts['reply-2'] = { addr: 'addr-b', text: 'reply b', seen: 100 } @@ -172,10 +231,6 @@ describe('#GetPostThread', () => { }) it('should treat a missing seen on the second operand as 0', async () => { - postQuery.postChildrenDb.iterator = sandbox.stub().callsFake(function * () { - yield ['root-1:reply-1', { parentTxid: 'root-1', childTxid: 'reply-1' }] - yield ['root-1:reply-2', { parentTxid: 'root-1', childTxid: 'reply-2' }] - }) mockPosts['reply-1'] = { addr: 'addr-a', text: 'reply', seen: 1, blockHeight: 600100 } mockPosts['reply-2'] = { addr: 'addr-b', text: 'reply b', blockHeight: 600100 }