Skip to content

Page the per-tenant SEO feed at the bridge's own limit - #1464

Merged
feruzm merged 3 commits into
developfrom
bugfix/seo-bridge-limit
Aug 12, 2026
Merged

Page the per-tenant SEO feed at the bridge's own limit#1464
feruzm merged 3 commits into
developfrom
bugfix/seo-bridge-limit

Conversation

@feruzm

@feruzm feruzm commented Aug 12, 2026

Copy link
Copy Markdown
Member

The per-tenant SEO files shipped in #1463 were never written in production: bridge.get_account_posts and bridge.get_ranked_posts assert limit into [1:20] and answer an error rather than a shorter list, so the single limit=100 call failed every tenant's pass.

[ConfigService] SEO sync failed for <tenant>: Assert Exception:limit = 100 outside valid range [1:20]
[ConfigService] SEO sync finished with 12 failure(s)

The feed is now walked in pages of 20 (the bridge's own page size) with an exclusive start_author/start_permlink cursor up to the same wanted depth of 100. The whole walk carries a single budget alongside the existing per-call timeout, so a slow chain costs a bounded pass rather than page count times the per-call timeout. The walk stops on a short page, on a page that adds nothing new (an inclusive-cursor node would otherwise repeat its last page forever) and on an unusable record.

Verified against the live chain with the real function, not mocks: a blog tenant and a community tenant each collect 100 unique posts and produce a 102-URL sitemap and a 100-item feed. Tests pin the per-page limit ceiling, the cursor walk, the short-page stop and the repeated-page termination.

The bridge asserts limit into [1:20] and answers an error rather than a
shorter list, so the single limit=100 call failed every tenant's SEO pass
in production and no robots.txt, sitemap.xml or rss.xml was ever written.
Walk pages of 20 with an exclusive cursor up to the same wanted depth,
under one budget for the whole walk as well as the per-call timeout, and
terminate on a short page, a repeated page or an unusable record.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Short-page check uses 20 ✓ Resolved 🐞 Bug ≡ Correctness
Description
fetchTenantPosts() treats any page with fewer than 20 items as end-of-feed, even when it
intentionally requested fewer than 20 (the final partial request). If that partial page contains
duplicates/malformed records (so fewer posts are added than requested), the walk can stop early and
publish truncated sitemap/RSS despite more posts existing.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[R161-164]

+    // 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;
Relevance

●● Moderate

Potential early-termination edge case in pagination; correctness concern is plausible but depends on
expected bridge behavior.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code intentionally requests fewer than 20 items when nearing POST_LIMIT, but still uses a fixed
20-item threshold to decide the feed ended. That means once the requested limit is <20, the “short
page” condition becomes true regardless of whether the bridge satisfied the request; if
duplicates/malformed entries reduce added, the loop can’t fetch another page to reach POST_LIMIT
and downstream writers will publish the shorter result.

apps/self-hosted/hosting/api/src/services/seo-files.ts[118-123]
apps/self-hosted/hosting/api/src/services/seo-files.ts[161-165]
apps/self-hosted/hosting/api/src/services/config-service.ts[262-268]
PR-#630

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`fetchTenantPosts()` requests `limit = min(20, remaining)` but later decides the feed ended when `page.length < 20`. When `remaining < 20`, every response is “short” by this rule even if it returned the full requested amount; this prevents the loop from compensating for duplicates/malformed records on that final partial page.
### Issue Context
This function’s output directly drives static SEO sitemap/RSS generation; an early stop publishes fewer URLs/items and then touches mtimes, making the truncated artifacts appear fresh until the next refresh.
### Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[118-123]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[161-165]
### Suggested change
- Capture the per-call requested limit in a variable (e.g. `const requestedLimit = Math.min(BRIDGE_PAGE_LIMIT, POST_LIMIT - posts.length)`), use it both in the RPC params and in the short-page stop condition (`if (page.length < requestedLimit ...)`).
- (Optional but recommended) Add/adjust a unit test that simulates duplicates or malformed items on the final partial request to ensure the walker continues until it truly can’t make progress or reaches `POST_LIMIT`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Short-page check uses 20 ✓ Resolved 🐞 Bug ≡ Correctness
Description
fetchTenantPosts() treats any page with fewer than 20 items as end-of-feed, even when it
intentionally requested fewer than 20 (the final partial request). If that partial page contains
duplicates/malformed records (so fewer posts are added than requested), the walk can stop early and
publish truncated sitemap/RSS despite more posts existing.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[R161-164]

+    // 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;
Relevance

●● Moderate

Potential early-termination edge case in pagination; correctness concern is plausible but depends on
expected bridge behavior.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code intentionally requests fewer than 20 items when nearing POST_LIMIT, but still uses a fixed
20-item threshold to decide the feed ended. That means once the requested limit is <20, the “short
page” condition becomes true regardless of whether the bridge satisfied the request; if
duplicates/malformed entries reduce added, the loop can’t fetch another page to reach POST_LIMIT
and downstream writers will publish the shorter result.

apps/self-hosted/hosting/api/src/services/seo-files.ts[118-123]
apps/self-hosted/hosting/api/src/services/seo-files.ts[161-165]
apps/self-hosted/hosting/api/src/services/config-service.ts[262-268]
PR-#630

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`fetchTenantPosts()` requests `limit = min(20, remaining)` but later decides the feed ended when `page.length < 20`. When `remaining < 20`, every response is “short” by this rule even if it returned the full requested amount; this prevents the loop from compensating for duplicates/malformed records on that final partial page.
### Issue Context
This function’s output directly drives static SEO sitemap/RSS generation; an early stop publishes fewer URLs/items and then touches mtimes, making the truncated artifacts appear fresh until the next refresh.
### Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[118-123]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[161-165]
### Suggested change
- Capture the per-call requested limit in a variable (e.g. `const requestedLimit = Math.min(BRIDGE_PAGE_LIMIT, POST_LIMIT - posts.length)`), use it both in the RPC params and in the short-page stop condition (`if (page.length < requestedLimit ...)`).
- (Optional but recommended) Add/adjust a unit test that simulates duplicates or malformed items on the final partial request to ensure the walker continues until it truly can’t make progress or reaches `POST_LIMIT`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Short-page check uses 20 ✓ Resolved 🐞 Bug ≡ Correctness
Description
fetchTenantPosts() treats any page with fewer than 20 items as end-of-feed, even when it
intentionally requested fewer than 20 (the final partial request). If that partial page contains
duplicates/malformed records (so fewer posts are added than requested), the walk can stop early and
publish truncated sitemap/RSS despite more posts existing.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[R161-164]

+    // 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;
Relevance

●● Moderate

Potential early-termination edge case in pagination; correctness concern is plausible but depends on
expected bridge behavior.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code intentionally requests fewer than 20 items when nearing POST_LIMIT, but still uses a fixed
20-item threshold to decide the feed ended. That means once the requested limit is <20, the “short
page” condition becomes true regardless of whether the bridge satisfied the request; if
duplicates/malformed entries reduce added, the loop can’t fetch another page to reach POST_LIMIT
and downstream writers will publish the shorter result.

apps/self-hosted/hosting/api/src/services/seo-files.ts[118-123]
apps/self-hosted/hosting/api/src/services/seo-files.ts[161-165]
apps/self-hosted/hosting/api/src/services/config-service.ts[262-268]
PR-#630

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`fetchTenantPosts()` requests `limit = min(20, remaining)` but later decides the feed ended when `page.length < 20`. When `remaining < 20`, every response is “short” by this rule even if it returned the full requested amount; this prevents the loop from compensating for duplicates/malformed records on that final partial page.
### Issue Context
This function’s output directly drives static SEO sitemap/RSS generation; an early stop publishes fewer URLs/items and then touches mtimes, making the truncated artifacts appear fresh until the next refresh.
### Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[118-123]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[161-165]
### Suggested change
- Capture the per-call requested limit in a variable (e.g. `const requestedLimit = Math.min(BRIDGE_PAGE_LIMIT, POST_LIMIT - posts.length)`), use it both in the RPC params and in the short-page stop condition (`if (page.length < requestedLimit ...)`).
- (Optional but recommended) Add/adjust a unit test that simulates duplicates or malformed items on the final partial request to ensure the walker continues until it truly can’t make progress or reaches `POST_LIMIT`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. raw as any[] in fetchTenantPosts ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new paging implementation introduces an any[] cast, which defeats type-safety for the RPC
response and violates the rule against new any usage. This can hide malformed response shapes and
make future changes error-prone.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[130]

+    const page = raw as any[];
Relevance

●●● Strong

Removing raw as any[] is a deterministic type-safety fix and matches repo’s push toward
runtime-shape validation.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668119 disallows introducing any in modified TypeScript code. The updated
implementation casts the RPC response to any[] (const page = raw as any[];), introducing any
on new/modified lines.

Rule 2668119: Disallow implicit and any types in new TypeScript code
apps/self-hosted/hosting/api/src/services/seo-files.ts[124-136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New code introduces `any` via `raw as any[]`, which violates the no-`any` requirement for modified TypeScript files.
## Issue Context
`fetchTenantPosts()` already performs runtime type checks for `author`, `permlink`, and `created`, so the intermediate array can be typed as `unknown[]` and narrowed without `any`.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[127-136]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. params: any in test ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new test introduces an explicit any type for RPC params, violating the rule against new any
usage in modified TypeScript files. This reduces test type-safety and can mask incorrect call
shapes.
Code

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[R157-158]

+    mocks.callRPC.mockImplementation(async (_m: string, params: any) =>
+      page(Number(params.start_permlink?.split('-')[0]?.replace('p', '') ?? -1) + 1),
Relevance

●●● Strong

Replacing explicit any in tests is a straightforward type-safety improvement; likely accepted to
satisfy TS no-any rule.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668119 disallows introducing any in modified TypeScript code. The added test
uses params: any in mocks.callRPC.mockImplementation(...), introducing an explicit any type on
newly added lines.

Rule 2668119: Disallow implicit and any types in new TypeScript code
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[149-159]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new test uses `params: any` in a mock implementation, which violates the no-`any` requirement.
## Issue Context
Only `start_permlink` is needed by this mock, so the parameter can be typed narrowly (e.g., `{ start_permlink?: string }`) or as `Record<string, unknown>` and then narrowed.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[149-159]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. params: any in test ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new test introduces an explicit any type for RPC params, violating the rule against new any
usage in modified TypeScript files. This reduces test type-safety and can mask incorrect call
shapes.
Code

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[R157-158]

+    mocks.callRPC.mockImplementation(async (_m: string, params: any) =>
+      page(Number(params.start_permlink?.split('-')[0]?.replace('p', '') ?? -1) + 1),
Relevance

●●● Strong

Replacing explicit any in tests is a straightforward type-safety improvement; likely accepted to
satisfy TS no-any rule.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668119 disallows introducing any in modified TypeScript code. The added test
uses params: any in mocks.callRPC.mockImplementation(...), introducing an explicit any type on
newly added lines.

Rule 2668119: Disallow implicit and any types in new TypeScript code
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[149-159]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new test uses `params: any` in a mock implementation, which violates the no-`any` requirement.
## Issue Context
Only `start_permlink` is needed by this mock, so the parameter can be typed narrowly (e.g., `{ start_permlink?: string }`) or as `Record<string, unknown>` and then narrowed.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[149-159]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (6)
7. raw as any[] in fetchTenantPosts ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new paging implementation introduces an any[] cast, which defeats type-safety for the RPC
response and violates the rule against new any usage. This can hide malformed response shapes and
make future changes error-prone.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[130]

+    const page = raw as any[];
Relevance

●●● Strong

Removing raw as any[] is a deterministic type-safety fix and matches repo’s push toward
runtime-shape validation.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668119 disallows introducing any in modified TypeScript code. The updated
implementation casts the RPC response to any[] (const page = raw as any[];), introducing any
on new/modified lines.

Rule 2668119: Disallow implicit and any types in new TypeScript code
apps/self-hosted/hosting/api/src/services/seo-files.ts[124-136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New code introduces `any` via `raw as any[]`, which violates the no-`any` requirement for modified TypeScript files.
## Issue Context
`fetchTenantPosts()` already performs runtime type checks for `author`, `permlink`, and `created`, so the intermediate array can be typed as `unknown[]` and narrowed without `any`.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[127-136]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. params: any in test ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new test introduces an explicit any type for RPC params, violating the rule against new any
usage in modified TypeScript files. This reduces test type-safety and can mask incorrect call
shapes.
Code

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[R157-158]

+    mocks.callRPC.mockImplementation(async (_m: string, params: any) =>
+      page(Number(params.start_permlink?.split('-')[0]?.replace('p', '') ?? -1) + 1),
Relevance

●●● Strong

Replacing explicit any in tests is a straightforward type-safety improvement; likely accepted to
satisfy TS no-any rule.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668119 disallows introducing any in modified TypeScript code. The added test
uses params: any in mocks.callRPC.mockImplementation(...), introducing an explicit any type on
newly added lines.

Rule 2668119: Disallow implicit and any types in new TypeScript code
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[149-159]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new test uses `params: any` in a mock implementation, which violates the no-`any` requirement.
## Issue Context
Only `start_permlink` is needed by this mock, so the parameter can be typed narrowly (e.g., `{ start_permlink?: string }`) or as `Record<string, unknown>` and then narrowed.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[149-159]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. raw as any[] in fetchTenantPosts ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new paging implementation introduces an any[] cast, which defeats type-safety for the RPC
response and violates the rule against new any usage. This can hide malformed response shapes and
make future changes error-prone.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[130]

+    const page = raw as any[];
Relevance

●●● Strong

Removing raw as any[] is a deterministic type-safety fix and matches repo’s push toward
runtime-shape validation.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668119 disallows introducing any in modified TypeScript code. The updated
implementation casts the RPC response to any[] (const page = raw as any[];), introducing any
on new/modified lines.

Rule 2668119: Disallow implicit and any types in new TypeScript code
apps/self-hosted/hosting/api/src/services/seo-files.ts[124-136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New code introduces `any` via `raw as any[]`, which violates the no-`any` requirement for modified TypeScript files.
## Issue Context
`fetchTenantPosts()` already performs runtime type checks for `author`, `permlink`, and `created`, so the intermediate array can be typed as `unknown[]` and narrowed without `any`.
## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[127-136]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Budget checked after call ✓ Resolved 🐞 Bug ☼ Reliability
Description
The overall FETCH_BUDGET_MS deadline is only checked after each RPC page fetch completes, so the
walker can start a request just before the deadline and still run up to RPC_TIMEOUT_MS beyond the
intended 30s cap. This undermines the “single bounded budget” goal and can extend background SEO
work longer than expected.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[R118-121]

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

●● Moderate

Pre-checking the deadline before starting each RPC aligns with “single bounded budget”, but behavior
change is subtle.

PR-#1458

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The loop begins the network call without checking whether the overall budget has already expired,
and only checks the deadline after page processing. Since boundedCall() itself uses a 10s timeout,
this can extend total runtime beyond the 30s budget by up to one call timeout.

apps/self-hosted/hosting/api/src/services/seo-files.ts[88-101]
apps/self-hosted/hosting/api/src/services/seo-files.ts[118-165]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The loop checks `Date.now() >= deadline` only after `boundedCall()` returns. This allows one extra `boundedCall()` to run after the budget has already expired.
### Issue Context
`boundedCall()` can take up to `RPC_TIMEOUT_MS` (10s). With a 30s budget, the walk can still take ~40s in the worst case.
### Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[88-101]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[118-165]
### Suggested change
- Add a pre-call budget check at the top of the loop: if `Date.now() >= deadline` then break.
- If you want a strict wall-clock cap, compute remaining time and pass it into `boundedCall` (e.g., `attemptTimeoutMs = Math.min(RPC_TIMEOUT_MS, deadline - Date.now())`), and update `boundedCall()` to accept a timeout parameter and use it for both the SDK timeout and the AbortController timer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Budget checked after call ✓ Resolved 🐞 Bug ☼ Reliability
Description
The overall FETCH_BUDGET_MS deadline is only checked after each RPC page fetch completes, so the
walker can start a request just before the deadline and still run up to RPC_TIMEOUT_MS beyond the
intended 30s cap. This undermines the “single bounded budget” goal and can extend background SEO
work longer than expected.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[R118-121]

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

●● Moderate

Pre-checking the deadline before starting each RPC aligns with “single bounded budget”, but behavior
change is subtle.

PR-#1458

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The loop begins the network call without checking whether the overall budget has already expired,
and only checks the deadline after page processing. Since boundedCall() itself uses a 10s timeout,
this can extend total runtime beyond the 30s budget by up to one call timeout.

apps/self-hosted/hosting/api/src/services/seo-files.ts[88-101]
apps/self-hosted/hosting/api/src/services/seo-files.ts[118-165]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The loop checks `Date.now() >= deadline` only after `boundedCall()` returns. This allows one extra `boundedCall()` to run after the budget has already expired.
### Issue Context
`boundedCall()` can take up to `RPC_TIMEOUT_MS` (10s). With a 30s budget, the walk can still take ~40s in the worst case.
### Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[88-101]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[118-165]
### Suggested change
- Add a pre-call budget check at the top of the loop: if `Date.now() >= deadline` then break.
- If you want a strict wall-clock cap, compute remaining time and pass it into `boundedCall` (e.g., `attemptTimeoutMs = Math.min(RPC_TIMEOUT_MS, deadline - Date.now())`), and update `boundedCall()` to accept a timeout parameter and use it for both the SDK timeout and the AbortController timer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Budget checked after call ✓ Resolved 🐞 Bug ☼ Reliability
Description
The overall FETCH_BUDGET_MS deadline is only checked after each RPC page fetch completes, so the
walker can start a request just before the deadline and still run up to RPC_TIMEOUT_MS beyond the
intended 30s cap. This undermines the “single bounded budget” goal and can extend background SEO
work longer than expected.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[R118-121]

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

●● Moderate

Pre-checking the deadline before starting each RPC aligns with “single bounded budget”, but behavior
change is subtle.

PR-#1458

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The loop begins the network call without checking whether the overall budget has already expired,
and only checks the deadline after page processing. Since boundedCall() itself uses a 10s timeout,
this can extend total runtime beyond the 30s budget by up to one call timeout.

apps/self-hosted/hosting/api/src/services/seo-files.ts[88-101]
apps/self-hosted/hosting/api/src/services/seo-files.ts[118-165]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The loop checks `Date.now() >= deadline` only after `boundedCall()` returns. This allows one extra `boundedCall()` to run after the budget has already expired.
### Issue Context
`boundedCall()` can take up to `RPC_TIMEOUT_MS` (10s). With a 30s budget, the walk can still take ~40s in the worst case.
### Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[88-101]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[118-165]
### Suggested change
- Add a pre-call budget check at the top of the loop: if `Date.now() >= deadline` then break.
- If you want a strict wall-clock cap, compute remaining time and pass it into `boundedCall` (e.g., `attemptTimeoutMs = Math.min(RPC_TIMEOUT_MS, deadline - Date.now())`), and update `boundedCall()` to accept a timeout parameter and use it for both the SDK timeout and the AbortController timer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@feruzm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6f4c904a-f5c0-4587-a6d0-ccf1e4f4471b

📥 Commits

Reviewing files that changed from the base of the PR and between ac13ea3 and 5fb63d3.

📒 Files selected for processing (2)
  • apps/self-hosted/hosting/api/src/services/seo-files.test.ts
  • apps/self-hosted/hosting/api/src/services/seo-files.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 12, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Page per-tenant SEO feed at bridge limit (20) with cursor + global budget

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Fix SEO sync failures by paging bridge post feeds in 20-item requests.
• Add exclusive cursor walking to collect up to 100 unique posts safely.
• Bound total fetch time and stop on short, repeated, or malformed pages.
Diagram

graph TD
  A["ConfigService SEO sync"] --> B["writeSeoFilesIfStale"] --> C["fetchTenantPosts (paged)"] --> D["boundedCall"] --> E["Hive bridge RPC\nget_*_posts"]
  B --> F["SEO builders\nrobots/sitemap/rss"] --> G["Config volume\n*.robots/*.sitemap/*.rss"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Fix the bridge to accept higher limits (server-side cap)
  • ➕ Simplifies client logic back to a single RPC call
  • ➕ Avoids cursor/dedupe termination complexity in this service
  • ➖ Requires bridge deployment/coordination; not always owned by this repo
  • ➖ Still risks large responses and increased load if clients request big limits
2. Switch to an endpoint that supports larger windows (if available)
  • ➕ Potentially fewer round trips than 20-item paging
  • ➕ May provide a more explicit pagination model
  • ➖ May not exist or may differ between community/account feeds
  • ➖ Could introduce new response shape/compatibility risk
3. Always fetch exactly one 20-item page (reduce SEO depth)
  • ➕ Trivial implementation and minimal RPC usage
  • ➕ Eliminates paging edge cases entirely
  • ➖ Reduces sitemap/feed usefulness vs intended 100-item depth
  • ➖ Does not meet stated SEO requirements

Recommendation: Keep the PR’s approach: client-side paging at the bridge’s enforced 20-item limit with an exclusive cursor, dedupe-based loop termination, and a global time budget. It restores the intended 100-post depth without requiring bridge changes, and the added tests cover the key failure modes (limit ceiling, cursor walking, short-page stop, repeated-page termination).

Files changed (2) +126 / -38

Bug fix (1) +70 / -38
seo-files.tsPage bridge post feeds at limit=20 with cursor, dedupe, and total budget +70/-38

Page bridge post feeds at limit=20 with cursor, dedupe, and total budget

• Reworks fetchTenantPosts to page bridge.get_account_posts / bridge.get_ranked_posts at the bridge-enforced limit (20) until collecting up to 100 posts. Adds an exclusive start_author/start_permlink cursor, a global time budget for the whole walk, and termination on short pages, repeated pages (no new posts), or unusable records while preserving existing malformed-response failure behavior.

apps/self-hosted/hosting/api/src/services/seo-files.ts

Tests (1) +56 / -0
seo-files.test.tsAdd tests for paging limit, cursor walking, and termination conditions +56/-0

Add tests for paging limit, cursor walking, and termination conditions

• Extends fetchTenantPosts tests to assert no RPC call exceeds limit=20. Adds coverage for cursor-based paging up to 100 items, stopping on a short page, and terminating when a node repeats the cursor page indefinitely.

apps/self-hosted/hosting/api/src/services/seo-files.test.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d11729a0cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

// 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.

@qodo-code-review

qodo-code-review Bot commented Aug 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Short-page check uses 20 ✓ Resolved 🐞 Bug ≡ Correctness
Description
fetchTenantPosts() treats any page with fewer than 20 items as end-of-feed, even when it
intentionally requested fewer than 20 (the final partial request). If that partial page contains
duplicates/malformed records (so fewer posts are added than requested), the walk can stop early and
publish truncated sitemap/RSS despite more posts existing.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[R161-164]

+    // 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;
Relevance

●● Moderate

Potential early-termination edge case in pagination; correctness concern is plausible but depends on
expected bridge behavior.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code intentionally requests fewer than 20 items when nearing POST_LIMIT, but still uses a fixed
20-item threshold to decide the feed ended. That means once the requested limit is <20, the “short
page” condition becomes true regardless of whether the bridge satisfied the request; if
duplicates/malformed entries reduce added, the loop can’t fetch another page to reach POST_LIMIT
and downstream writers will publish the shorter result.

apps/self-hosted/hosting/api/src/services/seo-files.ts[118-123]
apps/self-hosted/hosting/api/src/services/seo-files.ts[161-165]
apps/self-hosted/hosting/api/src/services/config-service.ts[262-268]
PR-#630

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`fetchTenantPosts()` requests `limit = min(20, remaining)` but later decides the feed ended when `page.length < 20`. When `remaining < 20`, every response is “short” by this rule even if it returned the full requested amount; this prevents the loop from compensating for duplicates/malformed records on that final partial page.

### Issue Context
This function’s output directly drives static SEO sitemap/RSS generation; an early stop publishes fewer URLs/items and then touches mtimes, making the truncated artifacts appear fresh until the next refresh.

### Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[118-123]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[161-165]

### Suggested change
- Capture the per-call requested limit in a variable (e.g. `const requestedLimit = Math.min(BRIDGE_PAGE_LIMIT, POST_LIMIT - posts.length)`), use it both in the RPC params and in the short-page stop condition (`if (page.length < requestedLimit ...)`).
- (Optional but recommended) Add/adjust a unit test that simulates duplicates or malformed items on the final partial request to ensure the walker continues until it truly can’t make progress or reaches `POST_LIMIT`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. params: any in test ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new test introduces an explicit any type for RPC params, violating the rule against new any
usage in modified TypeScript files. This reduces test type-safety and can mask incorrect call
shapes.
Code

apps/self-hosted/hosting/api/src/services/seo-files.test.ts[R157-158]

+    mocks.callRPC.mockImplementation(async (_m: string, params: any) =>
+      page(Number(params.start_permlink?.split('-')[0]?.replace('p', '') ?? -1) + 1),
Relevance

●●● Strong

Replacing explicit any in tests is a straightforward type-safety improvement; likely accepted to
satisfy TS no-any rule.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668119 disallows introducing any in modified TypeScript code. The added test
uses params: any in mocks.callRPC.mockImplementation(...), introducing an explicit any type on
newly added lines.

Rule 2668119: Disallow implicit and any types in new TypeScript code
apps/self-hosted/hosting/api/src/services/seo-files.test.ts[149-159]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new test uses `params: any` in a mock implementation, which violates the no-`any` requirement.

## Issue Context
Only `start_permlink` is needed by this mock, so the parameter can be typed narrowly (e.g., `{ start_permlink?: string }`) or as `Record<string, unknown>` and then narrowed.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.test.ts[149-159]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. raw as any[] in fetchTenantPosts ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new paging implementation introduces an any[] cast, which defeats type-safety for the RPC
response and violates the rule against new any usage. This can hide malformed response shapes and
make future changes error-prone.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[130]

+    const page = raw as any[];
Relevance

●●● Strong

Removing raw as any[] is a deterministic type-safety fix and matches repo’s push toward
runtime-shape validation.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668119 disallows introducing any in modified TypeScript code. The updated
implementation casts the RPC response to any[] (const page = raw as any[];), introducing any
on new/modified lines.

Rule 2668119: Disallow implicit and any types in new TypeScript code
apps/self-hosted/hosting/api/src/services/seo-files.ts[124-136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New code introduces `any` via `raw as any[]`, which violates the no-`any` requirement for modified TypeScript files.

## Issue Context
`fetchTenantPosts()` already performs runtime type checks for `author`, `permlink`, and `created`, so the intermediate array can be typed as `unknown[]` and narrowed without `any`.

## Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[127-136]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Budget checked after call ✓ Resolved 🐞 Bug ☼ Reliability
Description
The overall FETCH_BUDGET_MS deadline is only checked after each RPC page fetch completes, so the
walker can start a request just before the deadline and still run up to RPC_TIMEOUT_MS beyond the
intended 30s cap. This undermines the “single bounded budget” goal and can extend background SEO
work longer than expected.
Code

apps/self-hosted/hosting/api/src/services/seo-files.ts[R118-121]

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

●● Moderate

Pre-checking the deadline before starting each RPC aligns with “single bounded budget”, but behavior
change is subtle.

PR-#1458

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The loop begins the network call without checking whether the overall budget has already expired,
and only checks the deadline after page processing. Since boundedCall() itself uses a 10s timeout,
this can extend total runtime beyond the 30s budget by up to one call timeout.

apps/self-hosted/hosting/api/src/services/seo-files.ts[88-101]
apps/self-hosted/hosting/api/src/services/seo-files.ts[118-165]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The loop checks `Date.now() >= deadline` only after `boundedCall()` returns. This allows one extra `boundedCall()` to run after the budget has already expired.

### Issue Context
`boundedCall()` can take up to `RPC_TIMEOUT_MS` (10s). With a 30s budget, the walk can still take ~40s in the worst case.

### Fix Focus Areas
- apps/self-hosted/hosting/api/src/services/seo-files.ts[88-101]
- apps/self-hosted/hosting/api/src/services/seo-files.ts[118-165]

### Suggested change
- Add a pre-call budget check at the top of the loop: if `Date.now() >= deadline` then break.
- If you want a strict wall-clock cap, compute remaining time and pass it into `boundedCall` (e.g., `attemptTimeoutMs = Math.min(RPC_TIMEOUT_MS, deadline - Date.now())`), and update `boundedCall()` to accept a timeout parameter and use it for both the SDK timeout and the AbortController timer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context
✅ Compliance rules (platform): 75 rules
✅ Skills: 6 invoked
  add-feature
  add-query
  add-sdk-mutation
  add-test
  code-review
  debug
Review mode: ⚖️ Balanced: This changes production feed pagination, cursor termination, malformed-record handling, and a shared timeout budget across tenant paths; it has meaningful behavioral risk, but the logic is concentrated enough for one careful review.

Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread apps/self-hosted/hosting/api/src/services/seo-files.ts Outdated
Comment thread apps/self-hosted/hosting/api/src/services/seo-files.test.ts Outdated
Comment thread apps/self-hosted/hosting/api/src/services/seo-files.ts Outdated
Comment thread apps/self-hosted/hosting/api/src/services/seo-files.ts Outdated
@feruzm

feruzm commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

All four findings are addressed at 5fb63d3.

Short-page check uses 20 (High, already marked resolved): the check now compares against what the page was ASKED for, not the constant, so a deliberately short final request is no longer read as end-of-feed.

Budget checked after call (already marked resolved): the remaining budget is computed BEFORE each page and passed in as that call's timeout (which also drives its abort timer); a page is not started at all with under a second left. The walk can no longer run past its advertised 30s.

raw as any[]: gone. Records are narrowed by a toTenantPost(entry: unknown): TenantPost | null helper that checks each field the builders touch, so the page iterates as unknown[] and the loop body handles a typed value. This also removed the duplicated field-checking that lived inline.

params: any in tests: gone, replaced by a FeedParams interface for what the walk sends and a FeedRecord interface for what the mocks answer.

Two more regression tests came out of the Codex round on the same head: one drives an inclusive-cursor node and asserts the walk still reaches exactly 100 unique posts (it returns 99 without the reserved slot), the other spends 9s per page against a stubbed clock and asserts the fifth page is never started and the final call is bounded by the 3s left rather than the full 10s.

455 tests, typecheck clean, and re-verified against the live chain after each change: a blog and a community tenant each collect 100 unique posts, a 102-URL sitemap and a 100-item feed.

@feruzm
feruzm merged commit 33e2760 into develop Aug 12, 2026
9 checks passed
@feruzm
feruzm deleted the bugfix/seo-bridge-limit branch August 12, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant