Tasks and Artifacts

Report units of work with rb.task, parallelize them with threads or Function.map, and register durable outputs with rb.artifact.

Tasks and artifacts answer two different questions about a run:

  • A task says what unit of work ran, whether it succeeded, and what happened to it.
  • An artifact says where a durable output was written.

Neither API moves your workload or your data by itself. rb.task(...) is an inline status scope, and rb.artifact(...) registers a URI after your code has written the output. The run's [ Tasks ] and [ Artifacts ] views then make both visible without requiring someone to reconstruct them from logs or a result JSON document.

Report Inline Work with rb.task

Use a task context around one named unit of work:

import rebase as rb


@rb.workflow(project="energy")
def capture_all(datasets: list[str]) -> dict:
    import rebase as rb
    from energy_capture import fetch_and_store

    captured = []
    failures = []

    for dataset in datasets:
        try:
            with rb.task(
                f"Capture {dataset}",
                key=dataset,
                parameters={"dataset": dataset},
            ) as task:
                uri, rows = fetch_and_store(dataset)
                task.set_result({"outcome": "captured", "rows": rows, "uri": uri})
                captured.append(uri)
        except Exception as exc:
            # Catch outside the context. __exit__ has already reported this task as failed.
            failures.append({"dataset": dataset, "error": str(exc)})

    if failures:
        raise RuntimeError(f"{len(failures)} captures failed: {failures}")
    return {"captured": captured}

The examples use energy_capture.fetch_and_store as an application-owned storage helper. Replace it with your implementation and include that module in the deployed source or image. Imports used at runtime belong inside the decorated body because Rebase can ship that body independently.

Entering the context reports running. A normal exit reports succeeded; an exception reports failed and is re-raised. task.set_result(...) is optional and accepts a JSON object. Domain outcomes such as pending, skipped, or reused normally belong in that result while the lifecycle status remains succeeded.

rb.task does not create a worker, retry the body, or make the loop parallel. It records work that is already executing in the current function or workflow process.

Catch exceptions outside the with rb.task(...) block when later units should continue. If the overall run should also fail, collect the failures and raise after the remaining tasks have finished, as in the example above.

Outside a hosted run, the same context manager validates its inputs and behaves as an in-memory no-op. That keeps the function ordinary Python during local execution.

Parallel Inline Tasks with Threads

For a modest amount of I/O-bound work, a thread pool can run several inline tasks in the same container. Create the rb.task inside each worker so every unit gets its own lifecycle record:

import rebase as rb


@rb.function(project="energy", run_type="long")
def capture_threaded(datasets: list[str], max_workers: int = 8) -> dict:
    from concurrent.futures import ThreadPoolExecutor, as_completed
    from contextvars import copy_context

    import rebase as rb
    from energy_capture import fetch_and_store

    def capture_one(dataset: str) -> dict:
        with rb.task(
            f"Capture {dataset}",
            key=dataset,
            parameters={"dataset": dataset},
        ) as task:
            uri, rows = fetch_and_store(dataset)
            artifact = rb.artifact(
                f"Captured dataset {dataset}",
                uri=uri,
                key=f"capture/{dataset}",
                media_type="application/parquet",
                metadata={"dataset": dataset, "rows": rows},
            )
            result = {"dataset": dataset, "rows": rows, "artifact_id": artifact.id, "uri": uri}
            task.set_result(result)
            return result

    completed = []
    failures = []
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = {}
        for dataset in datasets:
            # Use a fresh snapshot once per worker. It carries this hosted run's
            # request-local reporting identity into the new thread.
            context = copy_context()
            futures[pool.submit(context.run, capture_one, dataset)] = dataset

        for future in as_completed(futures):
            dataset = futures[future]
            try:
                completed.append(future.result())
            except Exception as exc:
                failures.append({"dataset": dataset, "error": str(exc)})

    if failures:
        raise RuntimeError(f"{len(failures)} captures failed: {failures}")
    return {"completed": completed}

copy_context() is load-bearing. Hosted service runners keep run credentials and IDs in Python context variables so concurrent requests cannot see one another's identities. ThreadPoolExecutor workers do not reliably inherit that request context. Taking a fresh snapshot for each submitted worker preserves task reporting and lets rb.artifact inherit the task created inside that worker. A single Context object must not be entered by two threads simultaneously, which is why the loop calls copy_context() once per submission.

Threads are a good fit for bounded, I/O-heavy work that shares one environment. They still share the process, memory limit, CPU allocation, and failure domain. For CPU-heavy work, stronger isolation, or wider fan-out, deploy the unit as a function and use Function.map.

Platform Fan-out with Function.map

Function.map submits one deployed function run per input. When issued inside a workflow step, every map item is automatically recorded as a task of the parent workflow run. You do not need to wrap each item in rb.task.

import rebase as rb

project = rb.project("energy")


@project.function(name="capture-one", run_type="quick")
def capture_one(dataset: str) -> dict:
    import rebase as rb
    from energy_capture import fetch_and_store

    uri, rows = fetch_and_store(dataset)
    artifact = rb.artifact(
        f"Captured dataset {dataset}",
        uri=uri,
        key=f"capture/{dataset}",
        disposition="created",
        media_type="application/parquet",
        metadata={"dataset": dataset, "rows": rows},
    )
    return {"dataset": dataset, "rows": rows, "artifact_id": artifact.id, "uri": uri}


