|
| 1 | +--- |
| 2 | +title: Wake-triggered feed auto-refresh must reuse the canonical ingestion pipeline and durable success state |
| 3 | +date: 2026-04-20 |
| 4 | +category: integration-issues |
| 5 | +module: feed auto refresh |
| 6 | +problem_type: integration_issue |
| 7 | +component: nest_service |
| 8 | +symptoms: |
| 9 | + - a same-process wake after sleep could leave feed data stale because `INGEST_ON_BOOT` only covered cold startup |
| 10 | + - the API had no durable global timestamp for the last successful wake-driven refresh, so elapsed-interval gating had nowhere authoritative to read from |
| 11 | + - any wake-refresh implementation that bypassed `FeedIngestionService` risked drifting away from the existing article-content and summary pipeline |
| 12 | +root_cause: missing_workflow_step |
| 13 | +resolution_type: code_fix |
| 14 | +severity: medium |
| 15 | +related_components: |
| 16 | + - background_job |
| 17 | + - typeorm_repository |
| 18 | + - typeorm_migration |
| 19 | +tags: |
| 20 | + [ |
| 21 | + feed-auto-refresh, |
| 22 | + wake-resume, |
| 23 | + feed-ingestion, |
| 24 | + bootstrap, |
| 25 | + prisma, |
| 26 | + summary-pipeline, |
| 27 | + nestjs, |
| 28 | + ] |
| 29 | +--- |
| 30 | + |
| 31 | +# Wake-triggered feed auto-refresh must reuse the canonical ingestion pipeline and durable success state |
| 32 | + |
| 33 | +## Problem |
| 34 | + |
| 35 | +`apps/api` could ingest feeds during bootstrap, but it still behaved like a startup-only path. When the same Node process resumed after machine sleep or runtime freeze, feeds could stay stale even though the app was alive again, and there was no durable global success timestamp to decide whether a background catch-up refresh was overdue. |
| 36 | + |
| 37 | +The fix had to stay non-blocking for HTTP startup, preserve the existing meaning of `INGEST_ON_BOOT`, and keep newly discovered articles flowing into the existing article-content and LLM summary chain instead of inventing a second ingestion path. |
| 38 | + |
| 39 | +## Symptoms |
| 40 | + |
| 41 | +- Same-process wake/resume had no dedicated refresh trigger, so freshness depended on a later cold restart or manual intervention. |
| 42 | +- There was no single persisted `lastSuccessfulAutoRefreshAt` source of truth for interval checks; storing it on `Feed` rows would have turned a global decision into ambiguous per-feed state. |
| 43 | +- The existing ingestion path already owned persistence, Markdown enrichment, and summary scheduling, so a parallel wake-refresh loop would have been likely to diverge from the main contract immediately. |
| 44 | + |
| 45 | +## What Didn't Work |
| 46 | + |
| 47 | +- Reusing `INGEST_ON_BOOT` as the wake-refresh switch would have mixed two different semantics: unconditional bootstrap ingestion versus elapsed-interval refresh after same-process resume. |
| 48 | +- Putting wake-refresh eligibility on request paths was rejected because the requirements explicitly kept feed/network work out of read traffic. |
| 49 | +- Treating the last successful wake refresh as per-feed state did not fit the product rule. This slice needed one global timestamp, not a fan-out of duplicated values on `Feed`. |
| 50 | + |
| 51 | +## Solution |
| 52 | + |
| 53 | +The implementation landed three linked changes inside `apps/api`. |
| 54 | + |
| 55 | +1. Add a dedicated durable singleton state model plus a defaulted interval config: |
| 56 | + |
| 57 | +```prisma |
| 58 | +model FeedAutoRefreshState { |
| 59 | + id String @id |
| 60 | + lastSuccessfulAutoRefreshAt DateTime? |
| 61 | + createdAt DateTime @default(now()) |
| 62 | + updatedAt DateTime @updatedAt |
| 63 | +} |
| 64 | +``` |
| 65 | + |
| 66 | +```ts |
| 67 | +const DEFAULT_FEED_AUTO_REFRESH_INTERVAL_HOURS = 6; |
| 68 | + |
| 69 | +return { |
| 70 | + feedAutoRefreshIntervalHours: |
| 71 | + validated.FEED_AUTO_REFRESH_INTERVAL_HOURS ?? |
| 72 | + DEFAULT_FEED_AUTO_REFRESH_INTERVAL_HOURS, |
| 73 | + // ... |
| 74 | +}; |
| 75 | +``` |
| 76 | + |
| 77 | +2. Extend `FeedIngestionService` so wake-driven callers can reuse the canonical pipeline, pass trigger metadata, and receive a structured run result: |
| 78 | + |
| 79 | +```ts |
| 80 | +async ingestFromOpml( |
| 81 | + opmlPath: string, |
| 82 | + options: FeedIngestionOptions = {}, |
| 83 | +): Promise<FeedIngestionRunResult> { |
| 84 | + const trigger = options.trigger ?? "bootstrap"; |
| 85 | + const maxAttemptsPerFeed = Math.max(1, options.maxAttemptsPerFeed ?? 1); |
| 86 | + // ... |
| 87 | + |
| 88 | + return { |
| 89 | + failedCount, |
| 90 | + status: summaryStatus, |
| 91 | + successCount, |
| 92 | + totalFeeds: subscriptions.length, |
| 93 | + trigger, |
| 94 | + }; |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +3. Add a wake-only orchestration layer that detects a same-process resume gap, skips duplicate in-flight runs, gates by the durable interval, and only advances the success timestamp after a run finishes with at least one successful feed: |
| 99 | + |
| 100 | +```ts |
| 101 | +async checkHeartbeat(nowMs: number = Date.now()) { |
| 102 | + const gapMs = nowMs - this.lastHeartbeatAt; |
| 103 | + this.lastHeartbeatAt = nowMs; |
| 104 | + |
| 105 | + if (gapMs < RESUME_GAP_THRESHOLD_MS) { |
| 106 | + return; |
| 107 | + } |
| 108 | + |
| 109 | + await this.handleWakeResume(nowMs, gapMs); |
| 110 | +} |
| 111 | + |
| 112 | +private async handleWakeResume(nowMs: number, gapMs: number) { |
| 113 | + if (this.isRefreshRunning) { |
| 114 | + return; |
| 115 | + } |
| 116 | + |
| 117 | + const lastSuccessfulAutoRefreshAt = |
| 118 | + await this.repository.getLastSuccessfulAutoRefreshAt(); |
| 119 | + |
| 120 | + if ( |
| 121 | + lastSuccessfulAutoRefreshAt && |
| 122 | + nowMs - lastSuccessfulAutoRefreshAt.getTime() < intervalMs |
| 123 | + ) { |
| 124 | + return; |
| 125 | + } |
| 126 | + |
| 127 | + const result = await this.feedIngestionService.ingestFromOpml( |
| 128 | + config.feedOpmlPath, |
| 129 | + { |
| 130 | + maxAttemptsPerFeed: 3, |
| 131 | + trigger: "auto_refresh_resume", |
| 132 | + }, |
| 133 | + ); |
| 134 | + |
| 135 | + if (result.successCount > 0) { |
| 136 | + await this.repository.markSuccessfulAutoRefresh(new Date(nowMs)); |
| 137 | + } |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +Regression protection landed at the same time: |
| 142 | + |
| 143 | +1. `apps/api/src/config/app-config.spec.ts` locks the default and validation rules for `FEED_AUTO_REFRESH_INTERVAL_HOURS`. |
| 144 | +2. `apps/api/src/feeds/feed-ingestion.service.spec.ts` locks structured run results plus bounded retry behavior. |
| 145 | +3. `apps/api/src/feeds/feed-auto-refresh.service.spec.ts` covers first-run, stale-state, fresh-state, in-flight, and full-failure timestamp semantics. |
| 146 | +4. `apps/api/e2e/feed-auto-refresh.e2e-spec.ts` proves that wake-triggered refresh still writes through the canonical article-content path. |
| 147 | +5. `apps/api/e2e/prisma-schema.e2e-spec.ts` proves the singleton table exists without polluting `Feed` rows. |
| 148 | + |
| 149 | +## Why This Works |
| 150 | + |
| 151 | +The key was to separate orchestration from ingestion ownership instead of forking the pipeline. |
| 152 | + |
| 153 | +- `FeedAutoRefreshService` owns only wake detection, interval gating, and overlap prevention. |
| 154 | +- `FeedIngestionService` remains the single owner of feed fetch, persistence, Markdown enrichment, and summary scheduling. |
| 155 | +- `FeedAutoRefreshState` keeps the success timestamp global and durable, which matches the product rule and survives process restarts. |
| 156 | +- The returned run result lets the wake layer distinguish `all_success`, `partial_success`, and `full_failure` without reinterpreting ingestion logs. |
| 157 | +- Because the wake path calls the same ingestion backbone, new articles still enter the same downstream article-content and summary contracts as bootstrap-ingested articles. |
| 158 | + |
| 159 | +## Prevention |
| 160 | + |
| 161 | +- Keep bootstrap and wake semantics explicit. New callers should pass a `trigger` instead of overloading `INGEST_ON_BOOT`. |
| 162 | +- Preserve the singleton-state boundary in tests: schema coverage should keep proving that wake-refresh state lives outside `Feed`. |
| 163 | +- Keep both unit and e2e coverage around interval gating, in-flight dedupe, and canonical pipeline reuse; those are the fragile contracts of this slice. |
| 164 | +- When adding retry logic to ingestion callers, keep the retry budget and retryability decision close to `FeedIngestionService` so orchestration layers do not drift into custom fetch loops. |
| 165 | +- The final review handoff at `.claude/handoffs/2026-04-20-163932-ce-review-feed-auto-refresh.md` recorded follow-up concerns, but this branch intentionally shipped without additional code changes after that pass; future readers should treat it as review context, not as part of the implemented fix. |
| 166 | + |
| 167 | +## Related Issues |
| 168 | + |
| 169 | +- Moderate overlap: `docs/en/solutions/integration-issues/feed-ingestion-retries-missing-article-markdown-2026-04-17.md` + `docs/zh-Hans/solutions/integration-issues/feed-ingestion-retries-missing-article-markdown-2026-04-17.md` - same feed-ingestion recovery area and same canonical-pipeline principle, but a different root cause and a different trigger surface |
| 170 | +- `apps/api/src/feeds/feed-auto-refresh.service.ts` |
| 171 | +- `apps/api/src/feeds/feed-auto-refresh.repository.ts` |
| 172 | +- `apps/api/src/feeds/feed-ingestion.service.ts` |
| 173 | +- `apps/api/src/feeds/feed-ingestion.types.ts` |
| 174 | +- `apps/api/prisma/models/feed-auto-refresh-state.prisma` |
| 175 | +- `apps/api/prisma/migrations/202604200001_add_feed_auto_refresh_state/migration.sql` |
| 176 | +- `apps/api/e2e/feed-auto-refresh.e2e-spec.ts` |
| 177 | +- `apps/api/e2e/prisma-schema.e2e-spec.ts` |
| 178 | +- `apps/api/README.md` |
| 179 | +- `docs/en/brainstorms/2026-04-20-feed-auto-refresh-on-wake-requirements.md` |
| 180 | +- `docs/zh-Hans/brainstorms/2026-04-20-feed-auto-refresh-on-wake-requirements.md` |
| 181 | +- `docs/en/plans/2026-04-20-001-feat-feed-auto-refresh-on-wake-plan.md` |
| 182 | +- `docs/zh-Hans/plans/2026-04-20-001-feat-feed-auto-refresh-on-wake-plan.md` |
| 183 | +- `.claude/handoffs/2026-04-20-155612-feed-auto-refresh-on-wake.md` |
| 184 | +- `.claude/handoffs/2026-04-20-163932-ce-review-feed-auto-refresh.md` |
| 185 | +- GitHub issue search: no matching issues found via `gh issue list --search "feed auto refresh wake resume ingestion" --state all --limit 5` |
0 commit comments