Skip to content

feat: incremental catalog ingestion on the namespace model (PR #140 rebuilt) - #761

Draft
InduwaraSMPN wants to merge 7 commits into
openchoreo:mainfrom
InduwaraSMPN:pr-b/incremental-ingestion
Draft

feat: incremental catalog ingestion on the namespace model (PR #140 rebuilt)#761
InduwaraSMPN wants to merge 7 commits into
openchoreo:mainfrom
InduwaraSMPN:pr-b/incremental-ingestion

Conversation

@InduwaraSMPN

Copy link
Copy Markdown

The problem

The scheduled OpenChoreoEntityProvider ingests the entire platform in one
run() and emits a single full mutation. For large tenants that means a
long, memory-heavy sync every 300 seconds, and any interruption restarts
from zero. The original PR #140 built a cursor-resumable, burst-based
ingestion package to fix this, but it targeted the organization-era API and
a continue/hasMore pagination scheme the server never implemented.

Why this is still wanted given EventDeltaApplier

Upstream's event-driven delta path handles real-time freshness; the periodic
full sync is now its safety net. What remains uncovered is cold-start
ingestion of large tenants
and recovery when the event stream is lossy:
both need a resumable walk that survives restarts, persists progress in the
database, and ingests in bounded bursts. That is exactly this package.
(Maintainer confirmation requested on the PR thread — see open question.)

What this change does

  • New package @openchoreo/backstage-plugin-catalog-backend-module-openchoreo-incremental
    (33→~40 files, ~7,000 lines incl. 124 tests at 90% line coverage).
  • Two-level namespace walk (namespaces → projects) plus a flat
    namespace-scoped component fetch — components are no longer nested under
    projects, so the original three-level organization/project/component
    cursor collapsed into a redesigned state machine. One page per next()
    call; the engine persists the cursor between bursts.
  • Entity translation imports the sibling plugin's exported translators —
    the original 295-line local entityTranslator and 244-line
    ApiErrorHandler are deleted, superseded by upstream exports.
  • Ingestion state in three tables (ingestions, ingestion_marks,
    ingestion_mark_entities) behind knex migrations that run lazily on the
    plugin database; migrate.latest + rollback proven on SQLite and the
    migration set is idempotent under concurrent init.
  • Config-gated off by default via openchoreo.features.incrementalIngestion.enabled
    (declared once, in openchoreo-common's config schema). When enabled, the
    scheduled provider in the sibling module stands down with an explicit log
    line — no double ingestion. The feature loader has no conditional hook,
    so the gate lives inside both modules' init.
  • Page size capped at the documented LimitParam maximum of 100 (the
    original PR's 512 was never valid against the real API).

Stack

  1. PR-A (pr-a/fetch-all-pages-hardening): hardens
    fetchAllPages with a timeout budget, abort support, a stuck-cursor
    guard, a maxPages cap and malformed-page errors. This PR-B branch is
    based on it.
  2. PR-A2 (pr-a2/fetch-all-pages-misuse-fixes, optional): fixes three
    cursor-ignoring fetchAllPages closures in the scheduled provider.
    (The four limit: 1000 sites named in the analysis were verified
    against the observability spec — its query API documents
    maximum: 1000, so they are correct and untouched.)
  3. This PR.

What was intentionally dropped from the original PR #140

  • The hand-edited OpenAPI spec and generated types (vendored from the API
    server repo; CI's freshness gate fails any hand-edit).
  • The continue/hasMore pagination helper and DEFAULT_PAGE_LIMIT = 512
    (superseded by upstream's cursor protocol and invalid page size).
  • All orgs/-based call sites (the namespace migration rewrote them), the
    410 continue-token-expiry machinery (the API defines no cursor expiry),
    backstage-diff.md, and the fork's commented-out app-config blocks.

Migration and rollback story

Three timestamped knex migrations under migrations/ (the init migration is
byte-compatible with Backstage's own incremental-ingestion module, Apache
header and provenance retained). down restores the pre-module state on
both SQLite and Postgres. Disabling the feature flag stops the engine; the
tables persist harmlessly and can be dropped with the documented rollback.

Reviewing

One commit per milestone: scaffold → provider rewrite → database layer →
portal wiring → tests. CODEOWNERS reviewers cc'd. Happy to split along
those boundaries if preferred.

Induwara added 7 commits August 23, 2026 07:21
…r guards

fetchAllPages gains an optional options bag without changing behaviour for
existing single-argument callers:

- timeoutMs: wall-clock budget for the entire run. Defaults to 60s;
  0 disables the budget. One deadline, one setTimeout raced per page,
  cleared in finally.
- signal: AbortSignal cancellation, checked at entry and between pages;
  the once-listener is removed in finally so it never leaks.
- maxPages: opt-in hard cap on pages fetched. Exceeding it throws with
  the collected item count - it never silently truncates, since a
  truncated catalog sync looks like a successful one.
- Malformed-page guards: a null/undefined page or a page without an
  items array throws naming the page index and cursor instead of
  surfacing as a bare TypeError.
- Stuck-cursor guard: a server echoing the request cursor back as
  nextCursor throws instead of looping forever. One guard only.
- nextCursor of null or empty string is treated as terminal, matching
  undefined.

Also exports PaginatedResponse and FetchAllPagesOptions from the package
barrel. Statement coverage of pagination-utils.ts is 100%; the seven
pre-existing tests pass unmodified.

Signed-off-by: Induwara <induwara@induwara.com>
Three fetchAllPages closures in OpenChoreoEntityProvider (workflowplanes,
observabilityplanes, deploymentpipelines) were written as () => instead of
cursor => and omitted the limit query parameter, so those resource types
silently ingested only the first server-default page per namespace.
Forward the cursor and pass limit: 100 like every other list call in the
provider.

Note: the four limit: 1000 sites in ci-backend/workflows-backend/
observability POST bodies are NOT changed - the observability query API
documents maximum: 1000 for its limit, unlike the OpenChoreo API's
cursor-paginated list endpoints which cap at 100.

Signed-off-by: Induwara <induwara@induwara.com>
Skeleton of the rebuilt incremental ingestion package on current upstream
dependencies (Backstage 1.51 era). Package renamed to
@openchoreo/backstage-plugin-catalog-backend-module-openchoreo-incremental
matching every sibling; manifest carries repository + configSchema, knex
pinned exactly at 3.1.0 like the catalog module, backend-defaults moved to
devDependencies, and the unused catalog-backend / permission-common deps
dropped.

Layout fixes carried in from the analysis: no src/config.d.ts (stale zod
shadow), no src/database/migrations (duplicate .ts twins never resolved by
the migration runner); migrations/ holds only the .js files that
resolvePackagePath actually loads, with a provenance note on the init
migration copied from @backstage/plugin-catalog-backend-module-incremental-ingestion.

types.ts and config.ts are ported (chunkSize capped at the API's real
LimitParam maximum of 100); the provider/engine/database/router internals
are compiling stubs throwing 'M3' and are implemented by the following
commits. Registered nowhere yet - nothing can break at runtime.

Signed-off-by: Induwara <induwara@induwara.com>
The provider's three-level organization/project/component cursor collapses
to a two-level namespace walk plus a flat namespace-scoped component fetch,
matching the API after components were de-nested from projects. One page
per next() call is intentional: the engine persists the cursor between
bursts, so fetchAllPages is deliberately not used here.

- Phases: namespaces -> projects -> components; done:true only after every
  namespace's components are ingested. Stuck-cursor guard throws (engine
  backs off) - the API defines no cursor expiry, so all of the PR's 410
  continue-token-expiry machinery is gone.
- chunkSize clamped to the documented LimitParam maximum of 100.
- Entity translation now imports the sibling plugin's exported translators
  (translateNamespaceToDomainEntity, translateProjectToEntity,
  translateComponentToEntity) instead of shipping a local entityTranslator;
  ComponentTypeUtils comes from backstage-plugin-common via fromConfig().
- ComponentBatchProcessor shrinks to sequential translation; API-entity
  derivation is deferred until the sibling exports its workload-endpoint
  helpers.
- Engine and WrapperProviders ported verbatim (cursor-agnostic); router
  ported with two corpus compile bugs fixed (undefined provider params).
- Database manager remains a typed stub; M4 ports it.

Greps clean: no orgs/, orgName, organization, fetchAllResources,
metadata.hasMore/continue, DEFAULT_PAGE_LIMIT, ResponseMetadata, or
hardcoded default namespace anywhere in the package.

Signed-off-by: Induwara <induwara@induwara.com>
The 1,291-line database manager ports from the original work essentially
verbatim, minus one deletion: computeRemoved no longer queries
refresh_state/refresh_keys, tables owned by @backstage/plugin-catalog-backend
- a cross-plugin schema-ownership violation that would also fail at runtime
against this module's schema.

The orphaned performance-index migration is reissued as
migrations/20240110000001_add_performance_indexes.js, in the one directory
the migration runner actually resolves. It indexes only this module's own
tables (ingestions, ingestion_marks, ingestion_mark_entities), branches on
dialect - CREATE INDEX CONCURRENTLY with transaction:false on Postgres,
plain CREATE INDEX IF NOT EXISTS elsewhere - and mirrors down cleanly.

Concurrent initialization is now idempotent: WrapperProviders hoists the
migration run to a module-level shared promise (the same pattern the
sibling module uses for its AnnotationStore), so parallel instances await
a single migration run.

The database test suite migrates the real migrations directory on
in-memory SQLite and covers the ingestion record lifecycle, mark/entity
cascade delete, last_error truncation at the expanded width,
resume-from-cursor round-trip, double-migration idempotence, parallel
WrapperProviders init, and migrate.latest-then-rollback.

Signed-off-by: Induwara <induwara@induwara.com>
The incremental module joins the portal composition as one lazy thunk,
placed after @openchoreo/backstage-plugin-catalog-backend-module and
before @openchoreo/backstage-plugin-backend per the documented ordering
constraint, and yields wrapper-first so the extension point registers
before its consumer.

Config-gating is deliberately two-sided, since the feature loader has no
conditional hook: the incremental module's init goes idle unless
openchoreo.features.incrementalIngestion.enabled is true (default false -
no router, no providers, no migrations), and the sibling catalog module
stands the scheduled OpenChoreoEntityProvider down with an explicit log
line when the flag is on, so entities are never double-ingested.

The feature flag is declared once, in openchoreo-common's config.d.ts
alongside the six existing flags - a second declaration of the
openchoreo.features path would collide at build time; the incremental
package's own config.d.ts declares only openchoreo.incremental tuning
keys. app-config.yaml/.production/.local.example carry real keys:
flag via OPENCHOREO_FEATURES_INCREMENTAL_INGESTION_ENABLED, tuning
defaults burstLength 16s / burstInterval 8s / chunkSize 100 /
restLength 60m. README documents enable/disable.

Signed-off-by: Induwara <induwara@induwara.com>
124 tests across 11 suites, 90% line coverage, 13s runtime, no CI
conditionals, no skipped suites.

The core of the suite is a cursor-following client mock (src/testUtils/
mockApi.ts) that walks page chains keyed by nextCursor - the exact
multi-page behaviour this package exists for, which the repo's
createMockFetchAllPages helper cannot express (it fetches one page and
never follows the cursor). The provider suite drives all three phases
through >=3-page chains, asserts resume-after-restart by round-tripping
the serialized cursor into a fresh provider instance, and pins the
stuck-cursor and chunkSize-clamp behaviour. The engine suite covers the
rest/ingest/backoff/cancel state machine, burst-length expiry, delta
mutations, and the removal-rejection thresholds. The database suite now
exercises every manager method's happy path plus representative failure
paths on real migrations. WrapperProviders, both catalog modules (via
startTestBackend, proving the idle-when-disabled gate), config schemas,
validator, tables, errors, and the admin router are covered.

Testing surfaced two latent bugs in ConfigValidator, fixed here: its
error formatting read zod v3's zodError.errors, which no longer exists
in zod 4 (every message degraded to 'Unknown validation error'), and
business-rule failures were re-wrapped as CONFIG_VALIDATION_ERROR,
swallowing their INVALID_BURST_TIMING / INVALID_API_BASE_URL codes.

Signed-off-by: Induwara <induwara@induwara.com>
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fc910509-509b-49a1-badb-b67d7bc87b05

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

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