Skip to content

feat: build against continuum 3.1.0-SNAPSHOT on Spring Boot 4 and Vert.x 5 - #11

Open
NickPadilla wants to merge 15 commits into
developfrom
major-dep-bumps
Open

feat: build against continuum 3.1.0-SNAPSHOT on Spring Boot 4 and Vert.x 5#11
NickPadilla wants to merge 15 commits into
developfrom
major-dep-bumps

Conversation

@NickPadilla

Copy link
Copy Markdown
Member

Aligns Structures' shared dependencies with the continuum develop branch (3.1.0-SNAPSHOT, already published to Central Portal Snapshots) and bumps structuresVersion to 3.6.0.

Full suite is green: 91 tests, 0 failures, verified on a clean build.

Version alignment

Matched to continuum develop: Spring Boot 3.5.9 → 4.0.2, Vert.x 4.5.13 → 5.1.4, Ignite 2.17.0 → 2.18.0, OTel BOM 1.48.0 → 1.57.0, OTel instrumentation 2.13.3 → 2.23.0, Lombok plugin 8.13.1 → 9.1.0, Groovy 4.0.24 → 4.0.26, commons-text 1.13.0 → 1.15.0, vertx-stomp-lite 4.5.1 → 6.0.0. Added apacheCommonsLangVersion, commonsIoVersion and jakartaAnnotationVersion (replacing javaxAnnotationApi, which continuum dropped).

Two deliberate deviations:

  • allure stays at 2.32.0 rather than being downgraded to continuum's 2.29.1 — test-only, and we're ahead.
  • spring-ai 1.1.0 → 2.0.1 — not a continuum dependency, but 1.1.x targets Boot 3.5 and won't resolve against Boot 4.

JS/CLI packages are untouched — those codebases didn't change.

Vert.x 5

The callback overloads are gone, so listen(port, handler), close(promise) and sendJsonObject(json, handler) now chain .onComplete(...). CorsHandler.addRelativeOriginaddOriginWithRegex; HealthCheckHandler moved from io.vertx.ext.healthchecks to io.vertx.ext.web.healthchecks; WebClientOptions.setMaxPoolSize moved into a separate PoolOptions argument.

Spring Boot 4

spring-boot-autoconfigure was split into modules, so the three @SpringBootApplication(exclude = …) entries no longer compiled. ReactiveElasticsearchClientAutoConfiguration is gone entirely; Hazelcast and JPA are now excluded by name under their new packages (JpaRepositoriesAutoConfiguration was also renamed to DataJpaRepositoriesAutoConfiguration).

Boot 4 auto-configures only a Jackson 3 mapper. Structures is built on Jackson 2 across ~67 files and the Elasticsearch 8.18 client requires it, so the Jackson 2 ObjectMapper bean is now declared explicitly with the defaults Boot 3 applied. Continuum uses tools.jackson internally; the two coexist.

Behaviour change worth reviewing: the sync ElasticsearchClient came from auto-configuration driven by spring.elasticsearch.uris. Its Boot 4 replacement requires the Elasticsearch 9 client, which won't work against our pinned 8.18.1 — so it's declared alongside the async client, sharing its transport, and now follows structures.elastic-connections.

The Testcontainers failure

Every integration test failed with "Could not find a valid Docker environment." Cause: Boot 3 shipped a docker-java.properties resource pinning api.version=1.44 inside spring-boot-test.jar, and Boot 4 dropped it. Without it docker-java falls back to its built-in 1.32 default, which modern Docker engines reject with a 400. Upgrading Testcontainers to 1.21.3 does not help — docker-java 3.4.2 has the same default — so the resource is restored under structures-test.

Not covered here

  • structures-server has not been booted end to end, so the gateway RPC path — where continuum now serializes with Jackson 3 while Structures registers its C3Decorator subtypes on a Jackson 2 SimpleModule — is unverified by this suite. Worth exercising against the image this PR builds.
  • Spring AI 2.0.1 is compile-verified only; DataInsightsConfiguration is @Profile("!test"), so no AI bean is instantiated in tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH

NickPadilla and others added 15 commits September 10, 2026 12:10
…t.x 5

