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
6 changes: 6 additions & 0 deletions docs/content/docs/java/getting-started/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ Each classifier jar carries only its own library; the runtime resolves the
matching one from the classpath. Deploying to several platforms from one build?
Add multiple classifier dependencies — extra platforms cost nothing at runtime.

Anything outside that list — Windows on ARM, or an OS beyond Linux, macOS, and
Windows — has no published artifact. The loader fails at startup with an
`UnsatisfiedLinkError` naming the platform it detected rather than trying a
binary built for another one. Those platforms are still supported by building
the native library from source and pointing `-Dtaskito.native.lib` at it.

At runtime the platform binary is extracted (content-addressed, safe under
concurrent processes) and loaded. Two system properties tune this:
`-Dtaskito.native.lib=/abs/path` loads an explicit library instead — for local
Expand Down
7 changes: 5 additions & 2 deletions sdks/java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ dependencies {
Deploying one artifact to several platforms? Add multiple classifier
dependencies — each jar carries only its own library, and the loader picks the
right one at runtime. Supplying your own build instead (e.g. a custom feature
set): skip the classifier and point `-Dtaskito.native.lib=/path/to/library` at
it — the classifier-free main jar works with no bundled native.
set, or a platform with no published artifact such as Windows on ARM): skip the
classifier and point `-Dtaskito.native.lib=/path/to/library` at it — the
classifier-free main jar works with no bundled native. On an unpublished
platform the loader fails at startup naming the platform it detected, rather
than loading a binary built for a different one.

