Skip to main content

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
Processesone jar, every module of the editionone per module
Cross-module callsFeign over loopback to this same processFeign to each <module>.service.url
A missing <module>.service.urlharmless: the default is loopback, which is correctrefuses 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:

  1. 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: FilterChainProxy is both a Filter bean and the target of a DelegatingFilterProxyRegistrationBean, so a hand rolled scan collects Spring Security twice and runs the security chain twice per call.)
  2. InProcessTransportTest asserts 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