Activities
Status: [Designed] -- Area 2 of the Grove language. Syntax and semantics are defined; implementation is in progress.
Activities are the units of work inside a workflow. Each activity has a data block that performs side effects -- calling external APIs, invoking module actions, publishing messages, querying databases -- and an apply block that transforms the results into a typed output. Activities are retried on failure, and their results are durably recorded so they are not re-executed on replay.
Prerequisites: Workflows Overview, Concepts Overview. What you'll learn: How to declare activities, use each data operation type, write apply blocks, and configure retry policies.
Anatomy of an Activity
activity charge_payment v1 {
data charge {
fetch stripe.create_charge {
amount: input.amount
currency: input.currency
customer_id: input.customer_id
}
}
apply(input: ChargeInput) -> ChargeResult {
return {
charge_id: charge.id
status: charge.status
}
}
retry_policy {
max_attempts: 3
backoff: 2s
max_backoff: 30s
}
}
An activity has three parts:
- Data blocks -- One or more named blocks that perform side effects. Each block has a single data operation.
- Apply block -- A pure transformation that receives the activity input and data block results, then returns a typed output.
- Retry policy (optional) -- Controls how failures are retried.
Data Blocks
A data block is a named container for a single data operation. The name becomes a root object in the apply block.
data charge {
fetch stripe.create_charge { ... }
}
Multiple data blocks execute in the order they are declared. Each subsequent block can reference the results of earlier blocks.
fetch
Calls an operation on an external service. The qualified name references service.operation:
data charge {
fetch stripe.create_charge {
amount: input.amount
currency: input.currency
}
}
The response shape is determined by the service operation's output type.
invoke
Invokes an action on a Grove module. This is how workflows interact with Area 1 aggregates:
data result {
invoke orders.place_order {
customer_id: input.customer_id
items: input.items
}
}
The invoke operation sends an action request to the specified module. The result contains the updated record state and emitted events.
publish
Publishes a message to a topic or queue for asynchronous processing:
data notification {
publish notifications.order_update {
order_id: input.order_id
status: "shipped"
timestamp: input.shipped_at
}
}
query
Executes a declared query against the database:
data recent_orders {
query orders.recent_by_customer {
customer_id: input.customer_id
limit: 10
}
}
The result type is determined by the query's output declaration.
host
Invokes a host function provided by the runtime environment. Host functions are escape hatches for operations that cannot be expressed in Grove's declarative model:
data pdf {
host generate_pdf {
template: "invoice"
data: input.invoice_data
}
}
start
Starts a child workflow and waits for its result:
data fulfillment {
start fulfill_order v1 {
order_id: input.order_id
items: input.items
}
}
This is the data-block equivalent of a start node in a workflow. Use it when an activity needs the result of a sub-workflow before continuing its own logic.
spawn
Starts a child workflow without waiting for completion:
data background {
spawn send_receipts v1 {
order_id: input.order_id
email: input.customer_email
}
}
The spawn operation returns immediately with an execution identifier. The child workflow runs independently.
signal
Sends a signal to a running workflow execution. Signals are used for inter-workflow communication and to resume suspended workflows:
data wake {
signal target_workflow {
execution_id: input.target_execution_id
signal_name: "payment_received"
payload: {
amount: input.amount
}
}
}
See Durable Execution for more on signals.
The Apply Block
The apply block is a pure transformation. It receives the activity's typed input and has access to all data block results as root objects:
activity enrich_order v1 {
data customer {
fetch crm.get_customer {
id: input.customer_id
}
}
data inventory {
query warehouse.check_stock {
item_ids: input.item_ids
}
}
apply(input: EnrichInput) -> EnrichedOrder {
let available_items = inventory.items.filter(|i| i.stock > 0)
return {
customer_name: customer.name
customer_tier: customer.tier
items: available_items
total: available_items.map(|i| i.price * i.quantity).sum()
}
}
}
Root Objects in Apply
| Root | Description |
|---|---|
input | The typed input to the activity |
| Data block names | Results from each data block (e.g., customer, inventory) |
Rules
- The apply block must return a value matching the declared return type.
letbindings are allowed and are immutable.- Standard expression forms are available:
if/else,match, method chains, lambdas. emitis not allowed -- activities do not emit domain events directly. Useinvoketo trigger actions on aggregates.
Retry Policy
The retry_policy block configures automatic retries when a data operation fails:
retry_policy {
max_attempts: 5
backoff: 1s
max_backoff: 60s
}
| Field | Type | Description |
|---|---|---|
max_attempts | Int | Maximum number of attempts (including the first). Default varies by runtime. |
backoff | Duration | Initial delay between retries. |
max_backoff | Duration | Upper bound on the delay. Backoff grows exponentially up to this cap. |
If all attempts are exhausted, the activity fails. The workflow runtime then either marks the workflow as failed or triggers compensation if configured.
Versioning
Activities are versioned independently of workflows:
activity charge_payment v2 {
// updated implementation
}
A workflow node references a specific activity version. Updating an activity version does not affect workflows that reference the old version.
Multiple Data Blocks
Activities can have multiple data blocks that execute sequentially:
activity process_payment v1 {
data customer {
fetch crm.get_customer {
id: input.customer_id
}
}
data charge {
fetch stripe.create_charge {
amount: input.amount
currency: input.currency
customer_id: customer.stripe_id // references first data block
}
}
apply(input: PaymentInput) -> PaymentResult {
return {
charge_id: charge.id
customer_name: customer.name
status: charge.status
}
}
}
The second data block (charge) can reference the output of the first (customer) because blocks execute in declaration order.
See Also
- Workflows Overview -- DAG structure and node types
- Services -- Declaring external API contracts for
fetch - Queries -- Declaring database queries for
query - Compensation & Sagas -- Handling activity failure rollback
- Durable Execution -- Signals and sleep in activities
- LLM Cheat Sheet -- Root objects by context