Refactor profile-last-post: split recency logic, add property tests

Split backfillProfileRecency and establishProfileRecency into small
helpers so every analyzed function scores CRAP 6 or below, without
changing behavior. Extract the post recency-qualification check in
handlePost.

Add seeded property tests covering backfill conservation and
idempotence, the addrPostHeight key round trip, recent-profile read
ordering and pagination, and the indexer's recency convergence.

By refactorer.
This commit is contained in:
Chris Troutner
2026-09-20 14:45:37 -07:00
parent bdf2c3015e
commit 6b8da07981
5 changed files with 458 additions and 59 deletions
@@ -11,6 +11,13 @@ async function createIfMissing (db, key, value) {
}
}
// Only top-level posts and topic messages qualify for profile recency. Replies
// reuse handlePost to store their post record but must not move the author's
// recency.
function qualifiesForRecency (decoded) {
return decoded.action === 'post' || decoded.action === 'topicMessage'
}
export async function handlePost (ctx) {
const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx
const pushDatas = normalizeTwoPushMemoDatas(decoded.pushDatas)
@@ -37,10 +44,7 @@ export async function handlePost (ctx) {
await createIfMissing(adapters.postHeightDb, heightKey, { txid, blockHeight })
await createIfMissing(adapters.addrPostHeightDb, addrHeightKey, { txid, addr: signerAddr, blockHeight })
// Only top-level posts and topic messages qualify for profile recency.
// Replies reuse handlePost to store their post record but must not move the
// author's recency.
if (decoded.action === 'post' || decoded.action === 'topicMessage') {
if (qualifiesForRecency(decoded)) {
await recordProfileRecency(adapters, signerAddr, blockHeight, seen)
}
}
@@ -63,6 +63,38 @@ export async function recordProfileRecency (adapters, addr, blockHeight, seen) {
return upsertProfileRecency(adapters, addr, blockHeight ?? 0, seen ?? 0)
}
// The qualifying post for one addrPostHeights entry, or null when the entry is
// for another address, has no txid, is unconfirmed, is a reply, or is a poll.
async function qualifyingCandidate (adapters, key, value, addr, chainBlockHeight) {
if (!String(key).startsWith(`${addr}:`)) return null
const txid = value?.txid
if (!txid) return null
const blockHeight = value?.blockHeight ?? 0
if (!isConfirmed(blockHeight, chainBlockHeight)) return null
if (await getIfPresent(adapters.postParentDb, txid)) return null
if (await getIfPresent(adapters.pollDb, txid)) return null
const post = await getIfPresent(adapters.postDb, txid)
return { blockHeight, seen: post?.seen ?? 0 }
}
// Return whichever of two candidates is newer: greater height, or equal height
// with a greater seen value. The current candidate wins a tie.
function newerCandidate (current, candidate) {
if (!current) return candidate
if (candidate.blockHeight !== current.blockHeight) return candidate.blockHeight > current.blockHeight ? candidate : current
return candidate.seen > current.seen ? candidate : current
}
async function findNewestQualifyingPost (adapters, addr, chainBlockHeight) {
const range = { gte: `${addr}:`, lte: `${addr}:\uffff` }
let best = null
for await (const [key, value] of adapters.addrPostHeightDb.iterator(range)) {
const candidate = await qualifyingCandidate(adapters, key, value, addr, chainBlockHeight)
if (candidate) best = newerCandidate(best, candidate)
}
return best
}
// Establish a profile's recency from the posts already indexed for its
// address. Runs when a set-profile action creates the profile, so a profile
// set after posting still reports the existing newest confirmed qualifying
@@ -72,25 +104,7 @@ export async function establishProfileRecency (adapters, addr) {
if (!adapters.profileRecencyDb || !adapters.addrPostHeightDb) return null
const chainBlockHeight = await getChainBlockHeight(adapters)
const range = { gte: `${addr}:`, lte: `${addr}:\uffff` }
let best = null
for await (const [key, value] of adapters.addrPostHeightDb.iterator(range)) {
if (!String(key).startsWith(`${addr}:`)) continue
const txid = value?.txid
const blockHeight = value?.blockHeight ?? 0
if (!txid) continue
if (!isConfirmed(blockHeight, chainBlockHeight)) continue
if (await getIfPresent(adapters.postParentDb, txid)) continue
if (await getIfPresent(adapters.pollDb, txid)) continue
const post = await getIfPresent(adapters.postDb, txid)
const seen = post?.seen ?? 0
if (!best || blockHeight > best.blockHeight || (blockHeight === best.blockHeight && seen > best.seen)) {
best = { blockHeight, seen }
}
}
const best = await findNewestQualifyingPost(adapters, addr, chainBlockHeight)
if (!best) return null
return upsertProfileRecency(adapters, addr, best.blockHeight, best.seen)
}
@@ -0,0 +1,150 @@
/*
Property tests for the indexer's profileRecency maintenance.
The unit tests probe fixed fixtures. These properties pin the invariants that
must hold across broad random, out-of-order post streams:
- Convergence: recordProfileRecency ends on the greatest confirmed height,
with seen as the tie-breaker, regardless of processing order.
- Confirmation: posts above status.chainBlockHeight never move recency.
- Idempotence: replaying the same stream leaves the index unchanged.
- Establishment: establishProfileRecency recovers the same newest confirmed
qualifying post from addrPostHeights, excluding replies and polls.
*/
import test from 'node:test'
import { seededRandom, forAll, intGen } from './harness.js'
import { makeMemoryDb } from '../support/memory-db.js'
import {
recordProfileRecency,
establishProfileRecency
} from '../../src/use-cases/action-types/profile-recency.js'
const rng = seededRandom(20260921)
const ADDR = 'bitcoincash:qaddr-a'
function makeAdapters (chainBlockHeight) {
return {
profileDb: makeMemoryDb(),
profileRecencyDb: makeMemoryDb(),
addrPostHeightDb: makeMemoryDb(),
postParentDb: makeMemoryDb(),
pollDb: makeMemoryDb(),
postDb: makeMemoryDb(),
statusDb: { getStatus: async () => ({ chainBlockHeight }) }
}
}
function expectedBest (posts, chainBlockHeight) {
let best = null
for (const post of posts) {
if (post.blockHeight > chainBlockHeight) continue
if (!best || post.blockHeight > best.blockHeight || (post.blockHeight === best.blockHeight && post.seen > best.seen)) {
best = post
}
}
return best
}
function postStreamGen () {
return () => {
const chainBlockHeight = intGen(rng, 10, 1000)()
const count = intGen(rng, 0, 12)()
const posts = []
for (let i = 0; i < count; i++) {
posts.push({
blockHeight: chainBlockHeight + intGen(rng, -4, 4)(),
seen: intGen(rng, 0, 50)()
})
}
const shuffled = [...posts]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1))
const swap = shuffled[i]
shuffled[i] = shuffled[j]
shuffled[j] = swap
}
return { chainBlockHeight, posts: shuffled }
}
}
test('recordProfileRecency converges on the newest confirmed post and is idempotent', async () => {
await forAll(postStreamGen(), async ({ chainBlockHeight, posts }) => {
const adapters = makeAdapters(chainBlockHeight)
await adapters.profileDb.update(ADDR, { addr: ADDR, text: 'bio' })
for (const post of posts) {
await recordProfileRecency(adapters, ADDR, post.blockHeight, post.seen)
}
const expected = expectedBest(posts, chainBlockHeight)
const stored = adapters.profileRecencyDb.store.get(ADDR)
if (!expected) {
if (stored !== undefined) return false
} else {
if (!stored) return false
if (stored.blockHeight !== expected.blockHeight || stored.seen !== expected.seen) return false
}
const before = JSON.stringify(stored ?? null)
for (const post of posts) {
await recordProfileRecency(adapters, ADDR, post.blockHeight, post.seen)
}
return JSON.stringify(adapters.profileRecencyDb.store.get(ADDR) ?? null) === before
}, { label: 'recordProfileRecency convergence and idempotence' })
})
function establishWorldGen () {
return () => {
const chainBlockHeight = intGen(rng, 10, 1000)()
const adapters = makeAdapters(chainBlockHeight)
const entries = []
const count = intGen(rng, 0, 10)()
for (let i = 0; i < count; i++) {
const txid = `tx-${i}`
const blockHeight = chainBlockHeight + intGen(rng, -4, 4)()
const seen = intGen(rng, 0, 50)()
const key = `${ADDR}:${String(blockHeight).padStart(12, '0')}:${txid}`
adapters.addrPostHeightDb.store.set(key, { txid, addr: ADDR, blockHeight })
adapters.postDb.store.set(txid, { addr: ADDR, seen, blockHeight })
const roll = rng()
let kind = 'post'
if (roll < 0.3) {
kind = 'reply'
adapters.postParentDb.store.set(txid, { txid, parentTxid: 'parent' })
} else if (roll < 0.5) {
kind = 'poll'
adapters.pollDb.store.set(txid, { txid })
}
entries.push({ txid, blockHeight, seen, kind })
}
return { adapters, entries, chainBlockHeight }
}
}
test('establishProfileRecency recovers the newest confirmed qualifying post', async () => {
await forAll(establishWorldGen(), async ({ adapters, entries, chainBlockHeight }) => {
await establishProfileRecency(adapters, ADDR)
let expected = null
for (const entry of entries) {
if (entry.kind !== 'post') continue
if (entry.blockHeight > chainBlockHeight) continue
if (!expected || entry.blockHeight > expected.blockHeight ||
(entry.blockHeight === expected.blockHeight && entry.seen > expected.seen)) {
expected = entry
}
}
const stored = adapters.profileRecencyDb.store.get(ADDR)
if (!expected) return stored === undefined
return Boolean(stored) &&
stored.blockHeight === expected.blockHeight &&
stored.seen === expected.seen
}, { label: 'establishProfileRecency newest qualifying post' })
})