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
Open
feat: build against continuum 3.1.0-SNAPSHOT on Spring Boot 4 and Vert.x 5#11NickPadilla wants to merge 15 commits into
NickPadilla wants to merge 15 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Aligns Structures' shared dependencies with the continuum develop branch (
3.1.0-SNAPSHOT, already published to Central Portal Snapshots) and bumpsstructuresVersionto3.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.x4.5.13 → 5.1.4, Ignite2.17.0 → 2.18.0, OTel BOM1.48.0 → 1.57.0, OTel instrumentation2.13.3 → 2.23.0, Lombok plugin8.13.1 → 9.1.0, Groovy4.0.24 → 4.0.26, commons-text1.13.0 → 1.15.0, vertx-stomp-lite4.5.1 → 6.0.0. AddedapacheCommonsLangVersion,commonsIoVersionandjakartaAnnotationVersion(replacingjavaxAnnotationApi, which continuum dropped).Two deliberate deviations:
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)andsendJsonObject(json, handler)now chain.onComplete(...).CorsHandler.addRelativeOrigin→addOriginWithRegex;HealthCheckHandlermoved fromio.vertx.ext.healthcheckstoio.vertx.ext.web.healthchecks;WebClientOptions.setMaxPoolSizemoved into a separatePoolOptionsargument.Spring Boot 4
spring-boot-autoconfigurewas split into modules, so the three@SpringBootApplication(exclude = …)entries no longer compiled.ReactiveElasticsearchClientAutoConfigurationis gone entirely; Hazelcast and JPA are now excluded by name under their new packages (JpaRepositoriesAutoConfigurationwas also renamed toDataJpaRepositoriesAutoConfiguration).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
ObjectMapperbean is now declared explicitly with the defaults Boot 3 applied. Continuum usestools.jacksoninternally; the two coexist.Behaviour change worth reviewing: the sync
ElasticsearchClientcame from auto-configuration driven byspring.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 followsstructures.elastic-connections.The Testcontainers failure
Every integration test failed with "Could not find a valid Docker environment." Cause: Boot 3 shipped a
docker-java.propertiesresource pinningapi.version=1.44insidespring-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 understructures-test.Not covered here
structures-serverhas not been booted end to end, so the gateway RPC path — where continuum now serializes with Jackson 3 while Structures registers itsC3Decoratorsubtypes on a Jackson 2SimpleModule— is unverified by this suite. Worth exercising against the image this PR builds.DataInsightsConfigurationis@Profile("!test"), so no AI bean is instantiated in tests.🤖 Generated with Claude Code
https://claude.ai/code/session_01SAsEfzyJuEGKYmimnkuviH