Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
java-version: '25'

- name: Set up Gradle
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4

- name: Build and test
run: ./gradlew build --stacktrace
2 changes: 1 addition & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:

- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@a26b07d8c778b0c43f64618e5fd29fd0f8af72e8 # v1.0.250
uses: anthropics/claude-code-action@8041f1d17e0e7d2f2cfe35002962e9f26237c0bb # v1.0.263
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:

- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@a26b07d8c778b0c43f64618e5fd29fd0f8af72e8 # v1.0.250
uses: anthropics/claude-code-action@8041f1d17e0e7d2f2cfe35002962e9f26237c0bb # v1.0.263
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

Expand Down
18 changes: 7 additions & 11 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A service that declares nothing is in `Application`.
* A `StartOrder`, passed to `Bootable`, defines the phases and how each service's ordering is read, so an app can add phases, replace them, or place a service it didn't write.
The default is `DefaultStartOrder`.
* Services left unordered start in the order of a `StartOrder.Tiebreaker`: `ByName` (the default), or `Shuffled(seed)`, which surfaces missing milestones and logs its seed.
`boot.service.order.tiebreaker.type = Shuffled` in config (or `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled`) shuffles them, e.g. in `application-local.conf` or test config, and `boot.service.order.tiebreaker.seed` replays a logged shuffle; `ServiceOrderConfig` holds these settings.
`boot.service.order.tiebreaker._type = Shuffled` in config (or `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled`) shuffles them, e.g. in `application-local.conf` or test config, and `boot.service.order.tiebreaker.seed` replays a logged shuffle; `ServiceOrderConfig` holds these settings.
* `startPlan(services, startOrder, tiebreaker)` computes the order, so tests can check it.
A plan that can't be met throws a `StartOrderException` listing every problem: an unknown phase, a missing required milestone, a milestone provided in a later phase, or a cycle.
* `boot-db-jdbc` adds `DbMilestones.DataSourceReady` and `DbMilestones.SchemaMigrated`.
Expand All @@ -38,21 +38,17 @@ With Kodein, `bootableModule` uses `StartOrder` and `StartOrder.Tiebreaker` bind

`LifecycleController.priority()` is unchanged.

=== Config enum values match in any case
=== Config enum values and sealed subtype names match in any case

`hopliteConfigBinder` matches enum values against the entries' names ignoring case, so `schemaVerification = fail`, `Fail`, and `FAIL` all read the same.
This applies to every enum in config, so config values needn't follow how a Kotlin enum's entries are cased.
Sealed subtype names match ignoring case too, so `Shuffled` and `shuffled` select the same subtype.

=== Config selects sealed subtypes with `type`
=== Environment variables select sealed subtypes

`hopliteConfigBinder` selects a sealed type's subtype by a `type` key, instead of Hoplite's default `_type`, so an environment variable can select one too: `_` separates the path, so no environment variable can spell `_type`.
For example, `tiebreaker { type = Shuffled, seed = 42 }`, or `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled` and `BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`.
Subtype names match only in their own case.

==== Breaking changes

* Config that selects a sealed subtype with `_type` must use `type` instead.
* A sealed subtype can't have a property named `type`, as that key now selects the subtype.
Bootable now uses Hoplite 3.0.5, where an environment variable selects a sealed type's subtype by naming it as the sealed type's own value, since no environment variable can spell `_type`: `_` separates the path.
For example, `tiebreaker { _type = Shuffled, seed = 42 }` in config, or `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled` and `BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`.
An environment variable that selects a subtype overrides the one config selects, and config can still set the subtype's properties.

=== A failed start shuts down the services that started

