Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
171 changes: 171 additions & 0 deletions tutorials/workflow/java/history-propagation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Workflow History Propagation

This tutorial demonstrates **workflow history propagation**, a Dapr 1.18+ feature that lets a parent workflow share its execution history with child workflows and activities. Downstream services can inspect the propagated history to make trust-aware decisions — without any external state store or custom messaging. See the [Dapr docs](https://github.com/dapr/docs/pull/5153) for the feature reference.
Comment thread
alicejgibbons marked this conversation as resolved.
Outdated

## Scenario: Patient intake / e-prescribing

A `ComplianceAuditWorkflow` and a `DispenseMedicationActivity` refuse to act unless the propagated history proves the required upstream checks (insurance, allergies, drug interactions) actually ran.

```mermaid
graph LR
SI((Start))
PI[PatientIntakeWorkflow]
VI[VerifyInsurance]
PM[PrescribeMedicationWorkflow]
CA[CheckAllergies]
SD[ScreenDrugInteractions]
CO[ComplianceAuditWorkflow]
DM[DispenseMedication]
EW((End))
SI --> PI
PI -->|no propagation| VI
PI -->|LINEAGE| PM
PM -->|no propagation| CA
PM -->|no propagation| SD
PM -->|LINEAGE| CO
PM -->|OWN_HISTORY| DM
CO --> DM
DM --> EW
```

- `PatientIntakeWorkflow` (root) calls `VerifyInsuranceActivity` (no propagation), then invokes `PrescribeMedicationWorkflow` with `propagateLineage()`.
- `PrescribeMedicationWorkflow` receives `scope=LINEAGE` with **1 ancestor** (PatientIntake). It runs allergy and interaction checks, then calls `ComplianceAuditWorkflow` with `propagateLineage()` and `DispenseMedicationActivity` with `propagateOwnHistory()`.
- `ComplianceAuditWorkflow` receives `scope=LINEAGE` with **2 ancestors** (PatientIntake + PrescribeMedication). It uses `getLastActivityByName(...)` to verify each required upstream activity completed, then approves.
- `DispenseMedicationActivity` receives `scope=OWN_HISTORY` with **1 ancestor** (PrescribeMedication only). The grandparent PatientIntake is intentionally not visible (trust boundary).

## Java API surface

```java
import io.dapr.durabletask.ActivityResult;
import io.dapr.durabletask.HistoryPropagationScope;
import io.dapr.durabletask.PropagatedHistory;
import io.dapr.durabletask.WorkflowResult;
import io.dapr.workflows.WorkflowTaskOptions;

// Parent — propagate LINEAGE when calling a child workflow
AuditResult audit = ctx.callChildWorkflow(
ComplianceAuditWorkflow.class.getName(),
rec,
/* instanceId */ null,
WorkflowTaskOptions.propagateLineage(),
AuditResult.class).await();

// Parent — propagate OWN_HISTORY when calling an activity
DispenseResult dispense = ctx.callActivity(
DispenseMedicationActivity.class.getName(),
rec,
WorkflowTaskOptions.propagateOwnHistory(),
DispenseResult.class).await();

// Receiver (child workflow or activity) — read the propagated history
Optional<PropagatedHistory> historyOpt = ctx.getPropagatedHistory();
historyOpt.ifPresent(history -> {
history.getScope(); // HistoryPropagationScope (LINEAGE | OWN_HISTORY)
history.getWorkflows(); // List<WorkflowResult> — ancestor first, then own

Optional<WorkflowResult> intake = history.getLastWorkflowByName(
PatientIntakeWorkflow.class.getName());
intake.flatMap(wf -> wf.getLastActivityByName(VerifyInsuranceActivity.class.getName()))
.map(ActivityResult::isCompleted);
});
```

## Run the tutorial

1. Use a terminal to navigate to the `tutorials/workflow/java/history-propagation` folder.
2. Build and run the project using Maven. This spins up a Dapr sidecar via Testcontainers.

```bash
mvn spring-boot:test-run
```

### Scenario 1 (happy path): lineage forwarded — pharmacy dispenses

3. Use the first POST request in the [`history-propagation.http`](./history-propagation.http) file, or use this cURL command:

```bash
curl -i --request POST \
--url http://localhost:8080/start \
--header 'content-type: application/json' \
--data '{
"patientId": "P-1042",
"name": "Jane Doe",
"condition": "bacterial sinusitis",
"medication": "amoxicillin",
"dosage": 500,
"propagateHistory": true
}'
```

The app logs should show the propagation markers proving the feature works:

```text
i.d.s.e.h.PatientIntakeWorkflow : PROPAGATION-DEMO: root workflow received no propagated history (expected)
i.d.s.e.h.PrescribeMedicationWorkflow : PROPAGATION-DEMO: scope=LINEAGE workflows=1
i.d.s.e.h.ComplianceAuditWorkflow : PROPAGATION-DEMO: scope=LINEAGE workflows=2
i.d.s.e.h.ComplianceAuditWorkflow : upstream activity VerifyInsurance: completed=true
i.d.s.e.h.ComplianceAuditWorkflow : upstream activity CheckAllergies: completed=true
i.d.s.e.h.ComplianceAuditWorkflow : upstream activity ScreenDrugInteractions: completed=true
i.d.s.e.h.ComplianceAuditWorkflow : APPROVED (risk=0.10, 2 workflow(s) verified)
i.d.s.e.h.a.DispenseMedicationActivity : PROPAGATION-DEMO: scope=OWN_HISTORY workflows=1
i.d.s.e.h.a.DispenseMedicationActivity : DISPENSED: rx-P-1042-... (amoxicillin 500mg) for patient P-1042
```

4. Fetch the output with the GET request in the .http file, or:

```bash
curl --request GET --url http://localhost:8080/output
```

Expected:

```json
{"dispensed":true,"dispenseId":"rx-P-1042-<ts>","patientId":"P-1042","medication":"amoxicillin"}
```

### Scenario 2 (failure): lineage withheld — pharmacy refuses

When `propagateHistory` is `false`, `PatientIntakeWorkflow` invokes `PrescribeMedicationWorkflow` **without** propagation options. `ComplianceAuditWorkflow` then receives no PatientIntake events in its propagated history, can't verify `VerifyInsurance` ran, and blocks the prescription.

5. Use the second POST request in the .http file, or:

```bash
curl -i --request POST \
--url http://localhost:8080/start \
--header 'content-type: application/json' \
--data '{
"patientId": "P-2087",
"name": "John Roe",
"condition": "strep throat",
"medication": "penicillin",
"dosage": 500,
"propagateHistory": false
}'
```

The app logs show the audit failing because it cannot find the upstream `VerifyInsurance` activity in the propagated history:

```text
i.d.s.e.h.PatientIntakeWorkflow : Calling PrescribeMedicationWorkflow WITHOUT propagation (failure scenario)
i.d.s.e.h.PrescribeMedicationWorkflow : Starting prescription: penicillin 500mg for strep throat
i.d.s.e.h.ComplianceAuditWorkflow : PROPAGATION-DEMO: scope=LINEAGE workflows=1
i.d.s.e.h.ComplianceAuditWorkflow : upstream activity VerifyInsurance: completed=false
i.d.s.e.h.ComplianceAuditWorkflow : upstream activity CheckAllergies: completed=true
i.d.s.e.h.ComplianceAuditWorkflow : upstream activity ScreenDrugInteractions: completed=true
i.d.s.e.h.ComplianceAuditWorkflow : BLOCKED - missing upstream checks: insurance=false allergies=true interactions=true
i.d.s.e.h.PrescribeMedicationWorkflow : Audit blocked dispensing - aborting prescription
```

6. Fetch the output:

```bash
curl --request GET --url http://localhost:8080/output
```

Expected:

```json
{"dispensed":false,"dispenseId":null,"patientId":"P-2087","medication":"penicillin"}
```

7. Stop the application by pressing `Ctrl+C`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
@apphost=http://localhost:8080

### Scenario 1 (happy path): lineage forwarded - pharmacy dispenses
POST {{ apphost }}/start
Content-Type: application/json

{
"patientId": "P-1042",
"name": "Jane Doe",
"condition": "bacterial sinusitis",
"medication": "amoxicillin",
"dosage": 500,
"propagateHistory": true
}

### Scenario 2 (failure): lineage withheld - ComplianceAudit refuses, dispensed=false
POST {{ apphost }}/start
Content-Type: application/json

{
"patientId": "P-2087",
"name": "John Roe",
"condition": "strep throat",
"medication": "penicillin",
"dosage": 500,
"propagateHistory": false
}

### Get the workflow output (most recent /start)
GET {{ apphost }}/output
66 changes: 66 additions & 0 deletions tutorials/workflow/java/history-propagation/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<artifactId>history-propagation</artifactId>
<name>history-propagation</name>
<description>Workflow History Propagation Example</description>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.dapr.spring</groupId>
<artifactId>dapr-spring-bom</artifactId>
<version>1.18.0-rc-2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>io.dapr.spring</groupId>
<artifactId>dapr-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.dapr.spring</groupId>
<artifactId>dapr-spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2026 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.dapr.springboot.examples;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class HistoryPropagationApplication {

public static void main(String[] args) {
SpringApplication.run(HistoryPropagationApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2026 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.dapr.springboot.examples;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class HistoryPropagationConfiguration {

@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder().build();
}

@Bean
public ObjectMapper mapper() {
return new ObjectMapper();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright 2026 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.dapr.springboot.examples;

import io.dapr.spring.workflows.config.EnableDaprWorkflows;
import io.dapr.springboot.examples.historypropagation.PatientIntakeWorkflow;
import io.dapr.springboot.examples.historypropagation.models.PatientRecord;
import io.dapr.springboot.examples.historypropagation.models.PrescriptionResult;
import io.dapr.workflows.client.DaprWorkflowClient;
import io.dapr.workflows.client.WorkflowInstanceStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.TimeoutException;

@RestController
@EnableDaprWorkflows
public class HistoryPropagationRestController {

@Autowired
private DaprWorkflowClient daprWorkflowClient;

/*
* For example purposes only. In production, map workflowInstanceIds to your
* own business identifiers.
*/
private String instanceId;

/**
* Start the PatientIntake workflow with the given patient record.
*
* @param record the patient record
* @return the workflow instance id
*/
@PostMapping("start")
public String start(@RequestBody PatientRecord record) {
instanceId = daprWorkflowClient.scheduleNewWorkflow(PatientIntakeWorkflow.class, record);
return instanceId;
}

/**
* Get the output of the last started PatientIntake workflow.
*
* @return the prescription result, or a placeholder if not yet available
*/
@GetMapping("output")
public PrescriptionResult output() throws TimeoutException {
WorkflowInstanceStatus state = daprWorkflowClient.getInstanceState(instanceId, true);
if (state != null) {
return state.readOutputAs(PrescriptionResult.class);
}
return new PrescriptionResult(false, null, null, null);
}
}
Loading
Loading