diff --git a/transact-jdbi-step-factory/src/main/java/dev/dbos/transact/jdbi/JdbiStepFactory.java b/transact-jdbi-step-factory/src/main/java/dev/dbos/transact/jdbi/JdbiStepFactory.java index 38de61f26..368f98e9c 100644 --- a/transact-jdbi-step-factory/src/main/java/dev/dbos/transact/jdbi/JdbiStepFactory.java +++ b/transact-jdbi-step-factory/src/main/java/dev/dbos/transact/jdbi/JdbiStepFactory.java @@ -7,6 +7,7 @@ import dev.dbos.transact.txstep.TxStepSchema; import dev.dbos.transact.workflow.internal.StepResult; +import java.util.Collection; import java.util.Objects; import java.util.Optional; @@ -24,6 +25,10 @@ * Lambdas passed to {@link #inStep} or {@link #useStep} receive a {@link Handle} with a transaction * already open; they must not call {@code commit} or {@code close} themselves. * + *
Create the factory before calling {@link dev.dbos.transact.DBOS#launch()}: DBOS needs to know + * every factory when it launches, so the constructor throws {@link IllegalStateException} after + * launch. + * *
{@code
* JdbiStepFactory factory = new JdbiStepFactory(dbos, Jdbi.create(dataSource));
*
@@ -232,6 +237,45 @@ protected void recordError(String workflowId, int stepId, Exception exception) {
}
}
+ @Override
+ protected void deleteCheckpoints(String workflowId, int fromStepId) {
+ inOwnTransaction(
+ h ->
+ h.createUpdate(TxStepSchema.deleteFromStepSql(schema))
+ .bind(0, workflowId)
+ .bind(1, fromStepId)
+ .execute());
+ }
+
+ @Override
+ protected void deleteCheckpoints(Collection workflowIds) {
+ inOwnTransaction(
+ h ->
+ h.createUpdate(TxStepSchema.deleteWorkflowsSql(schema))
+ .bindArray(0, String.class, workflowIds)
+ .execute());
+ }
+
+ // Jdbi's useTransaction does not commit a connection that arrives with autocommit off: it takes
+ // that for a transaction already in progress and leaves the commit to someone else. begin() and
+ // commit() on the handle commit either way.
+ private void inOwnTransaction(HandleConsumer work) {
+ try (var handle = jdbi.open()) {
+ handle.begin();
+ try {
+ work.useHandle(handle);
+ handle.commit();
+ } catch (RuntimeException e) {
+ try {
+ handle.rollback();
+ } catch (RuntimeException rollbackFailure) {
+ e.addSuppressed(rollbackFailure);
+ }
+ throw e;
+ }
+ }
+ }
+
private void recordResult(
Handle handle,
String workflowId,
diff --git a/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/DBOSTestAccess.java b/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/DBOSTestAccess.java
new file mode 100644
index 000000000..ff6e5faba
--- /dev/null
+++ b/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/DBOSTestAccess.java
@@ -0,0 +1,11 @@
+package dev.dbos.transact;
+
+import dev.dbos.transact.execution.DBOSExecutor;
+
+// Helper class to retrieve DBOS internals via package private methods
+public class DBOSTestAccess {
+
+ public static DBOSExecutor getDbosExecutor(DBOS dbos) {
+ return dbos.getDbosExecutor();
+ }
+}
diff --git a/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/jdbi/JdbiStepFactoryNoAutoCommitTest.java b/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/jdbi/JdbiStepFactoryNoAutoCommitTest.java
new file mode 100644
index 000000000..27cc4cc15
--- /dev/null
+++ b/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/jdbi/JdbiStepFactoryNoAutoCommitTest.java
@@ -0,0 +1,91 @@
+package dev.dbos.transact.jdbi;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import dev.dbos.transact.DBOS;
+import dev.dbos.transact.DBOSTestAccess;
+import dev.dbos.transact.utils.PgContainer;
+
+import java.sql.SQLException;
+import java.util.List;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.jdbi.v3.core.Jdbi;
+import org.junit.jupiter.api.AutoClose;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JdbiStepFactory on a pool configured without autocommit. A delete that did not commit explicitly
+ * would be rolled back when its connection went back to the pool.
+ */
+public class JdbiStepFactoryNoAutoCommitTest {
+ private static final String SCHEMA = "jdbi_no_autocommit";
+
+ @AutoClose final PgContainer pgContainer = new PgContainer();
+
+ @AutoClose DBOS dbos;
+ @AutoClose HikariDataSource dataSource;
+ @AutoClose HikariDataSource noAutoCommitDataSource;
+
+ @BeforeEach
+ void beforeEach() {
+ dataSource = pgContainer.dataSource();
+ var cfg = new HikariConfig();
+ cfg.setJdbcUrl(pgContainer.jdbcUrl());
+ cfg.setUsername(pgContainer.username());
+ cfg.setPassword(pgContainer.password());
+ cfg.setAutoCommit(false);
+ noAutoCommitDataSource = new HikariDataSource(cfg);
+
+ dbos = new DBOS(pgContainer.dbosConfig());
+ new JdbiStepFactory(dbos, Jdbi.create(noAutoCommitDataSource), SCHEMA);
+ dbos.launch();
+ }
+
+ private void insertCheckpoints(String workflowId, int... stepIds) throws SQLException {
+ try (var conn = dataSource.getConnection();
+ var stmt =
+ conn.prepareStatement(
+ "INSERT INTO %s.tx_step_outputs (workflow_id, step_id) VALUES (?, ?)"
+ .formatted(SCHEMA))) {
+ for (var stepId : stepIds) {
+ stmt.setString(1, workflowId);
+ stmt.setInt(2, stepId);
+ stmt.executeUpdate();
+ }
+ }
+ }
+
+ private int checkpoints(String workflowId) throws SQLException {
+ try (var conn = dataSource.getConnection();
+ var stmt =
+ conn.prepareStatement(
+ "SELECT COUNT(*) FROM %s.tx_step_outputs WHERE workflow_id = ?"
+ .formatted(SCHEMA))) {
+ stmt.setString(1, workflowId);
+ try (var rs = stmt.executeQuery()) {
+ rs.next();
+ return rs.getInt(1);
+ }
+ }
+ }
+
+ @Test
+ public void deletesCommit() throws Exception {
+ insertCheckpoints("wf-one", 0, 1, 2);
+ insertCheckpoints("wf-a", 0);
+ insertCheckpoints("wf-b", 0);
+ insertCheckpoints("wf-keep", 0);
+ var executor = DBOSTestAccess.getDbosExecutor(dbos);
+
+ executor.deleteStepCheckpoints("wf-one", 1);
+ assertEquals(1, checkpoints("wf-one"));
+
+ executor.deleteStepCheckpoints(List.of("wf-a", "wf-b"));
+ assertEquals(0, checkpoints("wf-a"));
+ assertEquals(0, checkpoints("wf-b"));
+ assertEquals(1, checkpoints("wf-keep"));
+ }
+}
diff --git a/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/jdbi/JdbiStepFactoryTest.java b/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/jdbi/JdbiStepFactoryTest.java
index 901a76be3..82d81cb7d 100644
--- a/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/jdbi/JdbiStepFactoryTest.java
+++ b/transact-jdbi-step-factory/src/test/java/dev/dbos/transact/jdbi/JdbiStepFactoryTest.java
@@ -7,6 +7,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.dbos.transact.DBOS;
+import dev.dbos.transact.DBOSTestAccess;
import dev.dbos.transact.config.DBOSConfig;
import dev.dbos.transact.context.WorkflowOptions;
import dev.dbos.transact.database.SystemDatabase;
@@ -17,6 +18,7 @@
import dev.dbos.transact.workflow.WorkflowHandle;
import java.sql.SQLException;
+import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
@@ -228,6 +230,30 @@ private int getGreetCount(String user) throws SQLException {
}
}
+ @Test
+ public void testDeleteCheckpointsThroughDbos() throws Exception {
+ var wfid = "wf-delete";
+ try (var _o = new WorkflowOptions(wfid).setContext()) {
+ proxy.insertWorkflow("deleteUser");
+ }
+ assertEquals(1, DBUtils.getTxStepRows(dataSource, wfid).size());
+
+ // DBOS reaches the factory's checkpoints through the store it registered, and the delete
+ // commits through the library's own transaction.
+ DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints(wfid, 0);
+ assertEquals(0, DBUtils.getTxStepRows(dataSource, wfid).size());
+
+ // The batch form, which binds the workflow IDs as an array.
+ for (var id : List.of("wf-batch-1", "wf-batch-2")) {
+ try (var _o = new WorkflowOptions(id).setContext()) {
+ proxy.insertWorkflow("deleteUser");
+ }
+ }
+ DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints(List.of("wf-batch-1", "wf-batch-2"));
+ assertEquals(0, DBUtils.getTxStepRows(dataSource, "wf-batch-1").size());
+ assertEquals(0, DBUtils.getTxStepRows(dataSource, "wf-batch-2").size());
+ }
+
@Test
public void testInsert() throws Exception {
var wfid = "wf1";
diff --git a/transact-jooq-step-factory/src/main/java/dev/dbos/transact/jooq/JooqStepFactory.java b/transact-jooq-step-factory/src/main/java/dev/dbos/transact/jooq/JooqStepFactory.java
index a0bd1829e..c38cc44a2 100644
--- a/transact-jooq-step-factory/src/main/java/dev/dbos/transact/jooq/JooqStepFactory.java
+++ b/transact-jooq-step-factory/src/main/java/dev/dbos/transact/jooq/JooqStepFactory.java
@@ -9,6 +9,7 @@
import dev.dbos.transact.txstep.TxStepSchema;
import dev.dbos.transact.workflow.internal.StepResult;
+import java.util.Collection;
import java.util.Objects;
import java.util.Optional;
@@ -27,6 +28,10 @@
* org.jooq.Configuration} with a transaction already open; they must not commit or close the
* underlying connection themselves.
*
+ * Create the factory before calling {@link dev.dbos.transact.DBOS#launch()}: DBOS needs to know
+ * every factory when it launches, so the constructor throws {@link IllegalStateException} after
+ * launch.
+ *
*
{@code
* JooqStepFactory factory = new JooqStepFactory(dbos, dslContext);
*
@@ -208,6 +213,21 @@ protected void recordError(String workflowId, int stepId, Exception exception) {
}
}
+ @Override
+ protected void deleteCheckpoints(String workflowId, int fromStepId) {
+ dsl.transaction(
+ trx -> trx.dsl().execute(TxStepSchema.deleteFromStepSql(schema), workflowId, fromStepId));
+ }
+
+ @Override
+ protected void deleteCheckpoints(Collection workflowIds) {
+ // Bound on the JDBC connection: jOOQ can only bind an array when the dialect is set to one
+ // that has arrays, and the DSLContext may have been built without one.
+ dsl.transaction(
+ trx ->
+ trx.dsl().connection(conn -> TxStepSchema.deleteWorkflows(conn, schema, workflowIds)));
+ }
+
private void recordResult(
DSLContext ctx,
String workflowId,
diff --git a/transact-jooq-step-factory/src/test/java/dev/dbos/transact/DBOSTestAccess.java b/transact-jooq-step-factory/src/test/java/dev/dbos/transact/DBOSTestAccess.java
new file mode 100644
index 000000000..ff6e5faba
--- /dev/null
+++ b/transact-jooq-step-factory/src/test/java/dev/dbos/transact/DBOSTestAccess.java
@@ -0,0 +1,11 @@
+package dev.dbos.transact;
+
+import dev.dbos.transact.execution.DBOSExecutor;
+
+// Helper class to retrieve DBOS internals via package private methods
+public class DBOSTestAccess {
+
+ public static DBOSExecutor getDbosExecutor(DBOS dbos) {
+ return dbos.getDbosExecutor();
+ }
+}
diff --git a/transact-jooq-step-factory/src/test/java/dev/dbos/transact/jooq/JooqStepFactoryNoAutoCommitTest.java b/transact-jooq-step-factory/src/test/java/dev/dbos/transact/jooq/JooqStepFactoryNoAutoCommitTest.java
new file mode 100644
index 000000000..58e788d68
--- /dev/null
+++ b/transact-jooq-step-factory/src/test/java/dev/dbos/transact/jooq/JooqStepFactoryNoAutoCommitTest.java
@@ -0,0 +1,93 @@
+package dev.dbos.transact.jooq;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import dev.dbos.transact.DBOS;
+import dev.dbos.transact.DBOSTestAccess;
+import dev.dbos.transact.utils.PgContainer;
+
+import java.sql.SQLException;
+import java.util.List;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.jooq.SQLDialect;
+import org.jooq.impl.DSL;
+import org.junit.jupiter.api.AutoClose;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * JooqStepFactory on a pool configured without autocommit, and with jOOQ's default dialect, which
+ * cannot bind arrays itself. A delete that did not commit explicitly would be rolled back when its
+ * connection went back to the pool.
+ */
+public class JooqStepFactoryNoAutoCommitTest {
+ private static final String SCHEMA = "jooq_no_autocommit";
+
+ @AutoClose final PgContainer pgContainer = new PgContainer();
+
+ @AutoClose DBOS dbos;
+ @AutoClose HikariDataSource dataSource;
+ @AutoClose HikariDataSource noAutoCommitDataSource;
+
+ @BeforeEach
+ void beforeEach() {
+ dataSource = pgContainer.dataSource();
+ var cfg = new HikariConfig();
+ cfg.setJdbcUrl(pgContainer.jdbcUrl());
+ cfg.setUsername(pgContainer.username());
+ cfg.setPassword(pgContainer.password());
+ cfg.setAutoCommit(false);
+ noAutoCommitDataSource = new HikariDataSource(cfg);
+
+ dbos = new DBOS(pgContainer.dbosConfig());
+ new JooqStepFactory(dbos, DSL.using(noAutoCommitDataSource, SQLDialect.DEFAULT), SCHEMA);
+ dbos.launch();
+ }
+
+ private void insertCheckpoints(String workflowId, int... stepIds) throws SQLException {
+ try (var conn = dataSource.getConnection();
+ var stmt =
+ conn.prepareStatement(
+ "INSERT INTO %s.tx_step_outputs (workflow_id, step_id) VALUES (?, ?)"
+ .formatted(SCHEMA))) {
+ for (var stepId : stepIds) {
+ stmt.setString(1, workflowId);
+ stmt.setInt(2, stepId);
+ stmt.executeUpdate();
+ }
+ }
+ }
+
+ private int checkpoints(String workflowId) throws SQLException {
+ try (var conn = dataSource.getConnection();
+ var stmt =
+ conn.prepareStatement(
+ "SELECT COUNT(*) FROM %s.tx_step_outputs WHERE workflow_id = ?"
+ .formatted(SCHEMA))) {
+ stmt.setString(1, workflowId);
+ try (var rs = stmt.executeQuery()) {
+ rs.next();
+ return rs.getInt(1);
+ }
+ }
+ }
+
+ @Test
+ public void deletesCommit() throws Exception {
+ insertCheckpoints("wf-one", 0, 1, 2);
+ insertCheckpoints("wf-a", 0);
+ insertCheckpoints("wf-b", 0);
+ insertCheckpoints("wf-keep", 0);
+ var executor = DBOSTestAccess.getDbosExecutor(dbos);
+
+ executor.deleteStepCheckpoints("wf-one", 1);
+ assertEquals(1, checkpoints("wf-one"));
+
+ executor.deleteStepCheckpoints(List.of("wf-a", "wf-b"));
+ assertEquals(0, checkpoints("wf-a"));
+ assertEquals(0, checkpoints("wf-b"));
+ assertEquals(1, checkpoints("wf-keep"));
+ }
+}
diff --git a/transact-jooq-step-factory/src/test/java/dev/dbos/transact/jooq/JooqStepFactoryTest.java b/transact-jooq-step-factory/src/test/java/dev/dbos/transact/jooq/JooqStepFactoryTest.java
index 65753ff39..c0dc12993 100644
--- a/transact-jooq-step-factory/src/test/java/dev/dbos/transact/jooq/JooqStepFactoryTest.java
+++ b/transact-jooq-step-factory/src/test/java/dev/dbos/transact/jooq/JooqStepFactoryTest.java
@@ -7,6 +7,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.dbos.transact.DBOS;
+import dev.dbos.transact.DBOSTestAccess;
import dev.dbos.transact.config.DBOSConfig;
import dev.dbos.transact.context.WorkflowOptions;
import dev.dbos.transact.database.SystemDatabase;
@@ -19,6 +20,7 @@
import dev.dbos.transact.workflow.WorkflowHandle;
import java.sql.SQLException;
+import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
@@ -218,6 +220,30 @@ private int getGreetCount(String user) throws SQLException {
}
}
+ @Test
+ public void testDeleteCheckpointsThroughDbos() throws Exception {
+ var wfid = "wf-delete";
+ try (var _o = new WorkflowOptions(wfid).setContext()) {
+ proxy.insertWorkflow("deleteUser");
+ }
+ assertEquals(1, DBUtils.getTxStepRows(dataSource, wfid).size());
+
+ // DBOS reaches the factory's checkpoints through the store it registered, and the delete
+ // commits through the library's own transaction.
+ DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints(wfid, 0);
+ assertEquals(0, DBUtils.getTxStepRows(dataSource, wfid).size());
+
+ // The batch form, which binds the workflow IDs as an array.
+ for (var id : List.of("wf-batch-1", "wf-batch-2")) {
+ try (var _o = new WorkflowOptions(id).setContext()) {
+ proxy.insertWorkflow("deleteUser");
+ }
+ }
+ DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints(List.of("wf-batch-1", "wf-batch-2"));
+ assertEquals(0, DBUtils.getTxStepRows(dataSource, "wf-batch-1").size());
+ assertEquals(0, DBUtils.getTxStepRows(dataSource, "wf-batch-2").size());
+ }
+
@Test
public void testInsert() throws Exception {
var wfid = "wf1";
diff --git a/transact-spring-txstep-starter/src/main/java/dev/dbos/transact/spring/txstep/TransactionalStepFactory.java b/transact-spring-txstep-starter/src/main/java/dev/dbos/transact/spring/txstep/TransactionalStepFactory.java
index 159e2f5a7..a59faee4c 100644
--- a/transact-spring-txstep-starter/src/main/java/dev/dbos/transact/spring/txstep/TransactionalStepFactory.java
+++ b/transact-spring-txstep-starter/src/main/java/dev/dbos/transact/spring/txstep/TransactionalStepFactory.java
@@ -6,6 +6,7 @@
import dev.dbos.transact.DBOS;
import dev.dbos.transact.database.SystemDatabase;
import dev.dbos.transact.execution.ThrowingSupplier;
+import dev.dbos.transact.internal.StepCheckpointStore;
import dev.dbos.transact.json.DBOSSerializer;
import dev.dbos.transact.json.SerializationUtil;
import dev.dbos.transact.txstep.PostgresStepFactory;
@@ -14,6 +15,7 @@
import java.sql.Connection;
import java.sql.SQLException;
+import java.util.Collection;
import java.util.Objects;
import java.util.Optional;
@@ -44,6 +46,10 @@ public class TransactionalStepFactory {
private final PlatformTransactionManager txManager;
private final String schema;
private final DBOSSerializer serializer;
+ // The store DBOS calls. A private class rather than this factory implementing the interface, so
+ // the interface's methods do not become public methods of the factory. One object, so a second
+ // initialize() does not add a second entry.
+ private final StepCheckpointStore checkpointStore = new CheckpointStore();
public TransactionalStepFactory(
DBOS dbos, DataSource dataSource, PlatformTransactionManager txManager, String schema) {
@@ -57,8 +63,12 @@ public TransactionalStepFactory(
/**
* Verifies the datasource is PostgreSQL and creates the {@code tx_step_outputs} table if it does
- * not already exist. Called lazily by {@code TransactionalStepRegistrar} only when annotated
- * methods are found — avoids any DB contact for applications that never use this starter.
+ * not already exist, then registers the factory's checkpoints with DBOS so it can delete them
+ * when it discards a workflow's history. Called lazily by {@code TransactionalStepRegistrar} only
+ * when annotated methods are found — avoids any DB contact for applications that never use this
+ * starter. The registrar runs before DBOS launches.
+ *
+ * @throws IllegalStateException if DBOS has already been launched
*/
public void initialize() {
try (var conn = dataSource.getConnection()) {
@@ -67,6 +77,42 @@ public void initialize() {
} catch (SQLException e) {
throw new RuntimeException(e);
}
+
+ // Registered here rather than in the constructor: until now the table may not exist.
+ dbos.integration().registerStepCheckpointStore(checkpointStore);
+ }
+
+ private final class CheckpointStore implements StepCheckpointStore {
+ @Override
+ public void deleteCheckpoints(String workflowId, int fromStepId) throws SQLException {
+ inTransaction(conn -> TxStepSchema.deleteFromStep(conn, schema, workflowId, fromStepId));
+ }
+
+ @Override
+ public void deleteCheckpoints(Collection workflowIds) throws SQLException {
+ if (!workflowIds.isEmpty()) {
+ inTransaction(conn -> TxStepSchema.deleteWorkflows(conn, schema, workflowIds));
+ }
+ }
+ }
+
+ @FunctionalInterface
+ private interface SqlWork {
+ void run(Connection conn) throws SQLException;
+ }
+
+ // A fresh connection with an explicit commit, as recordError does.
+ private void inTransaction(SqlWork work) throws SQLException {
+ try (var conn = dataSource.getConnection()) {
+ conn.setAutoCommit(false);
+ try {
+ work.run(conn);
+ conn.commit();
+ } catch (SQLException ex) {
+ conn.rollback();
+ throw ex;
+ }
+ }
}
private Optional checkExecution(String workflowId, int stepId, String stepName) {
diff --git a/transact-spring-txstep-starter/src/test/java/dev/dbos/transact/DBOSTestAccess.java b/transact-spring-txstep-starter/src/test/java/dev/dbos/transact/DBOSTestAccess.java
new file mode 100644
index 000000000..ff6e5faba
--- /dev/null
+++ b/transact-spring-txstep-starter/src/test/java/dev/dbos/transact/DBOSTestAccess.java
@@ -0,0 +1,11 @@
+package dev.dbos.transact;
+
+import dev.dbos.transact.execution.DBOSExecutor;
+
+// Helper class to retrieve DBOS internals via package private methods
+public class DBOSTestAccess {
+
+ public static DBOSExecutor getDbosExecutor(DBOS dbos) {
+ return dbos.getDbosExecutor();
+ }
+}
diff --git a/transact-spring-txstep-starter/src/test/java/dev/dbos/transact/spring/txstep/TransactionalStepJdbcIntegrationTest.java b/transact-spring-txstep-starter/src/test/java/dev/dbos/transact/spring/txstep/TransactionalStepJdbcIntegrationTest.java
index f6e938502..2777fadd0 100644
--- a/transact-spring-txstep-starter/src/test/java/dev/dbos/transact/spring/txstep/TransactionalStepJdbcIntegrationTest.java
+++ b/transact-spring-txstep-starter/src/test/java/dev/dbos/transact/spring/txstep/TransactionalStepJdbcIntegrationTest.java
@@ -4,6 +4,7 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.dbos.transact.DBOS;
+import dev.dbos.transact.DBOSTestAccess;
import dev.dbos.transact.context.WorkflowOptions;
import dev.dbos.transact.database.SystemDatabase;
import dev.dbos.transact.spring.DBOSAutoConfiguration;
@@ -195,6 +196,39 @@ void goldenPath() throws SQLException {
}
}
+ @Test
+ void deleteCheckpointsThroughDbos() throws SQLException {
+ try (var db = new TransactionalStepTest.TestDatabase()) {
+ runner(db)
+ .run(
+ ctx -> {
+ assertThat(ctx).hasNotFailed();
+ var workflow = ctx.getBean(OrderWorkflowService.class);
+ var wfid = "wf-jdbc-int-delete";
+
+ try (var _o = new WorkflowOptions(wfid).setContext()) {
+ workflow.processOrder("ord-d", "Widget", 1);
+ }
+ assertThat(TransactionalStepTest.getTxRows(db.dataSource, wfid)).hasSize(1);
+
+ // The registrar initialized the factory before launch, which registered its
+ // checkpoints with DBOS.
+ var executor = DBOSTestAccess.getDbosExecutor(ctx.getBean(DBOS.class));
+ executor.deleteStepCheckpoints(wfid, 0);
+ assertThat(TransactionalStepTest.getTxRows(db.dataSource, wfid)).isEmpty();
+
+ // The batch form.
+ var batchId = wfid + "-batch";
+ try (var _o = new WorkflowOptions(batchId).setContext()) {
+ workflow.processOrder("ord-d2", "Widget", 1);
+ }
+ assertThat(TransactionalStepTest.getTxRows(db.dataSource, batchId)).hasSize(1);
+ executor.deleteStepCheckpoints(java.util.List.of(batchId));
+ assertThat(TransactionalStepTest.getTxRows(db.dataSource, batchId)).isEmpty();
+ });
+ }
+ }
+
@Test
void isolationLevel() {
try (var db = new TransactionalStepTest.TestDatabase()) {
diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java
index 3065d1fe1..2aa6966c6 100644
--- a/transact/src/main/java/dev/dbos/transact/DBOS.java
+++ b/transact/src/main/java/dev/dbos/transact/DBOS.java
@@ -9,6 +9,7 @@
import dev.dbos.transact.internal.DBOSIntegration;
import dev.dbos.transact.internal.DBOSInvocationHandler;
import dev.dbos.transact.internal.QueueRegistry;
+import dev.dbos.transact.internal.StepCheckpointStore;
import dev.dbos.transact.internal.WorkflowRegistry;
import dev.dbos.transact.migrations.MigrationManager;
import dev.dbos.transact.workflow.Debouncer;
@@ -64,6 +65,7 @@ public class DBOS implements AutoCloseable {
private final WorkflowRegistry workflowRegistry = new WorkflowRegistry();
private final QueueRegistry queueRegistry = new QueueRegistry();
private final Set lifecycleRegistry = ConcurrentHashMap.newKeySet();
+ private final Set stepCheckpointStores = ConcurrentHashMap.newKeySet();
private final DBOSConfig config;
private final AtomicReference dbosExecutor = new AtomicReference<>();
private final DBOSIntegration integration;
@@ -88,7 +90,11 @@ public DBOS(@NonNull DBOSConfig config) {
this.config = new DBOSConfig(config);
this.integration =
new DBOSIntegration(
- this.config, this.workflowRegistry, dbosExecutor::get, this::registerLifecycleListener);
+ this.config,
+ this.workflowRegistry,
+ dbosExecutor::get,
+ this::registerLifecycleListener,
+ this::registerStepCheckpointStore);
// Register the built-in debouncer workflow directly (without a proxy). Debouncer no
// longer starts it, but one an older SDK version enqueued can still be recovered here when the
// application version is pinned across the upgrade.
@@ -354,6 +360,16 @@ public void registerAlertHandler(AlertHandler handler) {
return dbosExecutor.get();
}
+ // The executor takes a snapshot of the stores at launch, so a factory created afterwards would
+ // never have its checkpoints deleted.
+ private void registerStepCheckpointStore(StepCheckpointStore store) {
+ if (dbosExecutor.get() != null) {
+ throw new IllegalStateException(
+ "Transactional step factories must be created before DBOS is launched");
+ }
+ stepCheckpointStores.add(store);
+ }
+
/** Launch DBOS, and start recovery. */
public void launch() {
logger.info("Launching DBOS v{}", DBOS.version());
@@ -377,6 +393,7 @@ public void launch() {
workflowRegistry.getInternalWorkflowSnapshot(),
workflowRegistry.getInternalInstanceSnapshot(),
queueRegistry.getSnapshot(),
+ Set.copyOf(stepCheckpointStores),
alertHandler);
}
}
diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java
index e7ca77c3f..96c715eff 100644
--- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java
+++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java
@@ -27,6 +27,7 @@
import dev.dbos.transact.exceptions.DBOSWorkflowExecutionConflictException;
import dev.dbos.transact.exceptions.DBOSWorkflowFunctionNotFoundException;
import dev.dbos.transact.internal.AppVersionComputer;
+import dev.dbos.transact.internal.StepCheckpointStore;
import dev.dbos.transact.internal.Validation;
import dev.dbos.transact.internal.WorkflowRegistry;
import dev.dbos.transact.json.DBOSSerializer;
@@ -62,6 +63,7 @@
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
@@ -173,6 +175,7 @@ public void close() {
private ExecutorService executorService;
private ScheduledExecutorService timeoutScheduler;
private AlertHandler alertHandler;
+ private Set stepCheckpointStores = Set.of();
public DBOSExecutor(DBOSConfig config) {
this.config = config;
@@ -240,6 +243,7 @@ public void start(
Map internalWorkflowMap,
Map internalInstanceMap,
List queues,
+ Set stepCheckpointStores,
AlertHandler alertHandler) {
if (isRunning.compareAndSet(false, true)) {
@@ -251,6 +255,7 @@ public void start(
this.queueMap =
queues.stream().collect(Collectors.toUnmodifiableMap(Queue::name, queue -> queue));
this.listeners = listenerSet;
+ this.stepCheckpointStores = stepCheckpointStores;
this.alertHandler = alertHandler;
if (this.appVersion == null || this.appVersion.isEmpty()) {
@@ -1038,6 +1043,55 @@ public void updateWorkflowAttributes(String workflowId, Map attr
null);
}
+ /** Whether any transactional step factory has registered its checkpoints. */
+ public boolean hasStepCheckpointStores() {
+ return !stepCheckpointStores.isEmpty();
+ }
+
+ /**
+ * Deletes a workflow's checkpoints from {@code fromStepId} on, in every registered transactional
+ * step factory. Each store commits its own delete, so a failure part way leaves the stores before
+ * it cleared; deleting again is safe.
+ */
+ public void deleteStepCheckpoints(String workflowId, int fromStepId) {
+ for (var store : stepCheckpointStores) {
+ try {
+ store.deleteCheckpoints(workflowId, fromStepId);
+ } catch (SQLException | RuntimeException e) {
+ throw new RuntimeException(
+ "Failed to delete the transactional step checkpoints of workflow " + workflowId, e);
+ }
+ }
+ }
+
+ /**
+ * Deletes every checkpoint of the given workflows, in every registered transactional step
+ * factory. As with {@link #deleteStepCheckpoints(String, int)}, a failure part way leaves the
+ * stores before it cleared, and deleting again is safe.
+ */
+ public void deleteStepCheckpoints(Collection workflowIds) {
+ for (var store : stepCheckpointStores) {
+ try {
+ store.deleteCheckpoints(workflowIds);
+ } catch (SQLException | RuntimeException e) {
+ throw new RuntimeException(
+ "Failed to delete the transactional step checkpoints of "
+ + describeWorkflows(workflowIds),
+ e);
+ }
+ }
+ }
+
+ // A retention batch can hold thousands of IDs, so name only the first few.
+ private static String describeWorkflows(Collection workflowIds) {
+ var shown = workflowIds.stream().limit(5).toList();
+ var more = workflowIds.size() - shown.size();
+ return workflowIds.size()
+ + " workflows "
+ + shown
+ + (more > 0 ? " and %d more".formatted(more) : "");
+ }
+
public WorkflowHandle forkWorkflow(
String workflowId, int startStep, ForkOptions options) {
diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java
index 7c0f271e8..c13a773cc 100644
--- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java
+++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java
@@ -36,16 +36,19 @@ public class DBOSIntegration {
private final WorkflowRegistry workflowRegistry;
private final Supplier executorSupplier;
private final Consumer listenerConsumer;
+ private final Consumer checkpointStoreConsumer;
public DBOSIntegration(
@NonNull DBOSConfig config,
@NonNull WorkflowRegistry workflowRegistry,
@NonNull Supplier executorSupplier,
- @NonNull Consumer lifecycleConsumer) {
+ @NonNull Consumer lifecycleConsumer,
+ @NonNull Consumer checkpointStoreConsumer) {
this.config = Objects.requireNonNull(config);
this.workflowRegistry = Objects.requireNonNull(workflowRegistry);
this.executorSupplier = Objects.requireNonNull(executorSupplier);
this.listenerConsumer = Objects.requireNonNull(lifecycleConsumer);
+ this.checkpointStoreConsumer = Objects.requireNonNull(checkpointStoreConsumer);
}
private DBOSExecutor executor(String caller) {
@@ -75,6 +78,18 @@ public void registerLifecycleListener(@NonNull DBOSLifecycleListener listener) {
listenerConsumer.accept(listener);
}
+ /**
+ * Register the checkpoints a transactional step factory keeps in its own database, so DBOS can
+ * delete a workflow's checkpoints there when it discards that workflow's history. Registering the
+ * same store again has no effect.
+ *
+ * @param store the factory's checkpoints; must not be {@code null}
+ * @throws IllegalStateException if called after DBOS is launched
+ */
+ public void registerStepCheckpointStore(@NonNull StepCheckpointStore store) {
+ checkpointStoreConsumer.accept(Objects.requireNonNull(store));
+ }
+
/**
* Register a workflow method with DBOS. This method is used internally by the proxy registration
* process and should not typically be called directly by application code.
diff --git a/transact/src/main/java/dev/dbos/transact/internal/StepCheckpointStore.java b/transact/src/main/java/dev/dbos/transact/internal/StepCheckpointStore.java
new file mode 100644
index 000000000..a968f013c
--- /dev/null
+++ b/transact/src/main/java/dev/dbos/transact/internal/StepCheckpointStore.java
@@ -0,0 +1,32 @@
+package dev.dbos.transact.internal;
+
+import java.sql.SQLException;
+import java.util.Collection;
+
+/**
+ * Checkpoints a transactional step factory keeps in its own database, outside the system database.
+ * Each factory registers one through {@link DBOSIntegration#registerStepCheckpointStore}, so DBOS
+ * can drop a workflow's checkpoints when it discards that workflow's history.
+ *
+ * This interface is not part of the primary public API and may change without
+ * notice.
+ */
+public interface StepCheckpointStore {
+
+ /**
+ * Deletes a workflow's checkpoints from {@code fromStepId} on. Deleting checkpoints that are
+ * already gone is not an error.
+ *
+ * @param workflowId the workflow whose checkpoints to delete
+ * @param fromStepId the first step ID to delete; 0 deletes them all
+ */
+ void deleteCheckpoints(String workflowId, int fromStepId) throws SQLException;
+
+ /**
+ * Deletes every checkpoint of the given workflows, in one statement. Workflows with no
+ * checkpoints are not an error.
+ *
+ * @param workflowIds the workflows whose checkpoints to delete
+ */
+ void deleteCheckpoints(Collection workflowIds) throws SQLException;
+}
diff --git a/transact/src/main/java/dev/dbos/transact/txstep/JdbcStepFactory.java b/transact/src/main/java/dev/dbos/transact/txstep/JdbcStepFactory.java
index 7218e1eb8..e93ab8366 100644
--- a/transact/src/main/java/dev/dbos/transact/txstep/JdbcStepFactory.java
+++ b/transact/src/main/java/dev/dbos/transact/txstep/JdbcStepFactory.java
@@ -9,6 +9,7 @@
import java.sql.Connection;
import java.sql.SQLException;
+import java.util.Collection;
import java.util.Objects;
import java.util.Optional;
@@ -22,6 +23,10 @@
* User lambdas passed to {@code txStep} receive a {@link Connection} with a transaction already
* started; they should not call {@code commit} or {@code close} themselves.
*
+ * Create the factory before calling {@link dev.dbos.transact.DBOS#launch()}: DBOS needs to know
+ * every factory when it launches, so the constructor throws {@link IllegalStateException} after
+ * launch.
+ *
*
{@code
* JdbcStepFactory factory = new JdbcStepFactory(dbos, dataSource);
*
@@ -200,6 +205,26 @@ public void txStep(
options);
}
+ @Override
+ protected void deleteCheckpoints(String workflowId, int fromStepId) throws SQLException {
+ executeTransaction(
+ dataSource,
+ (Connection conn) -> {
+ TxStepSchema.deleteFromStep(conn, schema, workflowId, fromStepId);
+ return null;
+ });
+ }
+
+ @Override
+ protected void deleteCheckpoints(Collection workflowIds) throws SQLException {
+ executeTransaction(
+ dataSource,
+ (Connection conn) -> {
+ TxStepSchema.deleteWorkflows(conn, schema, workflowIds);
+ return null;
+ });
+ }
+
private static R executeTransaction(
final DataSource ds, TransactionalFunction func) throws X {
return executeTransaction(ds, IsolationLevel.DEFAULT, func);
diff --git a/transact/src/main/java/dev/dbos/transact/txstep/PostgresStepFactory.java b/transact/src/main/java/dev/dbos/transact/txstep/PostgresStepFactory.java
index 75a638f5b..db9088519 100644
--- a/transact/src/main/java/dev/dbos/transact/txstep/PostgresStepFactory.java
+++ b/transact/src/main/java/dev/dbos/transact/txstep/PostgresStepFactory.java
@@ -1,12 +1,15 @@
package dev.dbos.transact.txstep;
import dev.dbos.transact.DBOS;
+import dev.dbos.transact.database.SqlTransaction;
import dev.dbos.transact.database.SystemDatabase;
+import dev.dbos.transact.internal.StepCheckpointStore;
import dev.dbos.transact.json.DBOSSerializer;
import dev.dbos.transact.workflow.internal.StepResult;
import java.sql.Connection;
import java.sql.SQLException;
+import java.util.Collection;
import java.util.Objects;
import java.util.Optional;
@@ -19,13 +22,23 @@
* with the DBOS runtime.
*
* The constructor verifies that the datasource is PostgreSQL and creates the {@code
- * tx_step_outputs} table (and its enclosing schema) if they do not already exist.
+ * tx_step_outputs} table (and its enclosing schema) if they do not already exist. It then registers
+ * the factory's checkpoints with DBOS, which must not be launched yet, so DBOS can delete them when
+ * it discards a workflow's history.
+ *
+ *
Subclasses: the factory is registered before the subclass constructor finishes, and stays
+ * registered even if that constructor then throws, so a subclass constructor should not fail after
+ * calling {@code super}.
*/
public abstract class PostgresStepFactory {
protected final DBOS dbos;
protected final String schema;
protected final DBOSSerializer serializer;
+ private final ConnectionOpener opener;
+ // The store DBOS calls. A private class rather than this factory implementing the interface, so
+ // the interface's methods do not become public methods of every factory.
+ private final StepCheckpointStore checkpointStore = new CheckpointStore();
@FunctionalInterface
protected interface ConnectionOpener {
@@ -38,6 +51,7 @@ protected PostgresStepFactory(
var config = dbos.integration().config();
this.schema = SystemDatabase.sanitizeSchema(schema == null ? config.databaseSchema() : schema);
this.serializer = serializer == null ? config.serializer() : serializer;
+ this.opener = opener;
try (var conn = opener.open()) {
TxStepSchema.verifyPostgres(conn);
@@ -45,6 +59,48 @@ protected PostgresStepFactory(
} catch (SQLException e) {
throw new RuntimeException(e);
}
+
+ dbos.integration().registerStepCheckpointStore(checkpointStore);
+ }
+
+ private final class CheckpointStore implements StepCheckpointStore {
+ @Override
+ public void deleteCheckpoints(String workflowId, int fromStepId) throws SQLException {
+ PostgresStepFactory.this.deleteCheckpoints(workflowId, fromStepId);
+ }
+
+ @Override
+ public void deleteCheckpoints(Collection workflowIds) throws SQLException {
+ if (!workflowIds.isEmpty()) {
+ PostgresStepFactory.this.deleteCheckpoints(workflowIds);
+ }
+ }
+ }
+
+ /**
+ * Deletes a workflow's checkpoints from {@code fromStepId} on, in a transaction of its own that
+ * commits before this returns. DBOS calls it when it discards the workflow's history.
+ *
+ * This default opens a connection and commits explicitly. Subclasses override it to go through
+ * their database library's own transaction handling.
+ */
+ protected void deleteCheckpoints(String workflowId, int fromStepId) throws SQLException {
+ try (var conn = opener.open()) {
+ SqlTransaction.run(conn, c -> TxStepSchema.deleteFromStep(c, schema, workflowId, fromStepId));
+ }
+ }
+
+ /**
+ * Deletes every checkpoint of the given workflows, never empty, in a transaction of its own that
+ * commits before this returns. DBOS calls it when it deletes the workflows.
+ *
+ *
This default opens a connection and commits explicitly. Subclasses override it to go through
+ * their database library's own transaction handling.
+ */
+ protected void deleteCheckpoints(Collection workflowIds) throws SQLException {
+ try (var conn = opener.open()) {
+ SqlTransaction.run(conn, c -> TxStepSchema.deleteWorkflows(c, schema, workflowIds));
+ }
}
protected abstract Optional checkExecution(
diff --git a/transact/src/main/java/dev/dbos/transact/txstep/TxStepSchema.java b/transact/src/main/java/dev/dbos/transact/txstep/TxStepSchema.java
index 4d7947de8..94a0ca6eb 100644
--- a/transact/src/main/java/dev/dbos/transact/txstep/TxStepSchema.java
+++ b/transact/src/main/java/dev/dbos/transact/txstep/TxStepSchema.java
@@ -6,6 +6,7 @@
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
+import java.util.Collection;
import java.util.Optional;
/** Shared SQL DDL and query constants for the {@code tx_step_outputs} table. */
@@ -38,6 +39,10 @@ PRIMARY KEY (workflow_id, step_id)
.formatted(schema));
stmt.executeBatch();
}
+ // A pool configured without autocommit would otherwise roll the DDL back on close.
+ if (!conn.getAutoCommit()) {
+ conn.commit();
+ }
}
public static String checkSql(String schema) {
@@ -49,6 +54,46 @@ public static String checkSql(String schema) {
.formatted(schema);
}
+ /** Deletes a workflow's checkpoints from a step on: parameters are the workflow and step IDs. */
+ public static String deleteFromStepSql(String schema) {
+ return """
+ DELETE FROM "%s".tx_step_outputs
+ WHERE workflow_id = ? AND step_id >= ?
+ """
+ .formatted(schema);
+ }
+
+ /** Runs {@link #deleteFromStepSql} on a connection; the caller owns the transaction. */
+ public static void deleteFromStep(
+ Connection conn, String schema, String workflowId, int fromStepId) throws SQLException {
+ try (var stmt = conn.prepareStatement(deleteFromStepSql(schema))) {
+ stmt.setString(1, workflowId);
+ stmt.setInt(2, fromStepId);
+ stmt.executeUpdate();
+ }
+ }
+
+ /** Deletes every checkpoint of a set of workflows: the parameter is a text[] of workflow IDs. */
+ public static String deleteWorkflowsSql(String schema) {
+ return """
+ DELETE FROM "%s".tx_step_outputs
+ WHERE workflow_id = ANY(?)
+ """
+ .formatted(schema);
+ }
+
+ /** Runs {@link #deleteWorkflowsSql} on a connection; the caller owns the transaction. */
+ public static void deleteWorkflows(Connection conn, String schema, Collection workflowIds)
+ throws SQLException {
+ var ids = conn.createArrayOf("text", workflowIds.toArray(String[]::new));
+ try (var stmt = conn.prepareStatement(deleteWorkflowsSql(schema))) {
+ stmt.setArray(1, ids);
+ stmt.executeUpdate();
+ } finally {
+ ids.free();
+ }
+ }
+
public static String upsertSql(String schema) {
return """
INSERT INTO "%s".tx_step_outputs
diff --git a/transact/src/test/java/dev/dbos/transact/txstep/StepCheckpointRegistryTest.java b/transact/src/test/java/dev/dbos/transact/txstep/StepCheckpointRegistryTest.java
new file mode 100644
index 000000000..836162cdd
--- /dev/null
+++ b/transact/src/test/java/dev/dbos/transact/txstep/StepCheckpointRegistryTest.java
@@ -0,0 +1,301 @@
+package dev.dbos.transact.txstep;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.dbos.transact.DBOS;
+import dev.dbos.transact.DBOSTestAccess;
+import dev.dbos.transact.config.DBOSConfig;
+import dev.dbos.transact.context.WorkflowOptions;
+import dev.dbos.transact.internal.StepCheckpointStore;
+import dev.dbos.transact.utils.DBUtils;
+import dev.dbos.transact.utils.PgContainer;
+import dev.dbos.transact.utils.TxStepOutputRow;
+import dev.dbos.transact.workflow.Workflow;
+import dev.dbos.transact.workflow.internal.StepResult;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.junit.jupiter.api.AutoClose;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+interface CheckpointService {
+ void threeSteps() throws SQLException;
+}
+
+class CheckpointServiceImpl implements CheckpointService {
+ private final JdbcStepFactory factory;
+
+ CheckpointServiceImpl(JdbcStepFactory factory) {
+ this.factory = factory;
+ }
+
+ @Override
+ @Workflow
+ public void threeSteps() throws SQLException {
+ for (var name : List.of("one", "two", "three")) {
+ factory.txStep((Connection conn) -> {}, name);
+ }
+ }
+}
+
+public class StepCheckpointRegistryTest {
+
+ @AutoClose final PgContainer pgContainer = new PgContainer();
+
+ DBOSConfig dbosConfig;
+ @AutoClose DBOS dbos;
+ @AutoClose HikariDataSource dataSource;
+ // A delete that relied on autocommit would be rolled back when its connection went back here.
+ @AutoClose HikariDataSource noAutoCommitDataSource;
+
+ @BeforeEach
+ void beforeEach() {
+ dbosConfig = pgContainer.dbosConfig();
+ dataSource = pgContainer.dataSource();
+ var cfg = new HikariConfig();
+ cfg.setJdbcUrl(pgContainer.jdbcUrl());
+ cfg.setUsername(pgContainer.username());
+ cfg.setPassword(pgContainer.password());
+ cfg.setAutoCommit(false);
+ noAutoCommitDataSource = new HikariDataSource(cfg);
+ dbos = new DBOS(dbosConfig);
+ }
+
+ private List checkpoints(String schema, String workflowId) throws SQLException {
+ return DBUtils.getTxStepRows(dataSource, workflowId, schema).stream()
+ .map(TxStepOutputRow::stepId)
+ .sorted()
+ .toList();
+ }
+
+ @Test
+ public void deletesAWorkflowsCheckpointsFromAStepOn() throws Exception {
+ var factory = new JdbcStepFactory(dbos, noAutoCommitDataSource, "registry_jdbc");
+ var proxy = dbos.registerProxy(CheckpointService.class, new CheckpointServiceImpl(factory));
+ dbos.launch();
+
+ try (var o = new WorkflowOptions("wf-registry").setContext()) {
+ proxy.threeSteps();
+ }
+ assertEquals(List.of(0, 1, 2), checkpoints("registry_jdbc", "wf-registry"));
+
+ var executor = DBOSTestAccess.getDbosExecutor(dbos);
+ assertTrue(executor.hasStepCheckpointStores());
+ executor.deleteStepCheckpoints("wf-registry", 1);
+ assertEquals(List.of(0), checkpoints("registry_jdbc", "wf-registry"));
+
+ // Deleting again is not an error, and 0 deletes the rest.
+ executor.deleteStepCheckpoints("wf-registry", 1);
+ executor.deleteStepCheckpoints("wf-registry", 0);
+ assertEquals(List.of(), checkpoints("registry_jdbc", "wf-registry"));
+ }
+
+ @Test
+ public void theDefaultDeleteCommits() throws Exception {
+ // A factory that inherits PostgresStepFactory's own delete rather than overriding it.
+ new MinimalStepFactory(dbos, noAutoCommitDataSource, "registry_default");
+ dbos.launch();
+ try (var conn = dataSource.getConnection();
+ var stmt = conn.createStatement()) {
+ stmt.execute(
+ "INSERT INTO registry_default.tx_step_outputs (workflow_id, step_id) VALUES"
+ + " ('wf-default', 0), ('wf-default', 1), ('wf-other', 1)");
+ }
+
+ DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints("wf-default", 1);
+
+ assertEquals(List.of(0), checkpoints("registry_default", "wf-default"));
+ assertEquals(List.of(1), checkpoints("registry_default", "wf-other"));
+ }
+
+ @Test
+ public void aFactoryCreatedAfterLaunchIsRefused() {
+ dbos.launch();
+ var refused =
+ assertThrows(
+ IllegalStateException.class,
+ () -> new JdbcStepFactory(dbos, dataSource, "registry_late"));
+ assertTrue(refused.getMessage().contains("before DBOS is launched"), refused.getMessage());
+ assertFalse(DBOSTestAccess.getDbosExecutor(dbos).hasStepCheckpointStores());
+ }
+
+ @Test
+ public void aStoreRegisteredTwiceHasOneEntry() {
+ var deletes = new AtomicInteger();
+ var store = new RecordingStore(deletes, null);
+ dbos.integration().registerStepCheckpointStore(store);
+ dbos.integration().registerStepCheckpointStore(store);
+ dbos.launch();
+
+ DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints("wf", 0);
+ assertEquals(1, deletes.get());
+ }
+
+ @Test
+ public void aFailedDeleteNamesTheWorkflow() {
+ dbos.integration()
+ .registerStepCheckpointStore(
+ new RecordingStore(new AtomicInteger(), new SQLException("datasource down")));
+ dbos.launch();
+
+ var thrown =
+ assertThrows(
+ RuntimeException.class,
+ () -> DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints("wf-down", 0));
+ assertTrue(thrown.getMessage().contains("wf-down"), thrown.getMessage());
+ assertEquals("datasource down", thrown.getCause().getMessage());
+ }
+
+ @Test
+ public void anUncheckedFailureNamesTheWorkflowToo() {
+ // Jdbi and jOOQ report database errors as unchecked exceptions.
+ dbos.integration()
+ .registerStepCheckpointStore(
+ new RecordingStore(new AtomicInteger(), new IllegalStateException("pool closed")));
+ dbos.launch();
+
+ var thrown =
+ assertThrows(
+ RuntimeException.class,
+ () -> DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints("wf-closed", 0));
+ assertTrue(thrown.getMessage().contains("wf-closed"), thrown.getMessage());
+ assertEquals("pool closed", thrown.getCause().getMessage());
+ }
+
+ @Test
+ public void aFailedBatchNamesOnlyTheFirstFewWorkflows() {
+ dbos.integration()
+ .registerStepCheckpointStore(
+ new RecordingStore(new AtomicInteger(), new SQLException("datasource down")));
+ dbos.launch();
+
+ var ids = java.util.stream.IntStream.range(0, 12).mapToObj(i -> "wf-" + i).toList();
+ var thrown =
+ assertThrows(
+ RuntimeException.class,
+ () -> DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints(ids));
+ var message = thrown.getMessage();
+ assertTrue(message.contains("12 workflows"), message);
+ assertTrue(message.contains("wf-0") && message.contains("wf-4"), message);
+ assertFalse(message.contains("wf-5"), message);
+ assertTrue(message.contains("and 7 more"), message);
+ }
+
+ @Test
+ public void deletesEveryCheckpointOfTheGivenWorkflows() throws Exception {
+ var factory = new JdbcStepFactory(dbos, noAutoCommitDataSource, "registry_batch");
+ var proxy = dbos.registerProxy(CheckpointService.class, new CheckpointServiceImpl(factory));
+ dbos.launch();
+
+ for (var id : List.of("wf-a", "wf-b", "wf-c")) {
+ try (var o = new WorkflowOptions(id).setContext()) {
+ proxy.threeSteps();
+ }
+ }
+
+ var executor = DBOSTestAccess.getDbosExecutor(dbos);
+ executor.deleteStepCheckpoints(List.of("wf-a", "wf-c", "wf-missing"));
+ assertEquals(List.of(), checkpoints("registry_batch", "wf-a"));
+ assertEquals(List.of(0, 1, 2), checkpoints("registry_batch", "wf-b"));
+ assertEquals(List.of(), checkpoints("registry_batch", "wf-c"));
+
+ executor.deleteStepCheckpoints(List.of());
+ assertEquals(List.of(0, 1, 2), checkpoints("registry_batch", "wf-b"));
+ }
+
+ @Test
+ public void anEmptyBatchDoesNotReachTheDatabase() {
+ var opens = new AtomicInteger();
+ new MinimalStepFactory(
+ dbos,
+ () -> {
+ opens.incrementAndGet();
+ return dataSource.getConnection();
+ },
+ "registry_empty");
+ dbos.launch();
+ var opensAfterCreate = opens.get();
+
+ DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints(List.of());
+ assertEquals(opensAfterCreate, opens.get());
+ }
+
+ @Test
+ public void theDefaultBatchDeleteCommits() throws Exception {
+ new MinimalStepFactory(dbos, noAutoCommitDataSource, "registry_default_batch");
+ dbos.launch();
+ try (var conn = dataSource.getConnection();
+ var stmt = conn.createStatement()) {
+ stmt.execute(
+ "INSERT INTO registry_default_batch.tx_step_outputs (workflow_id, step_id) VALUES"
+ + " ('wf-a', 0), ('wf-a', 1), ('wf-b', 0), ('wf-c', 0)");
+ }
+
+ DBOSTestAccess.getDbosExecutor(dbos).deleteStepCheckpoints(List.of("wf-a", "wf-b"));
+
+ assertEquals(List.of(), checkpoints("registry_default_batch", "wf-a"));
+ assertEquals(List.of(), checkpoints("registry_default_batch", "wf-b"));
+ assertEquals(List.of(0), checkpoints("registry_default_batch", "wf-c"));
+ }
+
+ /** Counts its calls, and fails every one with {@code failure} when it is not null. */
+ static class RecordingStore implements StepCheckpointStore {
+ private final AtomicInteger calls;
+ private final Exception failure;
+
+ RecordingStore(AtomicInteger calls, Exception failure) {
+ this.calls = calls;
+ this.failure = failure;
+ }
+
+ @Override
+ public void deleteCheckpoints(String workflowId, int fromStepId) throws SQLException {
+ record();
+ }
+
+ @Override
+ public void deleteCheckpoints(Collection workflowIds) throws SQLException {
+ record();
+ }
+
+ private void record() throws SQLException {
+ calls.incrementAndGet();
+ if (failure instanceof SQLException e) {
+ throw e;
+ }
+ if (failure instanceof RuntimeException e) {
+ throw e;
+ }
+ }
+ }
+
+ /** The smallest PostgresStepFactory: it runs no steps, and only inherits the default delete. */
+ static class MinimalStepFactory extends PostgresStepFactory {
+ MinimalStepFactory(DBOS dbos, javax.sql.DataSource dataSource, String schema) {
+ this(dbos, dataSource::getConnection, schema);
+ }
+
+ MinimalStepFactory(DBOS dbos, ConnectionOpener opener, String schema) {
+ super(dbos, schema, null, opener);
+ }
+
+ @Override
+ protected Optional checkExecution(String workflowId, int stepId, String stepName) {
+ return Optional.empty();
+ }
+
+ @Override
+ protected void recordError(String workflowId, int stepId, Exception exception) {}
+ }
+}