Aligns the shared dependencies with the continuum develop branch: Spring Boot
3.5.9 -> 4.0.2, Vert.x 4.5.13 -> 5.1.4, Ignite 2.17 -> 2.18, the OpenTelemetry
BOMs, Groovy, commons-text and the Lombok plugin. javax.annotation is replaced
by jakarta.annotation, which continuum dropped. spring-ai moves to 2.0.1 since
1.1.x targets Boot 3.5; allure stays ahead of continuum's version.

Vert.x 5 removed the callback overloads, so listen/close/sendJsonObject now
chain onComplete. CorsHandler.addRelativeOrigin became addOriginWithRegex,
HealthCheckHandler moved to io.vertx.ext.web.healthchecks, and the web client
pool size moved out of WebClientOptions into PoolOptions.

Spring Boot 4 split spring-boot-autoconfigure into modules. The Hazelcast and
JPA exclusions are now by name under their new packages, and
ReactiveElasticsearchClientAutoConfiguration is gone entirely. Boot 4 also
auto-configures only a Jackson 3 mapper, so the Jackson 2 ObjectMapper that
Structures and the Elasticsearch client are built on is declared explicitly.
The sync ElasticsearchClient used to come from auto-configuration; its Boot 4
replacement requires the Elasticsearch 9 client, so it is declared alongside
the async client and now follows structures.elastic-connections rather than
spring.elasticsearch.uris.

Boot 3 shipped a docker-java.properties resource pinning api.version=1.44
inside spring-boot-test. Boot 4 dropped it, leaving docker-java on its 1.32
default, which modern Docker engines reject with a 400 and Testcontainers
reports as "Could not find a valid Docker environment". The resource is
restored under structures-test.

Full suite is green: 91 tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Continuum serializes the IDL with Jackson 3 as of 3.1.0, but Structures
registered its C3Decorator and C3Type subtypes only on a Jackson 2 SimpleModule.
Continuum's mapper therefore knew nothing but its own built-in decorators, and
any schema carrying a Structures decorator failed to deserialize with:

  Could not resolve type id 'EntityServiceDecorators' as a subtype of
  C3Decorator: known type ids = [NotNull]

This is what broke the e2e suite against the PR image: every entity service
call failed once the schema carrying EntityServiceDecorators could not be read,
and the reported "Cannot read properties of undefined" failures were fallout.

The subtype set moves to a shared idlSubtypes() and is registered on both a
Jackson 2 and a Jackson 3 module, so the two mappers agree on the wire. The
TenantSpecificId abstract type mapping is registered on both for the same
reason. The RawJson, FieldValue and FastestType serializers stay on Jackson 2
for now; nothing has yet shown them crossing continuum's mapper.

IdlJacksonInteropTest covers this against the Jackson 3 mapper specifically -
a Jackson 2 test cannot see the bug, which is why the existing suite stayed
green while the e2e failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
…ckson 3 mapper

With the IDL subtypes registered, the e2e suite got past schema decoding and hit
the next layer of the same Jackson 2/3 boundary:

  Cannot deserialize value of type `com.fasterxml.jackson.databind.util.TokenBuffer`
  from Array value (token `JsonToken.START_ARRAY`)

JsonEntitiesService takes and returns Jackson 2 TokenBuffers, and RawJson and
FastestType ride on the other published signatures. Continuum serializes RPC
payloads with Jackson 3 as of 3.1.0 and knows none of them.

Rather than port those types and their serializers to Jackson 3, which would put
new bytes on a wire the JS client already speaks, continuum's mapper delegates
them back to the Jackson 2 mapper that has always produced them. The
representation is therefore identical to what continuum 2.6 emitted, which is the
property a dependency upgrade should have.

Covered by IdlJacksonInteropTest, including the TokenBuffer-holding-an-array case
that bulkSave receives and that the reported failure named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Covers the split brain seen once in production: a pod restarts, comes up when the
Ignite headless service reports no other endpoints, and forms its own single node
cluster. Ignite considers that a healthy new cluster, so no EVT_NODE_SEGMENTED
fires and the failure handler never halts the JVM. With auto restart deliberately
off, the value of that state is that the pod keeps serving and the monitor reports
it loudly enough to alert on.

k8s-segmentation.test.ts asserts, against the 3 replica KinD cluster:

  - the isolated pod still accepts and completes real entity work. Without
    continuum preferring local delivery most of those calls would be dispatched to
    peers it cannot reach, so sustained success is the property under test.
  - the observer reports the isolated topology, so a monitor has something to fire
    on, and the two pods still holding a majority stay healthy and quiet. A monitor
    that also fires on the healthy side is not usable.
  - restarting the pod rejoins the running cluster, so the documented remediation
    actually works.

