EUREKA-847 Spike: Kafka Formalization Gaps
- 1 Overview
- 2 Background
- 3 Scope
- 4 TL;DR
- 5 Question #1: How should sidecars declare their Kafka interactions?
- 6 Question #2: How should manager services declare their Kafka interactions?
- 6.1 Option 1: Add kafkaTopics Directly to Each Manager Service's Module Descriptor, No Wrapping
- 6.2 Option 2: Wrap Manager Service Module Descriptors in a platform-Type Application (Recommended)
- 6.2.1 2.1 Application types (entitlement cardinality)
- 6.2.2 2.2 Bootstrap ordering
- 6.2.3 2.3 Alternative considered and dismissed: route mgr-* traffic through the API gateway
- 6.2.4 2.4 Alternative considered and dismissed: extend whatever already consumes permissionSets
- 6.2.5 2.5 Platform vs Tenant-scoped entitlement collisions
- 7 Low-Level Design / Implementation Details
- 7.1 Option 1: Declare topics in the Module Descriptor
- 7.1.1 producerKafkaTopics
- 7.1.2 consumerKafkaTopics
- 7.1.3 Design rationale
- 7.1.3.1 Why two fields instead of one with a ‘role’ flag
- 7.1.3.2 ‘topic’, not ‘name’ or ‘pattern’
- 7.1.3.3 'version' format
- 7.1.3.4 Why no ‘optional’ on ‘producerKafkaTopics’
- 7.1.3.5 Why ‘optional’ is a plain boolean
- 7.1.3.6 Fields dropped rather than carried forward
- 7.1.3.7 Compatibility matching: shape-only
- 7.1.3.8 Enumerate shapes, don't embed pattern syntax
- 7.1.3.9 More sophisticated checks — considered & rejected
- 7.1.4 Examples
- 7.2 Option 2: Declare events in the Module Descriptor
- 7.2.1 producesEvents
- 7.2.2 consumesEvents
- 7.2.3 Design Rationale
- 7.2.4 Examples
- 7.1 Option 1: Declare topics in the Module Descriptor
- 8 Implementation Plan (WIP)
- 8.1 Workstream 1 — Shared descriptor schema (applications-poc-tools/folio-backend-common)
- 8.2 Workstream 2 — Sidecar event declaration
- 8.3 Workstream 3 — Discovery exemption fix
- 8.4 Workstream 4 — Manager service event declaration
- 8.5 Workstream 5 — platform application scope + entitlement mechanism
- 8.6 Workstream 6 — Event dependency validation
- 8.7 Explicitly out of scope
- 8.8 Future scope
- 8.9 Conclusion
- 8.10 Next Steps
- 8.11 Open Issues/Considerations
- 8.12 Appendix: More on Virtual Modules
- 8.13 Appendix: Operational Workflows
- 8.13.1 A.1 Fresh Start
- 8.13.2 A.2 Upgrade: Regular Application (No Platform Applications Changed)
- 8.13.3 A.3 Upgrade: Sidecar Application (No Platform Applications Changed)
- 8.13.4 A.4 Upgrade: Platform Application, Simple Case (e.g. only folio-keycloak)
- 8.13.5 A.5 Upgrade: Platform Application, Coupled Case (e.g. Keycloak Requiring Sidecar/mgr-*/Module Changes)
- 8.13.6 A.6 Upgrade: A Manager Service (mgr-*)
Overview
https://folio-org.atlassian.net/browse/EUREKA-847
Objective: Recommend approaches for the gaps identified so far in the Kafka formalization design, structured per-question so additional gaps discovered can be incorporated as new sections without needing to rework the document.
Background
The Kafka formalization design proposes that modules declare their Kafka producer/consumer usage in their module descriptors. Two gaps have been identified so far where that mechanism doesn't obviously apply: sidecars, which have no module descriptor at all, and manager services, which have module descriptors but aren't wrapped by any application descriptor that the entitlement/dependency-checking system would examine.
Reference Material: Kafka Topics Management Infrastructure Module - Design Document
Scope
In scope:
How sidecars should declare their Kafka interactions
How manager services should declare their Kafka interactions
Registration/validation mechanics for both
Out of scope:
Central topic management (health-check timing relative to "topics ready," whether notification failure fails entitlement, HA visibility into all instances of a service) — tracked in https://folio-org.atlassian.net/browse/EUREKA-848.
Fully specifying
platform-type application enforcement logic in mgr-tenant-entitlements (introduced below, not delivered by this spike).Mgr-tenant-entitlements (MTE) processing logic for non-sidecar virtual-module content kinds (permissions-only, data-bearing) — introduced below for completeness, not delivered by this spike.
Actually wrapping
mgr-*manager service module descriptors in a platform application descriptor (Question #2) — proposed here, implementation deferred as follow-on work.
TL;DR
Give the sidecar its own module descriptor (requires + kafkaTopics only). Introduce a new virtual application for the sidecar (e.g. app-folio-sidecar) with dependencies declared, entitled per tenant like any regular application. For the three manager services, bundle their module descriptors into a new app-mgr-services application with a new platform application type, reserved for genuine deployment-wide singletons (Keycloak, Kong, the Kafka broker, and now manager services), and auto-checked against every tenant's dependencies with no explicit per-tenant entitlement requirement. Getting there requires a new mechanism for entitlement of applications with type=platform, as well as softening the requirement that discovery info is required for every application.
Question #1: How should sidecars declare their Kafka interactions?
Sidecars presently consume events from several topics:
{{env}}.discovery(frommgr-applications) - Updates/changes to discovery information{{env}}.entitlement(frommgr-tenant-entitlements) - Updates/changes to tenant entitlements{{env}}.{{tenant}}.mod-login-keycloak.logout(frommod-login-keycloak) - Logout events informing the sidecar to invalidate AuthN cache entries for the associated session.
Since the sidecars do not have their own module descriptor, this presents a gap in the Kafka Topics Management Infrastructure Module - Design Document.
Option 1: Declare Sidecar Kafka Usage in the Application Descriptor
Each application whose module(s) rely on the sidecar declares the sidecar's fixed set of Kafka topics directly in its own application descriptor.
Pros
No new descriptor type or registration path — reuses the application descriptor mechanism as-is.
Cons
A sidecar's Kafka needs don't vary by which application its module belongs to, so every application descriptor would redeclare the same fixed set of topics — pure duplication.
Duplicated copies can drift out of sync with each other and with reality.
Conflates "what does this application consist of" with "what does the platform's sidecar infrastructure need" — two unrelated concerns.
Option 2: One Shared Descriptor for All Sidecars in a Deployment
Assume sidecars are homogeneous within a given deployment and declare their Kafka interactions once, in a single descriptor scoped to the deployment rather than to any particular application or module.
Pros
Simple: one artifact per deployment, no per-application duplication.
Cons
Ties the declaration to the deployment rather than to the sidecar software version — the thing that actually changes when declared Kafka interactions change.
Assumes homogeneity that conflicts with the platform's direction of supporting multiple releases concurrently in one deployment, where different tenants may need different sidecar versions running side by side.
Not viable if/when heterogeneous sidecar versions become necessary.
Option 3: Sidecar-Specific Module Descriptor + Virtual-Application (Recommended)
Give the sidecar its own module descriptor — shaped similar to an edge module descriptor (exists purely for dependency checking, not for declaring interfaces/routes or permissions/capabilities) — and register it the same way any module dependency is registered, via a wrapping application descriptor.
3.1 Sidecar descriptor
requires: the interfaces ofmgr-tenant-entitlements,mgr-applications, etc. that the sidecar depends on.kafkaTopics: consumer-only entries for the three topics identified above, using the schema already proposed in the design doc. No producer role is needed today, but the schema shouldn't preclude one later.No
provides, nopermissionSets— the sidecar doesn’t provide any interfaces, handlers, or permissions/capabilities.Versioned per sidecar release.
This approach reuses the existing module descriptor schema. There is no need for a new descriptor type or validation logic.
3.2 Registration via Virtual-application
The sidecar module descriptor is wrapped in a virtual-application descriptor — an application descriptor providing no real modules, existing solely to carry a dependency declaration (or other non-runtime content, see Virtual Module Contents/Purposes). The idea of a virtual-application is not new; it’s been a topic of discussion several times by the architecture team, and have even been mentioned to broader audiences (e.g. WOLFcon) as something we may introduce in the future.
Real applications (e.g. app-acquisitions, etc.) would declare a normal application-level dependency on the sidecar virtual-application. The existing application-level dependency validation, performed by the tenant entitlement manager would not require changes to support this.
3.3 Discovery exemption for virtual-modules
If a module descriptor declares an empty provides section, discovery information should not be required for entitlement to succeed. This is considered safe because there’s no need to create routes in the API gateway for modules which don’t provide an HTTP interface. Presently discovery information is required for all backend modules (yes, even edge modules - here it looks like we’re registering bogus discovery info to get around the check). This will need to change, and actually makes for a cleaner implementation anyways.
3.4 Sidecar version consistency (bootstrap self-reporting) - Optional
Operators are responsible for keeping descriptor, discovery, and container image versions in sync today; runtimes don't self-report. Sidecars already check in with manager services during bootstrap (They load tenant/entitlement data from manager services on startup with a periodic resync, default every 5 minutes). That said, it seems reasonable to have the sidecars include their actual running version in these requests for the purpose of validating expected vs actual versions (reported version vs. version implied by the tenant's entitled app-sidecar dependency). While not a strict requirement, this should be considered. If we choose to do this, it probably makes sense to make it feature flagged, defaulting to permissive/warn in the logs, with a stricter option (e.g. fail bootstrap) available to operators.
Pros
Reuses the existing module descriptor schema and validation engine (
requires/provideschecking), with direct precedent in edge modules — no new validation required.One descriptor per sidecar version, referenced by dependency rather than duplicated — less maintenance overhead compared to some other options
Compatible with the multi-release-per-deployment direction.
Generalizes cleanly: the same virtual-application/virtual-module mechanism also covers unrelated known use cases (settings-permissions, reference-data) without new machinery. See Virtual Module Contents/Purposes
Minimal (no?) new MTE enforcement logic required for the sidecar case itself — dependency checking of application dependencies is unchanged from today.
Cons
Depends on virtual-applications, a concept that isn't formally specified yet.
Several mechanics still need verification against the real codebase before this is final (see Open Issues).
Question #2: How should manager services declare their Kafka interactions?
Option 1: Add kafkaTopics Directly to Each Manager Service's Module Descriptor, No Wrapping
Manager services (mgr-tenant-entitlements, mgr-applications, mgr-tenants) emit Kafka events too — e.g. mgr-tenant-entitlements publishes events for system user creation and capability/capabilitySet creation. Unlike sidecars, they do have real module descriptors which provide interfaces, but don’t require any! Furthermore, these module descriptors are not present in any application descriptor(s). That suggests inter-manager-service interactions sit outside dependency checking entirely today. Simply adding kafkaTopics to these descriptors would literally have effect at all.
Do nothing structurally new — just extend each mgr-* module descriptor with a kafkaTopics block.
Pros
Trivial to implement; no new registration or bootstrap concerns.
Cons
Given the empty
requiresand no presence in an application, nothing in the entitlement/dependency-checking pipeline currently examines these descriptors in a way that would validate or enforcekafkaTopicseither. This doesn't actually close the gap — it just adds an unchecked field next to other unchecked fields (requires).
Option 2: Wrap Manager Service Module Descriptors in a platform-Type Application (Recommended)
Bundle the three mgr-* module descriptors into a new app-mgr-services application descriptor, typed platform (introduced below), registered with mgr-applications. This allows the platform to perform the same dependency-checking (and, once added, kafkaTopics-checking) logic on manager services as it already performs on everything else.
2.1 Application types (entitlement cardinality)
Introduce a type field on application descriptors:
Type | Meaning | Cardinality |
|---|---|---|
| Behaves exactly as applications do today. No change to existing behavior. | Multiple versions can coexist across tenants in one deployment — needed for the multi-release-per-deployment support, and why Question #1's |
| Deployment-wide infrastructure (e.g. | Exactly one version may exist for the entire deployment at a time; MTE enforces this and automatically factors platform applications into dependency checks during entitlement. |
This is the only new taxonomy this spike proposes. app-sidecar (Question #1) doesn't need it — the default (unspecified/regular) behavior already suffices there. app-mgr-services is the motivating case for platform: manager services are genuinely deployment-wide singletons, unlike the sidecar, which must support multiple concurrent versions across tenants.
2.2 Bootstrap ordering
Registering app-mgr-services looks circular at first glance (mgr-applications registering a descriptor that describes itself), but isn't: registration is informational and doesn't gate mgr-applications' own startup. An operator or bootstrap script can call its existing registration API once the process is up.
To avoid depending on external automation for this step, mgr-applications could instead self-register app-mgr-services idempotently on its own startup, from a location given by an env var pointing to the location of the application descriptor.
2.3 Alternative considered and dismissed: route mgr-* traffic through the API gateway
Considered: Today manager service locations are hard-wired via environment variables. The idea of replacing this with standard this with a the API gateway's location, and let normal application + discovery registration populate gateway routes so they could reach each other like any other module.
Dismissed: API Gateway route population lives in mgr-tenant-entitlements, and the route expressions are tenant-scoped by design (to support the multi-release-per-deployment direction). Platform-scoped applications like app-mgr-services have no tenant context, so we’d have to be able to support a mix of both tenant-agnostic and tenant-aware routes, which introduces scope creep and complexities instead of simplifying things, which was the intent of this idea.
2.4 Alternative considered and dismissed: extend whatever already consumes permissionSets
Considered: mgr-tenant-entitlements' descriptor declares real permissionSets despite having no wrapping application, so something plausibly consumes it for permission-registration purposes (e.g. Keycloak role/permission setup) independently of the application/entitlement system — if so, that might be a simpler than the platform-application wrapping above.
Dismissed : No such independent mechanism exists. The manager services have module descriptors, but I don’t see evidence that these descriptor are ever registered, or used (at least not yet!)
2.5 Platform vs Tenant-scoped entitlement collisions
Because platform-scoped applications (e.g. app-mgr-services, app-keycloak) are automatically factored into every tenant's dependency resolution without an explicit per-tenant entitlement record, a tenant-scoped entitlement request can interfere with a platform-scoped entitlement that's in progress. Rather than blocking one side while the other completes (which would hold open a connection/thread across hops (load balancers, API gateways) with their own timeouts), collisions are resolved with a non-blocking exclusive lock: a platform-scoped entitlement takes an exclusive lock (e.g. a Postgres pg_try_advisory_lock or SELECT ... FOR UPDATE NOWAIT on a sentinel row) for the duration of its operation. Any tenant-scoped operation performs a non-blocking check before proceeding. On contention (the lock is taken), either side fails fast with 409 Conflict and a Retry-After header rather than waiting. Retrying is the caller's responsibility (expected to be automation in most cases). The MTE does not retry internally. Tenant-scoped operations never lock against each other, only against an in-progress platform-scoped operation. Since platform-scoped operations are typically brief (no tenant-API calls for ordinary deployment-wide infrastructure changes), the exclusive window is expected to be short. The one exception is the coupled upgrade scenario, which bundles real tenant-scoped application installs into the same call as the platform-scoped change and can hold the lock for longer than the simple case. For example app-keycloak + app-platform-minimal (due to required changes in mod-*-keycloak).
Pros
Registration is not actually circular, just needs a deliberate bootstrap step (explicit registration or possibly a self-loading mechanism).
Doesn't require any change to how
mgr-*services actually communicate at runtime.
Cons
Yet another application descriptor to maintain and register
Low-Level Design / Implementation Details
Option 1: Declare topics in the Module Descriptor
Modules declare their Kafka producer/consumer usage via two new ModuleDescriptor fields, producerKafkaTopics and consumerKafkaTopics. Real topic names embed placeholders — confirmed against three independent modules (folio-module-sidecar, mod-search, mod-data-export-spring) — most commonly {{env}} (a deployment-wide constant) and {{tenant}} (varies per tenant, often wildcard-subscribed via a real regex pattern at the messaging-library level). The schema captures these as opaque tokens; it does not attempt to resolve or understand them.
The two-field split (rather than one kafkaTopics field with a role discriminator described in the original proposal) mirrors the existing schema sections — InterfaceDescriptor.json (provides) and InterfaceReference.json (requires/optional). This allows for version cardinality to differ by role. (Note: mgr-applications' own moduleDescriptor.json is currently just a stub({"type": "object"}). The fully-fledged ModuleDescriptor schema hasn't been migrated from the OKAPI repository yet. That's a separate, issue only mentioned here for completeness/clarity.)
producerKafkaTopics
Field | Type | Description | Example value | Notes |
|---|---|---|---|---|
| string | Topic name, using |
| Tokens are opaque to the compatibility validator — it never resolves them to real values, only normalizes and compares shapes. See Compatibility matching. |
| string | Payload version — a single value, major.minor format only, no patch component |
| Matches |
There is no optional field on the producer side — see rationale section for details.
consumerKafkaTopics
Field | Type | Description | Example value | Notes |
|---|---|---|---|---|
| string | Topic name, same token convention as the producer side |
| If a topic can genuinely appear in more than one shape (e.g. with or without a tenant segment), declare one entry per shape — never embed alternation or optionality inside a single entry. See Enumerate shapes, don't embed pattern syntax. |
| string | One or more acceptable payload versions, space-delimited, major.minor format |
| Just like |
| boolean | If |
| Default |
Design rationale
Why two fields instead of one with a ‘role’ flag
provides/requires in the real Okapi schema aren't one shape with a role discriminator — they're two distinct types (InterfaceDescriptor, InterfaceReference) in two distinct arrays. That split is what lets Okapi give them different version cardinality (single vs. space-delimited multi-value) without conditional schema logic. producerKafkaTopics/consumerKafkaTopics mirrors this directly, for the same reason, and picks up the same benefit: each side's schema enforces its own rules without needing if/then validation (which isn't clean in JSON Schemas anyway).
A side effect: role disappears entirely. It's now implicit in which array an entry lives in, same as requires/provides never needed a role field.
‘topic’, not ‘name’ or ‘pattern’
Started as name, renamed to topic for specificity. pattern was considered and rejected: application.yml/application.properties often have a property called topic-pattern already, which holds escaped raw Java regex (e.g. (${folio.environment}\.)(.*\.)authorities\.authority). Naming our field "pattern" would invite authors to paste that regex in directly — exactly what the {{env}}/{{tenant}} token convention exists to avoid.
'version' format
String, not float or array. A float can't distinguish
"1.10"from"1.1"(both collapse to1.1). An array was considered but rejected in favor of consistency with how versions are specified elsewhere in the ModuleDescriptor schema.Major.minor only, no patch. The official Okapi schema documents both
InterfaceDescriptor.versionandInterfaceReference.versionas "major.minor format". However, it’s worth noting thatInterfaceComparisonUtils.versionPartstolerates an undocumented third patch segment. In other words the implementation is more permissive than the published contract. Only noted here for completeness. Major and minor should be sufficient.No event-schema mechanism. There is presently no way to declare the actual shape of the event payload —
versionis a convention-enforced promise (major = breaking, minor = compatible) with nothing to validate it against. That's a real limitation, but not a new one: API interface versioning has the exact same gap. Formalizing event-schema declaration is a future consideration, not part of this work.
Why no ‘optional’ on ‘producerKafkaTopics’
optional controls entitlement's failure mode when a consumer's dependency isn't satisfied. There's no equivalent "optional producing" concept — nothing about entitlement fails because a producer entry goes unmatched. Splitting the two arrays let this asymmetry fall out naturally instead of needing a field that's meaningless on one side.
Why ‘optional’ is a plain boolean
Checked every boolean field in the real Okapi schemas (RoutingEntry.delegateCORS, LaunchDescriptor.dockerPull, Permission.visible, EnvEntry.required) — none are nullable. A primitive boolean in the Java model can never actually hold null, so allowing it in the schema wouldn't reflect anything the real serialization could produce. Keeping it a plain two-state boolean means consuming code never needs a null-check or a defensive Boolean.TRUE.equals(x) idiom.
Fields dropped rather than carried forward
Some of the fields present in the original proposal are absent here (e.g. config, etc.). This is because they pertain to topic centralized topic management, which is out of scope for this document.
Compatibility matching: shape-only
The entitlement-time validator normalizes every topic field by replacing each {{...}} token with one canonical wildcard marker, then compares the normalized strings for equality. It never resolves {{env}} to the deployment's real value, never substitutes a real tenant, and never needs to understand what any token name means. This is deliberately the only thing the validator checks — see below for what's explicitly excluded.
Version compatibility is not exact-match. It reuses InterfaceComparisonUtils's existing semver-style rule (same major required; the consumer's declared version is satisfied by an equal-or-newer producer minor/patch) — the same logic already applied to requires/provides — rather than a Kafka-specific comparison.
Enumerate shapes, don't embed pattern syntax
Two real cases forced a decision here, both resolved the same way — one entry per literal/pattern shape, never alternation or optionality embedded inside a single entry:
mod-searchsubscribes toinventory.(instance|holdings-record|item|bound-with)via one regex with alternation (application.yml,eventslistener) → modeled as four separateconsumerKafkaTopicsentries.mod-data-export-springsubscribes via${ENV:folio}.(.*\.)?data-export.job.update— a genuinely optional tenant segment → modeled as two separate entries ({{env}}.{{tenant}}.data-export.job.updateand{{env}}.data-export.job.update), rather than inventing a{{token?}}optional-marker syntax.
This keeps the shape-comparison mechanism simple: it only ever parses opaque {{...}} tokens, never alternation or optionality syntax. The downside is verbosity, but the tradeoff seems worth it.
More sophisticated checks — considered & rejected
Introducing checks which go beyond the simple shape comparison were considered. The mgr-tenant-entitlements knows for instance what {{env}} is, and can check {{tenant}} against the target tenant in the entitlement call. However, this was rejected as there’s a discrepancy - the topics are declared in static module descriptors. Performing these more sophisticated checks during entitlement requires deployment/runtime configuration information. Not only does the entitlement manager lack the full picture, but runtime configuration can change at any time, including after entitlement. To illustrate the point, let’s say mod-foo has a feature flag which controls whether it publishes to {{env}}.{{tenant}}.foo or {{env}}.foo. The module knows about that feature flag, but mgr-tenant-entitlements doesn’t. Even if it did, it wouldn’t have any awareness of the flag being changed after entitlement.
Examples
Option 2: Declare events in the Module Descriptor
Instead of declaring topics, declare events instead. This avoids the problem of placeholders/tokens, and puts us on a path that’s more closely aligned with longer term goals of tighter, more strict enforcement of async/event contracts.
producesEvents
Field | Type | Description | Example value | Notes |
|---|---|---|---|---|
| string | An event name, comprised of multiple segments, separated by |
| Required |
consumesEvents
Field | Type | Description | Example value | Notes |
|---|---|---|---|---|
| string | An event name, comprised of multiple segments, separated by |
| Required |
| boolean | When true, indicates that a consumer can continue to operate in the absence of a producer of this event |
| Not required. Default: |
Design Rationale
See Feedback on EUREKA-847: Event Identifier as Dependency Unit in Kafka Topics Management
Examples
Implementation Plan (WIP)
Workstream 1 — Shared descriptor schema (applications-poc-tools/folio-backend-common)
1.1 Add
producesEvents/consumesEventstoModuleDescriptor.1.2 Add
scope(regular/platform) toApplicationDescriptor.1.3 Generalize the tenant-agnostic-routing check:
KongGatewayService.buildTenantHeaderExpressionandApisixRouteFactory.buildVarstreat a module as tenant-agnostic when its owning application hasscope: platform, not only whenisMgrComponent()is true.1.4 Release a new version and bump it in the consuming repos'
pom.xml.
Workstream 2 — Sidecar event declaration
2.1 Author the sidecar's module descriptor.
requires:mgr-applications/mgr-tenant-entitlements/mgr-tenantsinterfaces (matches its existingAM_CLIENT_URL/TE_CLIENT_URL/TM_CLIENT_URLREST dependencies).