Topology: bundled and distributed (DEPLOY-004)
An edition says which modules a deployment contains. A topology says how many processes it runs them in. The two are independent: every edition runs either way, and the cloud tier is the hospital edition run distributed.
bundled (default) | distributed | |
|---|---|---|
| Processes | one jar, every module of the edition | one per module |
| Cross-module calls | Feign over loopback to this same process | Feign to each <module>.service.url |
A missing <module>.service.url | harmless: the default is loopback, which is correct | refuses to start |
Select it the way any profile is selected:
java -jar platform-app.jar --spring.profiles.active=distributed
⚠ A profile rather than a property, deliberately. A property can be set by anything contributing to the environment, including a file in the repo; profiles are stated at launch and printed in the startup banner. The difference between "this process talks to itself" and "this process talks to fifteen others" should not be reachable by accident.
⚠⚠ Why a missing URL is fatal under distributed
Every cross-module client declares:
url = "${lab.service.url:${uhp.platform.url}}"
That fallback is loopback, and loopback is exactly right when bundled, because the lab module is in this very
process. Distributed, it means the service calls itself, gets a clean 200 back from its own empty
tables, and reports "no results". Nothing throws and nothing is logged. What the operator sees is a
patient with no lab results, which is a wrong answer wearing the costume of a right one.
So DistributedTopologyUrlValidator refuses the deployment at startup, naming every unaddressed
service at once, since one per restart would mean fifteen deploys to learn fifteen facts:
Distributed topology requires an address for every module this deployment calls, and these have none:
imaging.service.url (unset; imaging would call this process);
lab.service.url (= uhp.platform.url, so lab would call this process).
Set each one, or run the bundled profile if this deployment is meant to hold every module in one process.
⚠ Note the second case. A URL that is set and merely points back here fails too; that is what a bundled config copied to a distributed deployment produces, and checking only for absence would wave through exactly the configurations most likely to be wrong.
⚠ The required set is discovered from the @FeignClient annotations, not listed in the validator. A
hand-kept list is correct on the day it is written; the next client is added by somebody who has never
read that class. Modules the edition excludes are skipped, since their clients are never registered.
⚠⚠ Bundled addresses itself by server.port, not APP_PORT
uhp.platform.url defaults to http://localhost:${server.port:8080}. It used to read ${APP_PORT:8080}
while the listener bound server.port, so --server.port=9999 moved the listener and left the loopback
address on 8080. The process then called a port nothing was on, every cross-module read came back empty,
and the visible symptom was empty pickers, not an error anyone could act on.
server.port is itself ${APP_PORT:8080}, so APP_PORT works exactly as before. The two simply can no
longer disagree.
⚠ One case this cannot serve: server.port=0 ("bind anything free"). The address is resolved while the
Feign clients are built, before a port exists. A bundled deployment must name its port.
Bundled calls itself in process, not over a socket (DEPLOY-006)
Bundled mode used to serialise a request, open a loopback socket to itself, deserialise, and do the
same in reverse. Every cross-module read paid for it. It now dispatches into this same process
instead, through one feign.Client that replaces the transport for all 88 clients at once. No client
interface changed and no caller changed.
⚠⚠ The call still goes through the whole filter chain
This is the part that matters, and the reason the work was split out of DEPLOY-004 for its own review. Reaching the handler is easy. Reaching it the way an HTTP request does is the requirement. Authentication, authorisation, cell residency and request context all run exactly as they do over HTTP, because an implementation that called the controller directly would pass every functional test and would silently remove authentication from every cross-module call in the platform. Nothing would fail to indicate it.
Two things make that hard to get wrong by accident:
- The filters are taken from Spring Boot's own
ServletContextInitializerBeans, the same type Boot uses to decide what the servlet container gets. A filter added later is picked up with no change to the transport. (Enumerating filter beans by hand looks equivalent and is not:FilterChainProxyis both aFilterbean and the target of aDelegatingFilterProxyRegistrationBean, so a hand rolled scan collects Spring Security twice and runs the security chain twice per call.) InProcessTransportTestasserts that an unauthenticated in-process call is refused with a 401, exactly as the same call over HTTP is refused, and that a wrong password is refused too. Both assertions were checked against a deliberately broken build that skipped the chain, and both failed as they should.
How "no loopback TCP" is proved
The test points its Feign client at http://localhost:1, where nothing listens. Over the socket
transport every call would fail with connection refused, so a successful response is itself the proof
that the call never left the process.
⚠ Which route was taken, and why
The choice was between spring-test's MockHttpServletRequest and a hand written
HttpServletRequest. The hand written route was taken.
spring-test would have put test infrastructure (MockMvc and friends) on the runtime classpath of
the shipped jar for every module, and MockHttpServletRequest exists to be lenient: every corner of
the servlet contract it does not really implement returns a plausible default. Plausible defaults are
exactly how an in-process transport diverges from the socket transport without anyone noticing.
So the in-process request and response implement faithfully what a Feign call actually uses (method,
URI, query string, headers, body, attributes, locale) and throw on everything else: async dispatch,
multipart, protocol upgrade and container managed authentication. A cross-module call cannot reach any
of them, and if one ever does the result is an immediate exception naming the method, not a wrong
answer somewhere downstream. InProcessServletContractTest pins that contract method by method.
⚠ Re-entrancy: the caller's own context is preserved
The dispatch runs on the calling thread, which is usually already inside an outer HTTP request.
Several filters end their finally block by clearing a thread local rather than restoring what was
there before, which is correct for a request that owns its thread and wrong for a nested one.
RequestContextFilter clears the tenant, facility and request id context; Spring Security's holder
filter clears the security context. Left alone, an in-process call would return successfully and strip
the caller's own tenant context for the rest of the outer request, surfacing much later as a query
silently scoped to no tenant. Every thread local the chain touches is therefore snapshotted before the
dispatch and put back afterwards, whatever the outcome.
Turning it off
uhp.topology.in-process-transport.enabled=false
Puts a bundled deployment back on the loopback socket with no code change. This is a performance optimisation sitting on the authorisation path, so an operator seeing something strange should be able to take it out of the path in one property and compare.
⚠ Under distributed the transport is never installed: those deployments open real connections to
real services. Leaving it installed there would mean a service asked to fetch a neighbour's data
answered itself instead, returning its own empty result rather than failing, which reads as "the
records are missing" rather than "the deployment is misconfigured".
⚠ Bundled is the default, expressed as "not distributed" rather than as a bundled profile that has
to be switched on. Nothing in the platform activates a profile by that name, so requiring one would
have shipped the whole feature dormant.
One deployment side effect
The dispatcher servlet is now initialised at startup rather than on the first HTTP request
(load-on-startup is forced to 1 in bundled topology). An in-process call is not an HTTP request, so
without this it can arrive first and find an uninitialised servlet. Startup does marginally more work;
the first real request does less.
See also
- Editions: which modules a deployment contains
- Cross-module database access: the other half of running modules apart