Isolation drops Ignite discovery and communication traffic with iptables on the
KinD node hosting the pod: the pod has no NET_ADMIN, and KinD's default CNI does
not enforce NetworkPolicy. Rules are tagged and removed unconditionally so a failed
run cannot leave a node firewalled.

The detection this relies on was switched off everywhere. The topology poll only
starts when minimumClusterSize is above 1, and both the chart default and the KinD
values left it at 1, so the condition could never be reported. The KinD values now
set 2 for 3 replicas. The chart keeps its conservative default and now also exposes
the reporting thresholds, which the KinD values shorten - production waits 5m to
warn and 15m to escalate so a slow start is never reported as a split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Running the test against a real KinD cluster showed the isolation was a no-op. The
rules matched on pod IP, but the pod under test is deliberately restarted while
segmented and Kubernetes gives the replacement a new IP: 10.244.3.4 became
10.244.3.5, the rules stopped matching, and the pod rejoined the cluster instead of
coming up alone. The test would have passed while asserting nothing.

Rules now match the hosting node's pod CIDR, which is stable across restarts.

Verified end to end against 3 replicas on KinD running 3.6.0-pr11.75a7ea1: the
isolated pod comes up as its own single node cluster (serverNodes=1, armed=false),
stays Ready, and serves 10 save/findById round trips; the observer escalates to
ERROR naming the condition; the two pods still holding a majority stay at
serverNodes=2 armed=true and never report a split; and a restart rejoins the
cluster at 3. Teardown leaves no rules behind on any node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
The segmentation test only covers local delivery indirectly, and only while a pod's
peers are unreachable. This asserts the preference directly on a whole, healthy
three node cluster, which is the case that actually runs in production.

Each pod is called 25 times and every call must be served by one node. Round robin
across the base resource would scatter roughly two thirds of them onto peers, so
landing on a single node every time is the property under test; by chance that is
about 1 in 10^12. Each pod pinning to a different node is what separates "served
locally" from "the cluster routes everything to one node", and serverNodeCount is
asserted to be the full replica count so a local result is a preference rather than
the only option left.

Making that observable meant publishing ClusterInfoService, which was written and
annotated but commented out. ClusterInfo.localNodeId reports the node that executed
the call rather than the node the caller connected to, which is exactly the
distinction being tested, and it is useful on its own for cluster monitoring.

Publishing it needs a version, which continuum takes from the interface or an
ancestor package. This package sits outside the tree the other published services
inherit theirs from, so it gets its own package-info, matching what the insights
package already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
…onse

The test claimed a pod served its own calls based on ClusterInfo.localNodeId, which
is the response telling us where it was built. DefaultClusterInfoService logs every
execution at trace, so the pods themselves can be asked instead: the pod that was
called must log all 25 executions and every other pod must log none.

Counted as a delta around each pod's turn, so the assertion holds whatever the logs
already contain. Verified against 3 replicas on KinD: 25 executions on the pod that
received them and 0 on each of the other two, every time, with serverNodeCount 3 so
all three nodes were registered and round robin had somewhere else to go.

Needs the trace level, which the chart could not set: it had no way to pass
environment beyond what it models. Adds an extraEnv passthrough, empty by default,
and the KinD values use it to raise that one package. Useful beyond this test for
anything the chart does not model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Publishing the service made it reachable by any caller the gateway has
authenticated. It reports node ids, addresses and cluster topology and performs no
authorization of its own, which is not something to hand out on a production
deployment just because a test needed to attribute a call to a node.

It is now off unless structures.cluster-info.enabled is true, matching how the OIDC
beans opt in, so a deployment that says nothing does not get it. The KinD values
turn it on because the local delivery test needs it, with a comment saying why it
must not be turned on anywhere else.

ClusterInfoServiceDisabledTest pins the default: the test profile does not set the
property, so the bean must be absent. Removing the condition fails it with
"expected: <0> but was: <1>", so a change that made the service unconditional gets
caught here rather than quietly exposing cluster internals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
…waiting

Reviewing the Vert.x 5 handler conversions turned up two places where a failure
leaves a future uncompleted. Neither came from that migration - onComplete receives
an AsyncResult and every converted handler already branched on ar.succeeded() - but
both produce the hang that motivated the review.