Expand Down
2 changes: 1 addition & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ NOTE: Importing the Metro DI artifact does bring annotations to your application
* Allows registration of lifecycle controllers, which can be used to control the lifecycle of application services in various ways.
The basic built-in implementation of this is the `AppStartStopLifecycleController` which starts application services on app start, and shuts them down on app close, in reverse.
Services start phase by phase (infrastructure, initialization, application, serving), and, within a phase, after the milestones they declare; see link:docs/service-ordering.adoc[Service ordering].
Services left unordered start by name, or shuffled with `boot.service.order.tiebreaker.type = Shuffled` in config (or `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled`).
Services left unordered start by name, or shuffled with `boot.service.order.tiebreaker._type = Shuffled` in config (or `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled`).
Shuffling is useful in `application-local.conf` and test config, to surface services that rely on an order they don't declare.
An app can pass its own `StartOrder` to add phases; see link:./examples/start-order[examples/start-order], which adds a phase after `Serving` for a service that announces the app is ready once its server is listening.
Another basic implementation, intended to be used in conjunction with `AppStartStopLifecycleController`, is the `StopSignalHandlerLifecycleController`, which registers signal handlers, which, when received, cleanly shuts the system down.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ private val shellPromptEnvVars = setOf("PS1", "PS2", "PS4", "PROMPT_COMMAND")
*
* [environmentVariables] supplies the environment variables to read (defaults to [System.getenv]).
*
* Enum values match the entries' names in any case, so `fail`, `Fail`, and `FAIL` are all `Fail`.
* A sealed type is chosen by its subtype's name, in a `_type` key next to the subtype's properties, e.g.
* `tiebreaker { _type = Shuffled, seed = 42 }`, or, in the environment, as the sealed type's own value, e.g.
* `TIEBREAKER=Shuffled` and `TIEBREAKER_SEED=42`.
*
* A sealed type is chosen by its subtype's name, in a `type` key next to the subtype's properties, e.g.
* `tiebreaker { type = Shuffled, seed = 42 }`, or `TIEBREAKER_TYPE=Shuffled` and `TIEBREAKER_SEED=42` in the
* environment. Unlike enum values, subtype names match only in their own case.
* Enum values and sealed subtype names match in any case, so `fail`, `Fail`, and `FAIL` are all `Fail`.
*
* Returns a plain [ConfigBinder]; decode config sections with `binder.config<T>("key")`:
*
Expand Down Expand Up @@ -111,8 +111,7 @@ fun hopliteConfigBinder(
strict()
}
}
// `type`, not Hoplite's default `_type`: an environment variable can't spell `_type`, as `_` separates the path
.withExplicitSealedTypes("type")
.withExplicitSealedTypes()
.withResolveTypesCaseInsensitive()
.build()
.configBinder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,26 +62,43 @@ val ServiceOrderConfigTest by testSuite {
assert(serviceOrderConfig(binder(emptyMap())) == ServiceOrderConfig())
}

test("is read from environment variables, the tiebreaker by its type") {
val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER_TYPE" to "Shuffled", "BOOT_SERVICE_ORDER_TIEBREAKER_SEED" to "42")
test("is read from environment variables, the tiebreaker by its own value") {
val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to "Shuffled", "BOOT_SERVICE_ORDER_TIEBREAKER_SEED" to "42")
assert(serviceOrderConfig(binder(env)).tiebreaker == StartOrder.Tiebreaker.Shuffled(42))

val byName = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER_TYPE" to "ByName")
val byName = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to "ByName")
assert(serviceOrderConfig(binder(byName)).tiebreaker == StartOrder.Tiebreaker.ByName)
}

test("shuffles with a new seed when none is given") {
val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER_TYPE" to "Shuffled")
val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to "Shuffled")
assert(serviceOrderConfig(binder(env)).tiebreaker is StartOrder.Tiebreaker.Shuffled)
}

test("is read from config") {
val binder = hocon("boot.service.order.tiebreaker { type = Shuffled, seed = 42 }")
val binder = hocon("boot.service.order.tiebreaker { _type = Shuffled, seed = 42 }")
assert(serviceOrderConfig(binder).tiebreaker == StartOrder.Tiebreaker.Shuffled(42))
}

test("the tiebreaker's subtype name matches in any case") {
listOf("shuffled", "SHUFFLED").forEach {
val env = mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to it, "BOOT_SERVICE_ORDER_TIEBREAKER_SEED" to "42")
assert(serviceOrderConfig(binder(env)).tiebreaker == StartOrder.Tiebreaker.Shuffled(42))
}
}

test("an environment variable selects the tiebreaker over config") {
val conf = "boot.service.order.tiebreaker { _type = ByName, seed = 42 }"
val binder = hopliteConfigBinder(
report = false,
environmentVariables = { mapOf("BOOT_SERVICE_ORDER_TIEBREAKER" to "Shuffled") },
withConfigStage2 = { addPropertySource(PropertySource.string(conf, "conf")) },
)
assert(serviceOrderConfig(binder).tiebreaker == StartOrder.Tiebreaker.Shuffled(42))
}