@project.step(name="capture-datasets")
def capture_datasets(datasets: list[str], max_concurrency: int = 20) -> dict:
    import rebase as rb

    # A step body is shipped independently, so resolve the deployed function by name.
    capture = rb.get_function("energy", "capture-one")
    items = [{"dataset": dataset} for dataset in datasets]
    outcomes = list(
        capture.map(
            items,
            max_concurrency=max_concurrency,
            return_exceptions=True,
        )
    )

    completed = []
    failures = []
    for item, outcome in zip(items, outcomes):
        if isinstance(outcome, Exception):
            failures.append({"dataset": item["dataset"], "error": str(outcome)})
        else:
            completed.append(outcome)

    if failures:
        raise RuntimeError(f"{len(failures)} captures failed: {failures}")
    return {"completed": completed}


@project.workflow(name="daily-capture")
def daily_capture(datasets: list[str]) -> dict:
    return capture_datasets(datasets)

The map call returns results in input order by default, which makes zip(items, outcomes) safe. Set ordered=False when you want results as soon as they complete and each result already carries enough identity to stand on its own. With return_exceptions=True, one failed item is yielded as an exception instead of stopping iteration at the first failure.

The runtime passes each map item's task ID into the child function. Consequently an rb.artifact(...) call inside capture_one appears in the parent workflow's [ Artifacts ] view, linked to the correct map task, while retaining the child function run as its producer.

A map uses platform compute for every item and is subject to workspace concurrency and compute limits. max_concurrency is a ceiling, not a request to bypass those limits.

Threads or Function.map?

Thread pool + rb.taskFunction.map
Where work runsThreads in one function/workflow containerSeparate deployed function runs
Task recordsCreated explicitly with rb.taskOne task per map item automatically
Context handlingPropagate with a fresh copy_context() per workerInjected by the Rebase runtime
Best forModest I/O-bound concurrency, shared in-process stateWider fan-out, CPU work, isolation, per-item run visibility
Failure domainOne process and one memory/CPU allocationEach item has its own function run
EnvironmentExactly the caller's environmentThe mapped function's deployed image, secrets, env, and attachments

Register Durable Outputs with rb.artifact

Write the object first, then register the location:

uri = upload_parquet(frame, "gs://forecast-archive/2026-08-11/SE3.parquet")

artifact = rb.artifact(
    "SE3 day-ahead forecast",
    uri=uri,
    key="forecast/SE3",
    disposition="created",
    media_type="application/parquet",
    size_bytes=218_420,
    version="1741632447112345",
    digest="md5:8d777f385d3dfec8815d20f7496026dc",
    metadata={"zone": "SE3", "rows": 24},
)

Rebase stores this record; it does not upload, download, proxy, or validate the bytes at the URI. The URI must be absolute and include a scheme, such as gs://, s3://, or https://.

ArgumentDescription
nameHuman-readable output name shown in the run.
uriDurable absolute URI. Required.
keyOptional logical identity within the run. Retrying the same key and URI is idempotent; using the key for another URI is rejected.
disposition"created" by default, or "reused" when this run found an existing output.
media_typeOptional MIME type such as application/json or application/parquet.
size_bytesOptional non-negative object size.
versionOptional provider version, generation, snapshot, or ETag.
digestOptional checksum with an explicit algorithm prefix, such as md5:….
metadataOptional JSON object with domain metadata.

Register reused outputs too. The disposition explains why the run did not write them:

if object_exists(uri):
    artifact = rb.artifact(
        "SE3 day-ahead forecast",
        uri=uri,
        key="forecast/SE3",
        disposition="reused",
        media_type="application/parquet",
    )
else:
    write_forecast(uri)
    artifact = rb.artifact(
        "SE3 day-ahead forecast",
        uri=uri,
        key="forecast/SE3",
        disposition="created",
        media_type="application/parquet",
    )

Inside rb.task, an artifact inherits the inline task ID. Inside a mapped function, it inherits the map task ID. Otherwise it is attached directly to the active run and step.

Hosted registration is strict. If Rebase cannot store the record, ArtifactReportingError is raised so a successful upload is not silently missing from the run's provenance. Outside a hosted run, the call validates its arguments and returns an Artifact with id is None without making an API request.

Inspect Tasks and Artifacts

The TUI interleaves tasks and artifacts with run events, steps, and logs. Use the [ Tasks ] and [ Artifacts ] filters for focused tables.

From Python:

run = rb.Run(run_id)

for task in run.tasks():
    print(task["name"], task["status"], task.get("error"))

for artifact in run.artifacts():
    print(artifact["name"], artifact["disposition"], artifact["uri"])

The lower-level client can filter by step or task:

client = rb.Client()

tasks = client.list_run_tasks(run_id, step_run_id=step_run_id)
artifacts = client.list_run_artifacts(run_id, step_run_id=step_run_id)
artifacts = client.list_run_artifacts(run_id, task_id=task_id)

See the rb.task reference, rb.artifact reference, and Function.map reference.

On this page