```xml
<!-- Maven: the processor is wired through the compiler plugin, not a dependency -->
Expand Down
43 changes: 42 additions & 1 deletion sdks/java/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@

import net.ltgt.gradle.errorprone.CheckSeverity
import net.ltgt.gradle.errorprone.errorprone

plugins {
`java-library`
checkstyle
id("com.diffplug.spotless") version "7.2.1"
id("com.vanniktech.maven.publish") version "0.37.0"
id("net.ltgt.errorprone") version "5.1.0"
}

java {
Expand All @@ -17,6 +21,32 @@ java {

tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
// Error Prone is carried only for NullAway; every other check stays off so the
// build fails on real nullness regressions and nothing else.
options.errorprone {
isEnabled = false
}
}

// NullAway makes the JSpecify annotations load-bearing: in a @NullMarked package
// an unannotated reference is non-null, and dereferencing a @Nullable one fails
// the build. OnlyNullMarked keeps the `package-info.java` annotations the single
// source of truth — no package list duplicated here. Tests deliberately feed
// nulls to assert the error paths, so they stay out.
tasks.named<JavaCompile>("compileJava") {
options.errorprone {
isEnabled = true
disableAllChecks.set(true)
check("NullAway", CheckSeverity.ERROR)
option("NullAway:OnlyNullMarked", "true")
option("NullAway:JSpecifyMode", "true")
// picocli assigns command fields reflectively after construction, so the
// constructor-initialization check cannot see them being set.
option(
"NullAway:ExcludedFieldAnnotations",
"picocli.CommandLine.Option,picocli.CommandLine.Parameters,picocli.CommandLine.ParentCommand,picocli.CommandLine.Spec",
)
}
}

repositories {
Expand Down Expand Up @@ -80,7 +110,14 @@ sourceSets["test"].java.srcDir(
)

dependencies {
// Nullness contract of the public API. `api` so consumers' own null checkers
// (and Kotlin's platform-type inference) resolve the annotations; the jar is
// a few KB with no transitive dependencies.
api("org.jspecify:jspecify:${property("jspecifyVersion")}")
api("com.fasterxml.jackson.core:jackson-databind:2.17.2")

errorprone("com.google.errorprone:error_prone_core:${property("errorProneVersion")}")
errorprone("com.uber.nullaway:nullaway:${property("nullAwayVersion")}")
implementation("info.picocli:picocli:4.7.6")

// Optional: the MessagePack serializer. Compiled against, not bundled — a
Expand Down Expand Up @@ -127,7 +164,11 @@ val crateDir = layout.projectDirectory.dir("../../crates/taskito-java")
val cargoTargetDir = layout.projectDirectory.dir("../../target")
val nativeStaging = layout.buildDirectory.dir("native")

/** Every platform published as a native classifier artifact. */
/**
* Every platform published as a native classifier artifact. Mirrored by
* `NativeLoader.PUBLISHED_PLATFORMS`, which fails with a clear message on anything
* else (Windows on ARM included) instead of reaching for a binary that isn't there.
*/
val nativePlatforms = listOf("linux-x86_64", "linux-aarch64", "osx-x86_64", "osx-aarch64", "windows-x86_64")

// Build the native library for the local platform.
Expand Down
7 changes: 7 additions & 0 deletions sdks/java/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,10 @@
# rewritten by scripts/version.mjs; do not hand-edit it.
group=org.byteveda
version=0.21.0

# Null-safety toolchain, shared by every published Java module so the three
# build scripts can't drift apart on versions. Error Prone is capped at 2.42.0:
# 2.43.0+ is compiled for Java 21, and the baseline CI leg runs javac on JDK 17.
jspecifyVersion=1.0.0
errorProneVersion=2.42.0
nullAwayVersion=0.13.8
25 changes: 25 additions & 0 deletions sdks/java/processor/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
// Standalone compile-time annotation processor for @TaskHandler. Dependency-free
// (reads annotations structurally via javax.lang.model), so it never forms a
// cycle with the runtime it serves. Consumers add it via `annotationProcessor`.
import net.ltgt.gradle.errorprone.CheckSeverity
import net.ltgt.gradle.errorprone.errorprone

plugins {
`java-library`
checkstyle
id("com.diffplug.spotless") version "7.2.1"
id("com.vanniktech.maven.publish") version "0.37.0"
id("net.ltgt.errorprone") version "5.1.0"
}

mavenPublishing {
Expand Down Expand Up @@ -49,6 +53,12 @@ repositories {
mavenCentral()
}

dependencies {
errorprone("com.google.errorprone:error_prone_core:${property("errorProneVersion")}")
errorprone("com.uber.nullaway:nullaway:${property("nullAwayVersion")}")
compileOnly("org.jspecify:jspecify:${property("jspecifyVersion")}")
}

spotless {
java {
target("src/**/*.java")
Expand All @@ -64,3 +74,18 @@ checkstyle {
configFile = file("../config/checkstyle/checkstyle.xml")
isIgnoreFailures = false
}

// Null-safety: @NullMarked sources checked by NullAway, matching the SDK module.
tasks.withType<JavaCompile>().configureEach {
options.errorprone { isEnabled = false }
}

tasks.named<JavaCompile>("compileJava") {
options.errorprone {
isEnabled = true
disableAllChecks.set(true)
check("NullAway", CheckSeverity.ERROR)
option("NullAway:OnlyNullMarked", "true")
option("NullAway:JSpecifyMode", "true")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
Expand All @@ -20,6 +21,7 @@
import javax.lang.model.element.VariableElement;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import org.jspecify.annotations.Nullable;

/**
* Generates, for each class with {@code @TaskHandler} methods, a {@code <Class>Tasks}
Expand Down Expand Up @@ -219,7 +221,7 @@ private String options(ExecutableElement method) {
}

/** Emit a {@code .circuitBreaker(...)} call when the annotation configures a threshold. */
private void appendCircuitBreaker(StringBuilder chain, AnnotationMirror mirror) {
private void appendCircuitBreaker(StringBuilder chain, @Nullable AnnotationMirror mirror) {
long threshold = longValue(mirror, "circuitBreakerThreshold", 0);
if (threshold <= 0) {
return;
Expand Down Expand Up @@ -250,7 +252,7 @@ private String handlerExpr(ExecutableElement method) {
StringBuilder args = new StringBuilder("payload");
for (int i = 1; i < params.size(); i++) {
args.append(", Resources.use(\"")
.append(escape(resourceName(params.get(i))))
.append(escape(requireResourceName(params.get(i))))
.append("\")");
}
if (isVoid(method)) {
Expand Down Expand Up @@ -283,8 +285,9 @@ private String codecsChain(ExecutableElement method) {

/** Whether the method or its enclosing class carries the annotation {@code fqn}. */
private boolean hasAnnotation(ExecutableElement method, String fqn) {
Element enclosing = Objects.requireNonNull(method.getEnclosingElement(), "a method has an enclosing type");
return hasAnnotation(method.getAnnotationMirrors(), fqn)
|| hasAnnotation(method.getEnclosingElement().getAnnotationMirrors(), fqn);
|| hasAnnotation(enclosing.getAnnotationMirrors(), fqn);
}

private boolean hasAnnotation(List<? extends AnnotationMirror> mirrors, String fqn) {
Expand All @@ -297,7 +300,12 @@ private boolean hasAnnotation(List<? extends AnnotationMirror> mirrors, String f
}

/** The {@code @Resource} value on a parameter, or null if it is not annotated. */
private String resourceName(VariableElement param) {
/** The injected resource name of a non-payload parameter, which validation has already required. */
private String requireResourceName(VariableElement param) {
return Objects.requireNonNull(resourceName(param), "a non-payload parameter must carry a resource name");
}

private @Nullable String resourceName(VariableElement param) {
for (AnnotationMirror annotation : param.getAnnotationMirrors()) {
if (annotation.getAnnotationType().toString().equals(RESOURCE)) {
Object value = rawValue(annotation, "value");
Expand All @@ -312,7 +320,7 @@ private String taskName(ExecutableElement method) {
return value.isEmpty() ? method.getSimpleName().toString() : value;
}

private AnnotationMirror mirror(ExecutableElement method) {
private @Nullable AnnotationMirror mirror(ExecutableElement method) {
for (AnnotationMirror mirror : method.getAnnotationMirrors()) {
if (mirror.getAnnotationType().toString().equals(ANNOTATION)) {
return mirror;
Expand All @@ -321,27 +329,27 @@ private AnnotationMirror mirror(ExecutableElement method) {
return null;
}

private String stringValue(AnnotationMirror mirror, String key, String fallback) {
private String stringValue(@Nullable AnnotationMirror mirror, String key, String fallback) {
Object value = rawValue(mirror, key);
return value == null ? fallback : value.toString();
}

private long longValue(AnnotationMirror mirror, String key, long fallback) {
private long longValue(@Nullable AnnotationMirror mirror, String key, long fallback) {
Object value = rawValue(mirror, key);
return value instanceof Number ? ((Number) value).longValue() : fallback;
}

private boolean boolValue(AnnotationMirror mirror, String key, boolean fallback) {
private boolean boolValue(@Nullable AnnotationMirror mirror, String key, boolean fallback) {
Object value = rawValue(mirror, key);
return value instanceof Boolean ? (Boolean) value : fallback;
}

private double doubleValue(AnnotationMirror mirror, String key, double fallback) {
private double doubleValue(@Nullable AnnotationMirror mirror, String key, double fallback) {
Object value = rawValue(mirror, key);
return value instanceof Number ? ((Number) value).doubleValue() : fallback;
}

private Object rawValue(AnnotationMirror mirror, String key) {
private @Nullable Object rawValue(@Nullable AnnotationMirror mirror, String key) {
if (mirror == null) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
/** Compile-time annotation processor that turns {@code @TaskHandler} into typed task companions. */
@NullMarked
package org.byteveda.taskito.processor;

import org.jspecify.annotations.NullMarked;
21 changes: 21 additions & 0 deletions sdks/java/spring/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// taskito-spring: a Spring Boot 3 starter that auto-configures a Taskito bean.
// Spring Boot 3 requires Java 17, which matches the SDK baseline.
import net.ltgt.gradle.errorprone.CheckSeverity
import net.ltgt.gradle.errorprone.errorprone

plugins {
`java-library`
checkstyle
id("com.diffplug.spotless") version "7.2.1"
id("com.vanniktech.maven.publish") version "0.37.0"
id("net.ltgt.errorprone") version "5.1.0"
}

mavenPublishing {
Expand Down Expand Up @@ -47,6 +51,8 @@ repositories { mavenCentral() }
val springBoot = "3.3.5"

dependencies {
errorprone("com.google.errorprone:error_prone_core:${property("errorProneVersion")}")
errorprone("com.uber.nullaway:nullaway:${property("nullAwayVersion")}")
api(project(":"))
// The runtime jar is native-free; tests auto-configure a real Taskito bean,
// so stage the host-platform library from the root build.
Expand Down Expand Up @@ -82,3 +88,18 @@ checkstyle {
}

tasks.test { useJUnitPlatform() }

// Null-safety: @NullMarked sources checked by NullAway, matching the SDK module.
tasks.withType<JavaCompile>().configureEach {
options.errorprone { isEnabled = false }
}

tasks.named<JavaCompile>("compileJava") {
options.errorprone {
isEnabled = true
disableAllChecks.set(true)
check("NullAway", CheckSeverity.ERROR)
option("NullAway:OnlyNullMarked", "true")
option("NullAway:JSpecifyMode", "true")
}
}
Original file line number Diff line number Diff line change
@@ -1,40 +1,41 @@
package org.byteveda.taskito.spring;

import org.jspecify.annotations.Nullable;
import org.springframework.boot.context.properties.ConfigurationProperties;

/** Configuration for the auto-configured {@link org.byteveda.taskito.Taskito} bean, bound from {@code taskito.*}. */
@ConfigurationProperties(prefix = "taskito")
public class TaskitoProperties {
/** Connection URL / DSN (e.g. a SQLite path, or a {@code postgres://}/{@code redis://} URL). */
private String url;
private @Nullable String url;

/** Connection-pool size; unset uses the backend default. */
private Integer poolSize;
private @Nullable Integer poolSize;

/** Optional namespace isolating this app's jobs within a shared store. */
private String namespace;
private @Nullable String namespace;

public String getUrl() {
public @Nullable String getUrl() {
return url;
}

public void setUrl(String url) {
public void setUrl(@Nullable String url) {
this.url = url;
}

public Integer getPoolSize() {
public @Nullable Integer getPoolSize() {
return poolSize;
}

public void setPoolSize(Integer poolSize) {
public void setPoolSize(@Nullable Integer poolSize) {
this.poolSize = poolSize;
}

public String getNamespace() {
public @Nullable String getNamespace() {
return namespace;
}

public void setNamespace(String namespace) {
public void setNamespace(@Nullable String namespace) {
this.namespace = namespace;
}

Expand All @@ -60,10 +61,10 @@ public static class Dashboard {
private boolean authEnabled = false;

/** Optional shared token gating {@code /api/*}; overrides the session flow. */
private String token;
private @Nullable String token;

/** Optional unpacked SPA directory; null auto-discovers the bundled assets. */
private String staticDir;
private @Nullable String staticDir;

/** Whether to keep the {@code Secure} cookie attribute (drop it for local HTTP). */
private boolean secureCookies = true;
Expand Down Expand Up @@ -92,19 +93,19 @@ public void setAuthEnabled(boolean authEnabled) {
this.authEnabled = authEnabled;
}

public String getToken() {
public @Nullable String getToken() {
return token;
}

public void setToken(String token) {
public void setToken(@Nullable String token) {
this.token = token;
}

public String getStaticDir() {
public @Nullable String getStaticDir() {
return staticDir;
}

public void setStaticDir(String staticDir) {
public void setStaticDir(@Nullable String staticDir) {
this.staticDir = staticDir;
}

Expand Down
Loading