mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user