Skip to content

Commit 47fa33f

Browse files
authored
Load the Conductor client through a ServiceLoader provider (#626)
DBOS finds its Conductor client through `java.util.ServiceLoader` instead of constructing it directly. This is the hook that lets the client move out of this SDK into a separate, closed-source artifact, `dev.dbos:transact-enterprise`, under the DBOS Conductor Software License. Python and TypeScript already split Conductor out this way (dbos-transact-py#873, dbos-transact-ts#1394), and Go did the same in dbos-transact-golang#498. Nothing changes for users yet. Until the client moves, this artifact registers its own client as the provider. The move ships in the same release as this change. ## Changes **1. A provider interface, in `dev.dbos.transact.conductor`.** - `ConductorProvider.create(executor, systemDatabase, options)` builds a `ConductorConnection`, which has `start()` and `stop()`. Giving the connection its own stop lets shutdown disconnect Conductor at a point DBOS chooses. - `ConductorOptions` carries the key, the app name and the domain. A null app name means the executor's name, and a null domain means DBOS Conductor's public endpoint. - `Conductor` implements `ConductorConnection`. `BundledConductorProvider` wraps it and is registered in `META-INF/services/dev.dbos.transact.conductor.ConductorProvider`. The registration and the class go away when the client moves. **2. The executor requires exactly one provider when Conductor is configured.** - When a Conductor key is set in `DBOSConfig` or `DBOS__CLOUD=true`, the `DBOSExecutor` constructor resolves the provider, so `launch()` fails before migrations or anything else run. As in Python, DBOS Cloud requires the provider whether or not the `DBOS__CONDUCTOR_*` variables are set. - **No provider:** the error names the artifact to add. For a core release it gives the exact version (`dev.dbos:transact-enterprise:1.3.0`). Preview builds are numbered by commits in each repo, so for a preview it names the minor instead. - **More than one provider** is an error naming them. - **A provider whose constructor throws**, e.g. on a version mismatch, fails launch with ServiceLoader's message plus the cause's. - Providers are looked up in the thread context class loader and in the one that loaded DBOS. A provider deployed beside the application is then found even when DBOS is a shared library, as in a servlet container. A provider visible through both loaders counts once. **3. The client is built early and started late.** `start()` creates the connection right after the system database opens, before queues, the scheduler or lifecycle listeners start. It starts the connection at the end of launch, as before. The provider is now another artifact's code, so if it throws, launch fails with nothing else running. `stop()` is documented as called even when launch failed before `start()`; `Conductor.stop()` already handles that. ## Tests `ConductorProvidersTest`: - The bundled provider is found, including through a context class loader that delegates to DBOS's own loader, where it must count once. - Selection returns the only provider, rejects two, and gives the release and preview forms of the missing-provider message. The path where a provider's constructor throws has no test here. A failing provider registered for tests would be found by every other test that launches with Conductor. The enterprise artifact's own tests exercise it. Run locally: `ConductorProvidersTest`, `ConductorTest`, `ConfigTest`, `ConfigEnvTest` (151 tests), plus `spotlessCheck`, `compileTestJava` and `javadoc`. The full suite has not been run locally; this PR's CI run is that pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 984510d commit 47fa33f

9 files changed

Lines changed: 287 additions & 32 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package dev.dbos.transact.conductor;
2+
3+
import dev.dbos.transact.database.SystemDatabase;
4+
import dev.dbos.transact.execution.DBOSExecutor;
5+
6+
/** Provides the Conductor client that ships in this artifact. */
7+
public final class BundledConductorProvider implements ConductorProvider {
8+
@Override
9+
public ConductorConnection create(
10+
DBOSExecutor executor, SystemDatabase systemDatabase, ConductorOptions options) {
11+
var builder = new Conductor.Builder(executor, systemDatabase, options.apiKey());
12+
if (options.appName() != null) {
13+
builder.appName(options.appName());
14+
}
15+
if (options.domain() != null) {
16+
builder.domain(options.domain());
17+
}
18+
return builder.build();
19+
}
20+
}

‎transact/src/main/java/dev/dbos/transact/conductor/Conductor.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
import tools.jackson.databind.cfg.DateTimeFeature;
6464
import tools.jackson.databind.json.JsonMapper;
6565

66-
public class Conductor implements AutoCloseable {
66+
public class Conductor implements ConductorConnection, AutoCloseable {
6767

6868
private static final Logger logger = LoggerFactory.getLogger(Conductor.class);
6969

@@ -588,11 +588,13 @@ public void close() {
588588
this.stop();
589589
}
590590

591+
@Override
591592
public void start() {
592593
logger.debug("start");
593594
connectWebSocket();
594595
}
595596

597+
@Override
596598
public void stop() {
597599
logger.debug("stop");
598600
if (isShutdown.compareAndSet(false, true)) {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package dev.dbos.transact.conductor;
2+
3+
/** The Conductor client, run for the life of a launched DBOS instance. */
4+
public interface ConductorConnection {
5+
/** Starts connecting to Conductor. Called once, at the end of launch. */
6+
void start();
7+
8+
/**
9+
* Disconnects from Conductor. Called once, at the start of shutdown, including when launch failed
10+
* before {@link #start} was called.
11+
*/
12+
void stop();
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package dev.dbos.transact.conductor;
2+
3+
import java.util.Objects;
4+
5+
import org.jspecify.annotations.Nullable;
6+
7+
/**
8+
* How a Conductor client connects.
9+
*
10+
* @param apiKey the Conductor API key
11+
* @param appName the application name Conductor addresses this executor by; {@code null} means the
12+
* executor's application name
13+
* @param domain the Conductor host or URL; {@code null} means DBOS Conductor's public endpoint
14+
*/
15+
public record ConductorOptions(String apiKey, @Nullable String appName, @Nullable String domain) {
16+
public ConductorOptions {
17+
Objects.requireNonNull(apiKey, "apiKey must not be null");
18+
}
19+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package dev.dbos.transact.conductor;
2+
3+
import dev.dbos.transact.database.SystemDatabase;
4+
import dev.dbos.transact.execution.DBOSExecutor;
5+
6+
/**
7+
* Builds the Conductor client for a DBOS instance. DBOS Transact finds the implementation with
8+
* {@link java.util.ServiceLoader}; the {@code dev.dbos:transact-enterprise} artifact registers it
9+
* in {@code META-INF/services/dev.dbos.transact.conductor.ConductorProvider}.
10+
*
11+
* <p>DBOS requires exactly one implementation when a Conductor key is set in {@code DBOSConfig} or
12+
* when running in DBOS Cloud, and fails at launch without one.
13+
*/
14+
public interface ConductorProvider {
15+
/**
16+
* Builds a Conductor client, without connecting. DBOS calls this during launch, once the system
17+
* database is open and before queues, the scheduler or lifecycle listeners start, and starts the
18+
* returned connection at the end of launch.
19+
*/
20+
ConductorConnection create(
21+
DBOSExecutor executor, SystemDatabase systemDatabase, ConductorOptions options);
22+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package dev.dbos.transact.conductor;
2+
3+
import dev.dbos.transact.DBOS;
4+
5+
import java.util.ArrayList;
6+
import java.util.LinkedHashMap;
7+
import java.util.List;
8+
import java.util.ServiceConfigurationError;
9+
import java.util.ServiceLoader;
10+
import java.util.regex.Pattern;
11+
12+
/** Finds the {@link ConductorProvider} on the class path. */
13+
public final class ConductorProviders {
14+
static final String ENTERPRISE_ARTIFACT = "dev.dbos:transact-enterprise";
15+
16+
private static final Pattern RELEASE = Pattern.compile("\\d+\\.\\d+\\.\\d+");
17+
private static final Pattern MINOR = Pattern.compile("(\\d+\\.\\d+)\\..*");
18+
19+
private ConductorProviders() {}
20+
21+
/**
22+
* Returns the one registered {@link ConductorProvider}. Looks in the thread context class loader
23+
* and in the one that loaded DBOS, so a provider deployed beside the application is found when
24+
* DBOS itself is a shared library, as in a servlet container.
25+
*
26+
* @throws IllegalStateException if none is registered, more than one is, or one fails to
27+
* initialize
28+
*/
29+
public static ConductorProvider load() {
30+
var loaders = new ArrayList<ClassLoader>();
31+
var context = Thread.currentThread().getContextClassLoader();
32+
if (context != null) {
33+
loaders.add(context);
34+
}
35+
var own = ConductorProvider.class.getClassLoader();
36+
if (own != context) {
37+
loaders.add(own);
38+
}
39+
40+
// One instance per provider class, constructed once: a loader that delegates to its parent sees
41+
// the parent's registrations too, so duplicates are dropped before they are instantiated.
42+
var providers = new LinkedHashMap<Class<?>, ConductorProvider>();
43+
try {
44+
for (var loader : loaders) {
45+
ServiceLoader.load(ConductorProvider.class, loader).stream()
46+
.filter(provider -> !providers.containsKey(provider.type()))
47+
.forEach(provider -> providers.put(provider.type(), provider.get()));
48+
}
49+
} catch (ServiceConfigurationError e) {
50+
// A provider's constructor rejected this DBOS, e.g. for a version mismatch. Its own message
51+
// says why; ServiceLoader's names the provider.
52+
var cause = e.getCause();
53+
var message =
54+
cause != null && cause.getMessage() != null
55+
? e.getMessage() + ": " + cause.getMessage()
56+
: e.getMessage();
57+
throw new IllegalStateException(message, e);
58+
}
59+
return select(List.copyOf(providers.values()), DBOS.version());
60+
}
61+
62+
static ConductorProvider select(List<ConductorProvider> providers, String version) {
63+
if (providers.isEmpty()) {
64+
throw new IllegalStateException(
65+
"Connecting to DBOS Conductor requires "
66+
+ ENTERPRISE_ARTIFACT
67+
+ " on the class path. "
68+
+ versionAdvice(version));
69+
}
70+
if (providers.size() > 1) {
71+
var names = providers.stream().map(p -> p.getClass().getName()).toList();
72+
throw new IllegalStateException(
73+
"Found more than one DBOS Conductor provider on the class path: " + names);
74+
}
75+
return providers.get(0);
76+
}
77+
78+
// Previews are numbered by commits in each repo, so only a release names the exact version.
79+
private static String versionAdvice(String version) {
80+
if (version != null && RELEASE.matcher(version).matches()) {
81+
return String.format(
82+
"Add %s:%s, the same version as dev.dbos:transact", ENTERPRISE_ARTIFACT, version);
83+
}
84+
var minor = version == null ? null : MINOR.matcher(version);
85+
if (minor != null && minor.matches()) {
86+
return String.format(
87+
"Add a %s %s build, as dev.dbos:transact is %s",
88+
ENTERPRISE_ARTIFACT, minor.group(1), version);
89+
}
90+
return "Add the " + ENTERPRISE_ARTIFACT + " version that matches dev.dbos:transact";
91+
}
92+
}

‎transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java‎

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
import dev.dbos.transact.EnqueueOptions;
77
import dev.dbos.transact.StartWorkflowOptions;
88
import dev.dbos.transact.admin.AdminServer;
9-
import dev.dbos.transact.conductor.Conductor;
9+
import dev.dbos.transact.conductor.ConductorConnection;
10+
import dev.dbos.transact.conductor.ConductorOptions;
11+
import dev.dbos.transact.conductor.ConductorProvider;
12+
import dev.dbos.transact.conductor.ConductorProviders;
1013
import dev.dbos.transact.config.DBOSConfig;
1114
import dev.dbos.transact.context.DBOSContext;
1215
import dev.dbos.transact.context.DBOSContextHolder;
@@ -187,7 +190,8 @@ public void close() {
187190
private QueueService queueService;
188191
private SchedulerService schedulerService;
189192
private AdminServer adminServer;
190-
private Conductor conductor;
193+
private @Nullable ConductorProvider conductorProvider;
194+
private ConductorConnection conductor;
191195
private ExecutorService executorService;
192196
private ScheduledExecutorService timeoutScheduler;
193197
private AlertHandler alertHandler;
@@ -238,6 +242,11 @@ public DBOSExecutor(DBOSConfig config) {
238242
logger.warn(msg);
239243
}
240244

245+
// Found before anything is started, so a missing provider fails launch cleanly.
246+
if (dbosCloud || config.conductorKey() != null) {
247+
conductorProvider = ConductorProviders.load();
248+
}
249+
241250
if (!dbosCloud) {
242251
if (config.enablePatching()) {
243252
appVersion = "PATCHING_ENABLED";
@@ -316,6 +325,9 @@ public void start(
316325

317326
systemDatabase = SystemDatabase.create(config, this.executorId, this.appName);
318327
systemDatabase.start();
328+
// The provider is another artifact's code, so build its client before anything else starts:
329+
// if it throws, launch fails with no queue, scheduler or listener running.
330+
conductor = createConductor();
319331
// Handed to the executor service: a refresh waits for a pool connection, and this scheduler
320332
// also fires workflow timeouts.
321333
timeoutScheduler.scheduleWithFixedDelay(
@@ -359,35 +371,9 @@ public void start(
359371
listener.dbosLaunched(dbos);
360372
}
361373

362-
if (dbosCloud) {
363-
String cloudAppName = System.getenv("DBOS__CONDUCTOR_APP_NAME");
364-
String cloudKey = System.getenv("DBOS__CONDUCTOR_KEY");
365-
String cloudUrl = System.getenv("DBOS__CONDUCTOR_URL");
366-
if (cloudAppName != null
367-
&& !cloudAppName.isBlank()
368-
&& cloudKey != null
369-
&& !cloudKey.isBlank()
370-
&& cloudUrl != null
371-
&& !cloudUrl.isBlank()) {
372-
logger.debug("Starting Conductor connection (DBOS Cloud)");
373-
conductor =
374-
new Conductor.Builder(this, systemDatabase, cloudKey)
375-
.appName(cloudAppName)
376-
.domain(cloudUrl)
377-
.build();
378-
conductor.start();
379-
}
380-
} else {
381-
String conductorKey = config.conductorKey();
382-
if (conductorKey != null) {
383-
Conductor.Builder builder = new Conductor.Builder(this, systemDatabase, conductorKey);
384-
String domain = config.conductorDomain();
385-
if (domain != null && !domain.trim().isEmpty()) {
386-
builder.domain(domain);
387-
}
388-
conductor = builder.build();
389-
conductor.start();
390-
}
374+
if (conductor != null) {
375+
logger.debug("Starting Conductor connection");
376+
conductor.start();
391377
}
392378

393379
if (dbosCloud || config.adminServer()) {
@@ -407,6 +393,37 @@ public void start(
407393
}
408394
}
409395

396+
private @Nullable ConductorConnection createConductor() {
397+
if (conductorProvider == null) {
398+
return null;
399+
}
400+
if (dbosCloud) {
401+
String cloudAppName = System.getenv("DBOS__CONDUCTOR_APP_NAME");
402+
String cloudKey = System.getenv("DBOS__CONDUCTOR_KEY");
403+
String cloudUrl = System.getenv("DBOS__CONDUCTOR_URL");
404+
if (cloudAppName == null
405+
|| cloudAppName.isBlank()
406+
|| cloudKey == null
407+
|| cloudKey.isBlank()
408+
|| cloudUrl == null
409+
|| cloudUrl.isBlank()) {
410+
return null;
411+
}
412+
return conductorProvider.create(
413+
this, systemDatabase, new ConductorOptions(cloudKey, cloudAppName, cloudUrl));
414+
}
415+
String conductorKey = config.conductorKey();
416+
if (conductorKey == null) {
417+
return null;
418+
}
419+
String domain = config.conductorDomain();
420+
if (domain != null && domain.trim().isEmpty()) {
421+
domain = null;
422+
}
423+
return conductorProvider.create(
424+
this, systemDatabase, new ConductorOptions(conductorKey, null, domain));
425+
}
426+
410427
@Override
411428
public void close() {
412429
if (isRunning.compareAndSet(true, false)) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dev.dbos.transact.conductor.BundledConductorProvider
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package dev.dbos.transact.conductor;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
5+
import static org.junit.jupiter.api.Assertions.assertSame;
6+
import static org.junit.jupiter.api.Assertions.assertThrows;
7+
8+
import java.net.URL;
9+
import java.net.URLClassLoader;
10+
import java.util.List;
11+
12+
import org.junit.jupiter.api.Test;
13+
14+
public class ConductorProvidersTest {
15+
16+
@Test
17+
public void loadsTheBundledProvider() {
18+
assertInstanceOf(BundledConductorProvider.class, ConductorProviders.load());
19+
}
20+
21+
@Test
22+
public void aContextLoaderThatDelegatesToDbosFindsItOnce() throws Exception {
23+
// The child sees the bundled registration through its parent; finding it twice would read as
24+
// two providers.
25+
var thread = Thread.currentThread();
26+
var previous = thread.getContextClassLoader();
27+
try (var child = new URLClassLoader(new URL[0], ConductorProvider.class.getClassLoader())) {
28+
thread.setContextClassLoader(child);
29+
assertInstanceOf(BundledConductorProvider.class, ConductorProviders.load());
30+
} finally {
31+
thread.setContextClassLoader(previous);
32+
}
33+
}
34+
35+
@Test
36+
public void selectsTheOnlyProvider() {
37+
var provider = new BundledConductorProvider();
38+
assertSame(provider, ConductorProviders.select(List.of(provider), "1.3.0"));
39+
}
40+
41+
@Test
42+
public void noProviderNamesTheReleaseToAdd() {
43+
var e =
44+
assertThrows(
45+
IllegalStateException.class, () -> ConductorProviders.select(List.of(), "1.3.0"));
46+
assertEquals(
47+
"Connecting to DBOS Conductor requires dev.dbos:transact-enterprise on the class path."
48+
+ " Add dev.dbos:transact-enterprise:1.3.0, the same version as dev.dbos:transact",
49+
e.getMessage());
50+
}
51+
52+
@Test
53+
public void noProviderOnAPreviewNamesTheMinor() {
54+
var e =
55+
assertThrows(
56+
IllegalStateException.class, () -> ConductorProviders.select(List.of(), "1.3.0-m25"));
57+
assertEquals(
58+
"Connecting to DBOS Conductor requires dev.dbos:transact-enterprise on the class path."
59+
+ " Add a dev.dbos:transact-enterprise 1.3 build, as dev.dbos:transact is 1.3.0-m25",
60+
e.getMessage());
61+
}
62+
63+
@Test
64+
public void moreThanOneProviderIsRejected() {
65+
var providers =
66+
List.<ConductorProvider>of(new BundledConductorProvider(), new BundledConductorProvider());
67+
assertThrows(IllegalStateException.class, () -> ConductorProviders.select(providers, "1.3.0"));
68+
}
69+
}

0 commit comments

Comments
 (0)