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