Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/self-hosted/hosting/api/src/services/seo-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ describe('fetchTenantPosts', () => {
undefined,
expect.any(AbortSignal),
);
// The bridge asserts limit into [1:20] and ERRORS above it, so a page may
// never ask for more; asking for 100 failed every tenant in production.
for (const call of mocks.callRPC.mock.calls) {
expect(call[1].limit).toBeLessThanOrEqual(20);
}

const community = {
...TENANT,
Expand All @@ -141,6 +146,57 @@ describe('fetchTenantPosts', () => {
);
});

it('walks pages with an exclusive cursor up to the wanted depth', async () => {
// Six full pages exist; the walk wants 100 posts, so it stops after five.
const page = (n: number) =>
Array.from({ length: 20 }, (_, i) => ({
author: 'alice',
permlink: `p${n}-${i}`,
created: '2026-08-01T00:00:00',
}));
mocks.callRPC.mockImplementation(async (_m: string, params: any) =>
page(Number(params.start_permlink?.split('-')[0]?.replace('p', '') ?? -1) + 1),
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
);

const posts = await fetchTenantPosts(TENANT);
expect(posts).toHaveLength(100);
expect(mocks.callRPC).toHaveBeenCalledTimes(5);
// Each page after the first carries the previous page's last post as the
// cursor, and every returned post is distinct.
expect(mocks.callRPC.mock.calls[1][1]).toMatchObject({
start_author: 'alice',
start_permlink: 'p0-19',
});
expect(new Set(posts.map((p) => p.permlink)).size).toBe(100);
});

it('stops on a short page instead of asking for one more', async () => {
mocks.callRPC.mockResolvedValue(
Array.from({ length: 3 }, (_, i) => ({
author: 'alice',
permlink: `only-${i}`,
created: '2026-08-01T00:00:00',
})),
);
const posts = await fetchTenantPosts(TENANT);
expect(posts).toHaveLength(3);
expect(mocks.callRPC).toHaveBeenCalledTimes(1);
});

it('terminates when a node echoes the cursor post back forever', async () => {
// An inclusive-cursor node would otherwise repeat its last page for ever:
// a full page whose posts are all already seen ends the walk.
const repeated = Array.from({ length: 20 }, (_, i) => ({
author: 'alice',
permlink: `same-${i}`,
created: '2026-08-01T00:00:00',
}));
mocks.callRPC.mockResolvedValue(repeated);
const posts = await fetchTenantPosts(TENANT);
expect(posts).toHaveLength(20);
expect(mocks.callRPC).toHaveBeenCalledTimes(2);
});

it('throws on a malformed response so stale files are kept, never blanked', async () => {
mocks.callRPC.mockResolvedValue({ nope: true });
await expect(fetchTenantPosts(TENANT)).rejects.toThrow('malformed');
Expand Down
108 changes: 70 additions & 38 deletions apps/self-hosted/hosting/api/src/services/seo-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,19 @@ export function canonicalPostUrl(
return `https://ecency.com/@${author}/${permlink}`;
}

/** How many posts feeds and sitemaps carry; one bridge page. */
/**
* How many posts feeds and sitemaps carry, and how they are collected.
*
* The bridge asserts `limit` into [1:20] and answers an ERROR, not a shorter
* list, when asked for more: a single limit=100 call failed every tenant's
* pass in production, so the wanted depth is PAGED at the bridge's own page
* size. The whole walk carries one deadline as well as the per-call one, so
* a chain that answers slowly costs a bounded pass rather than page count
* times the per-call timeout.
*/
const POST_LIMIT = 100;
const BRIDGE_PAGE_LIMIT = 20;
const FETCH_BUDGET_MS = 30_000;
/** A pass regenerates a tenant's files only when they are older than this. */
export const SEO_FRESH_MS = 30 * 60 * 1000;
/** The background pass is patient but never unbounded. */
Expand Down Expand Up @@ -92,45 +103,66 @@ async function boundedCall<T>(method: string, params: object): Promise<T> {
/** The tenant's latest posts, the same feeds the archive itself pages. */
export async function fetchTenantPosts(tenant: Tenant): Promise<TenantPost[]> {
const { community, communityId } = isCommunityTenant(tenant);
const raw = community
? await boundedCall<unknown>('bridge.get_ranked_posts', {
sort: 'created',
tag: communityId,
limit: POST_LIMIT,
observer: '',
})
: await boundedCall<unknown>('bridge.get_account_posts', {
sort: 'posts',
account: tenant.username,
limit: POST_LIMIT,
observer: '',
});
// A malformed answer is an ERROR, never an empty blog: returning [] here
// would overwrite a good sitemap and feed with empty ones and mark them
// fresh, while throwing lets the sync pass keep yesterday's files.
if (!Array.isArray(raw)) {
throw new Error('malformed bridge feed response');
}
// Every field the builders touch is type-checked here: a malformed record
// (a numeric date, a missing permlink) is dropped or normalized instead of
// failing the tenant's whole SEO pass on an .endsWith of a number.
const method = community
? 'bridge.get_ranked_posts'
: 'bridge.get_account_posts';
const feedParams = community
? { sort: 'created', tag: communityId, observer: '' }
: { sort: 'posts', account: tenant.username, observer: '' };

const posts: TenantPost[] = [];
for (const p of raw as any[]) {
if (
typeof p?.author !== 'string' ||
typeof p?.permlink !== 'string' ||
typeof p?.created !== 'string'
) {
continue;
}
posts.push({
author: p.author,
permlink: p.permlink,
title: typeof p.title === 'string' ? p.title : '',
created: p.created,
updated: typeof p.updated === 'string' ? p.updated : undefined,
body: typeof p.body === 'string' ? p.body : undefined,
const seen = new Set<string>();
const deadline = Date.now() + FETCH_BUDGET_MS;
let cursor: { start_author: string; start_permlink: string } | null = null;

while (posts.length < POST_LIMIT) {
const raw = await boundedCall<unknown>(method, {
...feedParams,
limit: Math.min(BRIDGE_PAGE_LIMIT, POST_LIMIT - posts.length),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve a slot for an inclusive cursor

When a bridge node includes the cursor post in the next page, this limit counts that duplicate against the remaining post count. After collecting 96 unique posts, for example, the request for 4 returns the cursor plus 3 new posts; the short-page check then stops with only 99 posts. The repository's scan-post-corpus.mjs paginator explicitly handles this API behavior by requesting one extra slot on follow-up pages, so this walk should do the same while retaining the identity deduplication.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in e0f97ee. Follow-up pages now ask for one extra slot (Math.min(20, cursor ? need + 1 : need)), the short-page check compares against what was ASKED rather than the constant, and the result is trimmed to the wanted depth since the reserved slot can overshoot by one on an exclusive node. The identity set still does the actual de-duplication, so both node behaviours are covered. Worth noting for the record: I checked api.hive.blog directly and BOTH feeds are exclusive today (page 2 starts after the cursor post), while scan-post-corpus.mjs documents the inclusive behaviour, so the reserved slot is the right defence against the variance rather than a fix for a known-inclusive node. A regression test drives an inclusive-cursor mock and asserts the walk still reaches exactly 100 unique posts; it returns 99 without the reserved slot.

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
...(cursor ?? {}),
});
// A malformed answer is an ERROR, never an empty blog: returning [] here
// would overwrite a good sitemap and feed with empty ones and mark them
// fresh, while throwing lets the sync pass keep yesterday's files.
if (!Array.isArray(raw)) {
throw new Error('malformed bridge feed response');
}
const page = raw as any[];
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
// Every field the builders touch is type-checked here: a malformed record
// (a numeric date, a missing permlink) is dropped or normalized instead of
// failing the tenant's whole SEO pass on an .endsWith of a number.
let added = 0;
let last: { author: string; permlink: string } | null = null;
for (const p of page) {
if (
typeof p?.author !== 'string' ||
typeof p?.permlink !== 'string' ||
typeof p?.created !== 'string'
) {
continue;
}
last = { author: p.author, permlink: p.permlink };
// The cursor is exclusive on today's bridge, but a node that echoes the
// start post back would otherwise repeat a page forever; the identity
// set makes the walk terminate either way.
const key = `${p.author}/${p.permlink}`;
if (seen.has(key)) continue;
seen.add(key);
added++;
posts.push({
author: p.author,
permlink: p.permlink,
title: typeof p.title === 'string' ? p.title : '',
created: p.created,
updated: typeof p.updated === 'string' ? p.updated : undefined,
body: typeof p.body === 'string' ? p.body : undefined,
});
}
// A short page is the end of the feed; no new posts or no usable record
// means paging further cannot help. Either way, stop.
if (page.length < BRIDGE_PAGE_LIMIT || added === 0 || !last) break;
if (Date.now() >= deadline) break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the overall deadline during each RPC

If several pages each complete just under the 10-second per-call timeout, this check can observe 29 seconds elapsed and start another full 10-second call, allowing the advertised 30-second fetch budget to take nearly 40 seconds. Because stale tenants are processed by a bounded worker pool, repeated overruns can substantially extend or skip SEO sync passes; pass the remaining overall budget into the next call (and its abort timer) rather than checking only after it finishes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in e0f97ee. The remaining budget is now computed BEFORE each page and passed into the call as its timeout (Math.min(RPC_TIMEOUT_MS, remaining), which also drives the abort timer), and a page is not started at all when less than a second of budget is left. The walk can no longer exceed its advertised 30s. Covered by a test that spends 9s per page against a stubbed clock: four pages run, the fifth is never started, and the final call's timeout is the 3s that were left rather than the full 10s.

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
cursor = { start_author: last.author, start_permlink: last.permlink };
}
return posts;
}
Expand Down
Loading