translateSql completed exceptionally with convertErrorResponse(input) on a non 200
response, outside any try/catch. Parsing an unexpected error body throws, the
exception escapes the handler, and the future is never completed, so the caller
waits on it forever. queryForPage wraps the identical call, so this was an
inconsistency between siblings rather than a considered choice.

The openapi.json route chained thenApply with no exceptionally. If getOpenApiSpec
fails the stage is skipped, nothing ever writes to the response, and the request
hangs until the client times out. The other eighteen routes in that file end with
the exceptionally/writeException pair; this one was the outlier and now matches.

Also fixes the indentation left mangled in the translateSql handler by the earlier
mechanical conversion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
…er info

Publishing ClusterInfoService to let a test attribute a call to a node meant an
endpoint that hands out node ids, addresses and topology to any caller the gateway
has authenticated, gated only by a property. Until RBAC is in place that is more
surface than the test is worth, and a property is a thin thing to stand between a
caller and cluster internals.

ClusterInfoService goes back to being unpublished, so it cannot be reached remotely
at all regardless of configuration. In its place EchoService returns the caller's
own message plus a random id for the instance that handled the call. That is all
the test needs to tell instances apart, and it gives away nothing: no topology, no
node ids, no addresses, no data. It is still off unless
structures.echo-service.enabled is true, but now the gate is defence in depth
rather than the only protection.

The test asked the service for serverNodeCount to confirm the cluster was whole.
The echo service deliberately does not know, so that is asked of Kubernetes
instead, which is a better source for it anyway.

EchoServiceDisabledTest pins the default the way the cluster info test did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Sweeping the async completion paths across structures-core, structures-sql and
structures-auth for the case where nothing ever completes.

DefaultDelegatingGqlHandler resolved the schema handler and discarded the resulting
Future with no failure path. Resolving it fails for an unknown application among
other things, and when it did the map was skipped, nothing wrote to the routing
context, and the GraphQL request hung until the client gave up. The route already
installs a failure handler; the failure just never reached it. It does now.

ReindexStatementExecutor.pollTaskRecursive completed the future on every branch it
considered, but anything thrown while inspecting the response escaped the callback
and stranded it, and since the deadline is only checked when a poll comes back, no
later poll would ever notice. The body is now guarded.

Everything else came back clean. Four futures are completed by hand: two in
DefaultElasticVertxClient already covered, MigrationExecutor's is completed
immediately, and the reindex one above. The GraphQL data fetchers return their
futures to graphql-java rather than discarding them. The verticles pass stopPromise
as the handler, so close completes it either way. The only .subscribe in the tree is
TypeScript inside a string literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
From code review of the branch.

The hand rolled Jackson 2 ObjectMapper claimed to mirror Boot 3's defaults but did
not. Boot 3 disabled WRITE_DATES_AS_TIMESTAMPS and WRITE_DURATIONS_AS_TIMESTAMPS on
top of what Jackson2ObjectMapperBuilder.json() does; without them Jackson writes
epoch numbers. Verified against the running context: a Date came out as
1700000000000 rather than "2023-11-14T22:13:20.000+00:00". That is both API
responses and what the Elasticsearch client stores, and it is symmetric, so reading
our own data back still worked and nothing failed. Structure.created, updated and
publishedTimestamp are Dates, so this was live. Pinned by a test, and the migration
tool's mapper gets the duration feature too so the two write indices the same way.

The Jackson 3 bridge registered both directions for every type, but FastestType has
only a serializer and it writes the unwrapped inner value. Reading one back found no
data property and, with unknown properties ignored, produced FastestType(null)
rather than failing. It now registers the serializer alone.

The segmentation test matched /reached the minimum/ to decide a pod had rejoined,
which the "has not yet reached the minimum" warning also satisfies, so a pod that
never rejoined would have passed step 6. Both regexes are anchored now. It also
assumed the replacement lands on the node the iptables rules were written for, which
nothing enforces, so that is asserted rather than assumed, and segmentPod refuses to
run when a peer shares the target's node instead of silently isolating two pods.