test("in strict mode, a seed without a shuffle fails") {
val binder = hocon("boot.service.order.tiebreaker { type = ByName, seed = 42 }", strict = true)
val binder = hocon("boot.service.order.tiebreaker { _type = ByName, seed = 42 }", strict = true)
val e = runCatching { serviceOrderConfig(binder) }.exceptionOrNull()
val message = e?.message.orEmpty()
assert(e is ConfigException && "'boot.service.order.tiebreaker.seed'" in message && "unused" in message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ import com.github.rocketraman.bootable.config.config
* ```
* boot.service.order {
* tiebreaker {
* type = Shuffled # or ByName, the default
* _type = Shuffled # or ByName, the default
* seed = 42 # optional: replays a logged shuffle
* }
* }
* ```
*
* or, as environment variables, `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled` and
* `BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`. The `type` is the [StartOrder.Tiebreaker] subtype's name, and the other
* or, as environment variables, `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled` and
* `BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`. The `_type` is the [StartOrder.Tiebreaker] subtype's name, and the other
* keys are its properties.
*
* Set them like any other config, e.g. `type = Shuffled` in `application-local.conf` or test config, where a
* Set them like any other config, e.g. `_type = Shuffled` in `application-local.conf` or test config, where a
* surprise is cheap, and the default in production, so it starts in the same order on every run.
*/
data class ServiceOrderConfig(
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ fun Project.configureDiVariants() {
if (!hasMetro && !hasKodein) return

val metroRuntime = "dev.zacsweers.metro:runtime:${rootProject.libs.versions.metro.get()}"
val kodeinDi = "org.kodein.di:kodein-di:7.25.0"
val kodeinDi = "org.kodein.di:kodein-di:7.33.0"

if (hasMetro) {
apply(plugin = "dev.zacsweers.metro")
Expand Down
12 changes: 6 additions & 6 deletions docs/service-ordering.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -447,13 +447,13 @@ on `boot`, and the environment is only set during `appInit`, after the plan is m
----
boot.service.order {
tiebreaker {
type = Shuffled # ByName, the default, if unset
_type = Shuffled # ByName, the default, if unset
seed = 42 # optional: replays a logged shuffle
}
}
----

Or, as environment variables, `BOOT_SERVICE_ORDER_TIEBREAKER_TYPE=Shuffled` and
Or, as environment variables, `BOOT_SERVICE_ORDER_TIEBREAKER=Shuffled` and
`BOOT_SERVICE_ORDER_TIEBREAKER_SEED=42`.

[source,kotlin]
Expand All @@ -466,10 +466,10 @@ fun serviceOrderConfig(binder: ConfigBinder): ServiceOrderConfig = binder.config
Bootable(appServices, tiebreaker = serviceOrderConfig(binder).tiebreaker)
----

Config names the sealed `StartOrder.Tiebreaker` itself: `type` is the subtype's name, and the other keys are its
properties, so the seed is `tiebreaker.seed`, where it means something. `hopliteConfigBinder` selects sealed
subtypes by a `type` key rather than Hoplite's default `_type`, which no environment variable can spell, since `_`
separates the path. Subtype names match only in their own case (`Shuffled`, not `shuffled`), unlike enum values.
Config names the sealed `StartOrder.Tiebreaker` itself: `_type` is the subtype's name, and the other keys are its
properties, so the seed is `tiebreaker.seed`, where it means something. No environment variable can spell `_type`,
since `_` separates the path, so an environment variable names the subtype as the tiebreaker's own value instead.
Subtype names match in any case, as enum values do.

`boot`'s Kodein and Metro flavors read it this way when the app has a `ConfigBinder`, so an app sets it in config
and writes no code; one without a `ConfigBinder` gets `ByName`. Keeping the tiebreaker apart from the `StartOrder`
Expand Down
12 changes: 6 additions & 6 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@
compose-multiplatform = "1.12.2"
dokka = "2.2.0"
exposed = "1.5.1"
flyway = "13.10.0"
graalvm-buildtools = "0.10.6"
flyway = "13.11.1"
graalvm-buildtools = "0.11.5"
hikari = "7.1.0"
hoplite = "3.0.3"
hoplite = "3.0.5"
# 4.0.0 is out, but npm tags it next rather than latest, and Ktor's HTMX attributes follow htmx 2
htmx = "2.0.11"
kilua = "0.0.36"
kotlin = "2.4.21"
kotlinx-coroutines = "1.10.1"
kotlinx-coroutines = "1.11.0"
kotlinx-html = "0.12.0"
kotlinx-serialization = "1.9.0"
kotlinx-serialization = "1.12.0"
ktor = "3.6.0"
log4j = "2.24.3"
log4j-kotlin-api = "1.5.0"
metro = "1.4.5"
mysql-connector-j = "26.7.0"
mysql-connector-j = "26.10.0"
postgresql = "42.7.14"
sqlite-jdbc = "3.53.4.0"
testcontainers = "2.0.5"
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-all.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
Expand Down
Loading