feat: pin running instances to immutable published definition versions

Replace register/replace with publish so new graphs can ship without rewriting old ones, and keep in-flight work on the version it started with.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-16 10:30:14 +08:00
co-authored by Cursor
parent 8bc628613d
commit 9bda7c417c
35 changed files with 688 additions and 382 deletions
@@ -8,15 +8,15 @@ import com.jetlumen.ordo.api.query.TaskQuery;
import java.util.List;
import java.util.Optional;
/** Public entry point for definition registration and approval operations. */
/** Public entry point for definition publication and approval operations. */
public interface OrdoEngine {
void register(ProcessDefinition definition);
/**
* Inserts the definition, or replaces its entire graph when the id already exists.
* Refuses replacement while any instance of this definition is {@code RUNNING}.
* Publishes an immutable graph version. First publication of an id is version 1.
* If the graph matches the latest version, returns that version without inserting.
* Otherwise inserts {@code latest + 1}. Running instances do not block publication.
*/
void replace(ProcessDefinition definition);
ProcessDefinition publish(ProcessDefinition definition);
default ProcessInstance start(String definitionId, String initiator) {
return start(definitionId, initiator, ProcessContext.empty());
}
@@ -49,15 +49,22 @@ public interface OrdoEngine {
List<ApprovalTask> findPendingTasksByAssignee(String assignee);
List<ApprovalTask> findPendingTasksByInstanceId(String instanceId);
Optional<ProcessDefinition> findDefinition(String definitionId);
Optional<ProcessDefinition> findDefinition(String definitionId, int version);
/** Paginated, filterable task query; see {@link com.jetlumen.ordo.api.repository.ApprovalTaskRepository#query}. */
Page<ApprovalTask> queryTasks(TaskQuery query, PageRequest pageRequest);
/** Paginated, filterable instance query; see {@link com.jetlumen.ordo.api.repository.ProcessInstanceRepository#query}. */
Page<ProcessInstance> queryInstances(InstanceQuery query, PageRequest pageRequest);
/** Paginated listing of all registered process definitions. */
/** Paginated listing of the latest version of each process definition, ordered by id. */
Page<ProcessDefinition> queryDefinitions(PageRequest pageRequest);
/** Paginated versions of one definition, newest version first. */
Page<ProcessDefinition> queryDefinitionVersions(String definitionId, PageRequest pageRequest);
/** Instance timeline, oldest-first; see {@link com.jetlumen.ordo.api.repository.ProcessHistoryRepository#query}. */
Page<ProcessEvent> queryHistory(String instanceId, PageRequest pageRequest);
@@ -8,9 +8,13 @@ import java.util.Objects;
import java.util.Set;
/** Immutable blueprint for an approval process with explicit step transitions. */
public record ProcessDefinition(String id, String name, List<ApprovalStep> steps, List<StepTransition> transitions) {
public record ProcessDefinition(String id, int version, String name, List<ApprovalStep> steps,
List<StepTransition> transitions) {
public ProcessDefinition {
ApprovalStep.requireText(id, "definition id");
if (version < 0) {
throw new IllegalArgumentException("definition version must not be negative");
}
ApprovalStep.requireText(name, "definition name");
steps = List.copyOf(steps);
if (steps.isEmpty()) {
@@ -52,6 +56,24 @@ public record ProcessDefinition(String id, String name, List<ApprovalStep> steps
.toList();
}
/** Unpublished graph; the engine assigns a version on {@code publish}. */
public ProcessDefinition(String id, String name, List<ApprovalStep> steps, List<StepTransition> transitions) {
this(id, 0, name, steps, transitions);
}
public ProcessDefinition withVersion(int version) {
return new ProcessDefinition(id, version, name, steps, transitions);
}
/** Equality of the executable graph, ignoring assigned version. */
public boolean sameGraph(ProcessDefinition other) {
Objects.requireNonNull(other, "other must not be null");
return id.equals(other.id)
&& name.equals(other.name)
&& steps.equals(other.steps)
&& transitions.equals(other.transitions);
}
/**
* Builds a definition whose transitions mirror the former linear steps order: each step
* unconditionally advances to the next, and the last step unconditionally ends.
@@ -112,6 +112,7 @@ public final class ProcessDefinitionParser {
private record DefinitionDocument(
String id,
String name,
Integer version,
String startStep,
List<StepDocument> steps,
List<TransitionDocument> transitions) {
@@ -2,6 +2,6 @@ package com.jetlumen.ordo.api;
import java.time.Instant;
public record ProcessInstance(String id, String definitionId, String initiator, ProcessStatus status,
Instant startedAt, Instant finishedAt, ProcessContext context) {
public record ProcessInstance(String id, String definitionId, int definitionVersion, String initiator,
ProcessStatus status, Instant startedAt, Instant finishedAt, ProcessContext context) {
}
@@ -1,7 +0,0 @@
package com.jetlumen.ordo.api.exception;
public final class DefinitionAlreadyExistsException extends OrdoException {
public DefinitionAlreadyExistsException(String definitionId) {
super("definition already exists: " + definitionId);
}
}
@@ -1,7 +0,0 @@
package com.jetlumen.ordo.api.exception;
public final class DefinitionInUseException extends OrdoException {
public DefinitionInUseException(String definitionId) {
super("definition in use: " + definitionId);
}
}
@@ -4,4 +4,8 @@ public final class DefinitionNotFoundException extends OrdoException {
public DefinitionNotFoundException(String definitionId) {
super("definition not found: " + definitionId);
}
public DefinitionNotFoundException(String definitionId, int version) {
super("definition not found: " + definitionId + " version " + version);
}
}
@@ -6,23 +6,21 @@ import com.jetlumen.ordo.api.query.PageRequest;
import java.util.Optional;
/** Storage port for process definitions. */
/** Storage port for immutable process definition versions. */
public interface ProcessDefinitionRepository {
/**
* Inserts the definition if no definition with the same id exists.
*
* @return true if the definition was inserted, false if a definition with the same id already exists
* Inserts version 1 when the id is new, returns the latest version when the graph is unchanged,
* otherwise inserts {@code latest + 1}.
*/
boolean insertIfAbsent(ProcessDefinition definition);
ProcessDefinition publish(ProcessDefinition definition);
/**
* Inserts the definition, or replaces its name, steps, candidates and transitions
* when the id already exists.
*/
void upsert(ProcessDefinition definition);
Optional<ProcessDefinition> findLatest(String definitionId);
Optional<ProcessDefinition> findById(String definitionId);
Optional<ProcessDefinition> find(String definitionId, int version);
/** Paginated listing of all registered definitions, ordered by id ascending. */
/** Paginated listing of the latest version of each definition, ordered by id ascending. */
Page<ProcessDefinition> findAll(PageRequest pageRequest);
/** Paginated versions of one definition, ordered by version descending. */
Page<ProcessDefinition> findVersions(String definitionId, PageRequest pageRequest);
}
@@ -38,6 +38,25 @@ class ProcessDefinitionParserTest {
));
assertEquals(expected, ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON));
assertEquals(0, ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON).version());
}
@Test
void ignoresVersionInJson() {
String json = """
{
"id": "leave",
"name": "Leave request",
"version": 9,
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
],
"transitions": [
{ "from": "manager", "to": null }
]
}
""";
assertEquals(0, ProcessDefinitionParser.fromJson(json).version());
}
@Test