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:

  1. Data blocks -- One or more named blocks that perform side effects. Each block has a single data operation.
  2. Apply block -- A pure transformation that receives the activity input and data block results, then returns a typed output.
  3. 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

RootDescription
inputThe typed input to the activity
Data block namesResults from each data block (e.g., customer, inventory)

Rules

Retry Policy

The retry_policy block configures automatic retries when a data operation fails:

retry_policy {
  max_attempts: 5
  backoff: 1s
  max_backoff: 60s
}
FieldTypeDescription
max_attemptsIntMaximum number of attempts (including the first). Default varies by runtime.
backoffDurationInitial delay between retries.
max_backoffDurationUpper 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