restartPod called kubectl wait before the ReplicaSet had necessarily created the
replacement, so it could wait on the survivors and return success; it now waits for
the replica count first. healPod could throw out of an unconditional afterAll and
leave a node firewalled, which is the state it exists to prevent, and looped without
a bound; it now swallows per rule failures, caps its passes and says what to clean up
by hand. getPodPlacements counted any labelled pod, so a Pending or crash looping
replica satisfied the local delivery test's "cluster is whole" precondition; it now
filters to Running and Ready.

Running both k8s files together failed: they share one cluster and the segmentation
test breaks it on purpose while the other asserts it is whole. Their files now run
one at a time when the k8s tests are enabled, and each uses its own port forward
base.

Reviewed and rejected: the claim that objectMapper(List<Module>) fails the migration
app at startup with no Jackson 2 module beans. Spring injects an empty list for a
@bean method collection parameter rather than throwing. Confirmed by instrumenting
the bean, which printed 0 modules, and by running the jar, which starts and applies
its migrations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
VITE_USE_STRUCTURES_DOCKER=false assumed a plaintext server on 127.0.0.1:58503, so
the suite could only ever run against the compose stack it starts itself. It now
takes STRUCTURES_E2E_HOST, _PORT, _USE_SSL and _OPENAPI_BASE_URL, which is what let
the whole suite run against the three replica KinD cluster through its nginx
ingress. Defaults are unchanged, so the compose path and CI behave exactly as before.

The OpenAPI tests built http://host:port by hand, which silently spoke plaintext to
443. They take the provided base URL now.

The OpenAPI base URL is separate from the STOMP target rather than derived from it,
because the two cannot currently point at the same place: the chart's api ingress
routes /api and /graphql but not /api-docs, so the schema request falls through to
the UI's catch all and comes back 200 with index.html. A caller fetching the schema
through the ingress gets a page instead of a spec, which is worse than a 404 because
it looks like it worked. Left alone here since whether to expose /api-docs publicly
is a deployment decision, not something a test should settle.

Verified against the cluster on 3.6.0-pr11.1bf8bfc: nine of twelve files pass through
the ingress, and the three OpenAPI files pass pointed straight at a pod.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
Two things a future reader would otherwise have to rediscover.

Boot's customizer applied spring.jackson.* on top of the feature defaults -
default-property-inclusion, time-zone, locale, date-format, visibility and the
per-feature maps. The hand declared mapper binds none of it. Nothing sets those
today so nothing is lost, but setting one later will appear to do nothing, which is
a poor thing to debug from scratch. The four features themselves are now confirmed
to be the whole of what Boot 3 configured, so behaviour there matches.

The bridge's two directions cost very differently, measured rather than guessed.
Reading is three passes and two intermediates: 3.7x slower and 4.5x the allocation
against a 830KB bulk array, 14.4MB versus 3.2MB, with the ratios holding from 16KB
up. Writing is one extra string, about 2x allocation and no measurable time. The
expensive direction is the one entity ingest uses, since the bridged types are
parameters to save, update, bulkSave and bulkUpdate. Noted on the deserializer along
with what fixing it would take, so the tradeoff is visible to whoever profiles this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
The 3.6.0 bridge costs 3.7x time and 4.5x allocation on reads against an 830KB bulk
array, and reads are the direction entity ingest uses. At tens of thousands of bulk
updates a day moving hundreds of GB that is disqualifying, so this writes down the
fix rather than leaving it as a comment on the deserializer.

The plan takes RawJson on the published entity methods instead of TokenBuffer and
gives it native Jackson 3 serialization through ByteArrayBuilder and
copyCurrentStructure. That removes the bridge's tree and string, and also removes the
TokenBuffer itself, which the old Jackson 2 code built and discarded too - so the
target is faster than the code we had before the upgrade, not merely even with it.

Three things make it smaller than it looks: RawJson.from already implements the
optimal shape in Jackson 2 and Jackson 3 has the same copyCurrentStructure;
DelegatingUpsertPreProcessor already dispatches on RawJson and the non-blocking byte
array parser path is already exercised; and the wire format does not change, so the
JS clients and the e2e suite are unaffected. Only Java consumers of structures-api
break, which is what the major bump is for.

Also records what the plan does not cover: version stamping still builds a tree per
entity on the optimistic locking path, RawJsonSerializer still converts bytes to a
string, and leaving Jackson 2 entirely is not possible while the Elasticsearch client
has no Jackson 3 binding in any published version including 9.5.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH
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