Phase 3 — Backend#
Tip
This page is where you meet the fundamentals of the framework. Each of them is called out in a purple Key concept box like the ones below. If you only read one thing per section, read those.
Note
Every code snippet on this page is pulled directly from the SAF examples solution, so what you read here is always in sync with code that is built and tested on every commit.
One consequence: the import paths show the examples namespace. In your own solution, replace
saf.solutions.examples.solution.logic.game_of_life
with
saf.solutions.game_of_life.solution.logic.game_of_life
Where you are heading#
Here is the complete backend you will end up with. Skim it now, then come back to it whenever a snippet below feels out of context.
Complete game_of_life_step.py
"""Backend of the game of life step."""
from ansys.saf.glow.solution import StepModel, StepSpec, long_running, transaction
from saf.solutions.examples.solution.logic.game_of_life import SimulationController
class GameOfLifeStep(StepModel):
"""Step definition of the game of life step."""
grid_size: int = 20
selected_pattern: str = "beacon"
max_iterations: int = 10
initial_grid_state: list[list[int]] = []
grid_states: list[list[list[int]]] = []
@transaction(
self=StepSpec(upload=["initial_grid_state"], download=["selected_pattern", "grid_size"]),
enable_termination_event=True,
)
def display_initial_state(self) -> None:
"""Display the initial state of the Game of Life simulation."""
controller = SimulationController(grid_size=(self.grid_size, self.grid_size))
controller.initialize(self.selected_pattern)
state = controller.get_current_state()
self.initial_grid_state = state.grid.tolist()
@transaction(
self=StepSpec(
upload=["grid_states"],
download=["selected_pattern", "grid_size", "max_iterations"],
),
enable_termination_event=True,
)
@long_running
def simulate(self) -> None:
"""Run the Game of Life simulation."""
controller = SimulationController(grid_size=(self.grid_size, self.grid_size))
controller.initialize(self.selected_pattern)
state = controller.get_current_state()
self.grid_states = []
self.grid_states.append(state.grid.tolist())
self.transaction.raise_event(
message={"grid": state.grid.tolist(), "iteration": 0},
stream_name="my-stream",
)
iterations = 0
while state.live_cells > 0 and iterations < self.max_iterations:
iterations += 1
controller.step_forward()
state = controller.get_current_state()
self.grid_states.append(state.grid.tolist())
self.transaction.raise_event(
message={"grid": state.grid.tolist(), "iteration": iterations},
stream_name="my-stream",
)
Barely fifty lines — and yet it exercises every backend concept SAF has to offer. Now unpack it, one concept at a time.
Add a new step#
The scaffold you created in phase 1 ships with two example
steps — FirstStep and SecondStep. You will replace them with a single new step called
game_of_life_step.
Practice
From the root of the game-of-life folder, run:
saf add-step --step-name game_of_life_step --ui-framework dash
The command prompts for a step template. Press Enter to accept the default
calculator-step, which generates the sample “add two numbers” step you are about to
replace.
saf add-step creates both the backend and the frontend file for the new step at
once:
src/saf/solutions/game_of_life/solution/game_of_life_step.py(backend).src/saf/solutions/game_of_life/ui/pages/game_of_life_page.py(frontend).
It also registers the step in definition.py. You will keep the frontend file for
phase 4; for now, focus on the backend one.
Note
The page router in ui/pages/page.py needs no edit, ever. Pages advertise themselves
through dash.register_page, and the router builds the navigation tree from Dash’s page
registry. Adding or deleting a page file is enough.
The solution definition#
Key concept — Solution definition
definition.py is the single source of truth of the workflow. A StepsModel
subclass lists every step as a typed attribute, and a Solution subclass ties the
display name, the schema version and that step model together. The SAF runtime reads this
file at startup to discover what the solution is made of.
Open src/saf/solutions/game_of_life/solution/definition.py. This is the file the SAF
runtime reads to discover what steps exist and in what order they should be presented.
Right after saf add-step, it looks something like this:
from ansys.saf.glow.solution import Solution, StepsModel
from saf.solutions.game_of_life.solution.first_step import FirstStep
from saf.solutions.game_of_life.solution.second_step import SecondStep
from saf.solutions.game_of_life.solution.game_of_life_step import GameOfLifeStep
class Steps(StepsModel):
"""Workflow definition."""
first_step: FirstStep
second_step: SecondStep
game_of_life_step: GameOfLifeStep
class GameOfLifeSolution(Solution):
"""Solution definition."""
display_name: str = "Game of Life"
version: int = 1
steps: Steps
Three things are worth pointing out:
Stepsinherits fromStepsModeland lists every step as a typed attribute. The attribute name (game_of_life_step) is how you will reach the step from the frontend later on:project.steps.game_of_life_step.GameOfLifeSolutioninherits fromSolutionand ties the display name, a schema version, and theStepsmodel together. This is the entrypoint the SAF runtime picks up when it boots the app.The imports at the top of the file are what the SAF runtime uses to discover the step classes. Removing a step from the definition without removing its import would leave dead code around, so always keep the two in sync.
Practice
You only need one step for this tutorial, so delete the leftover FirstStep and
SecondStep:
Delete the files
solution/first_step.pyandsolution/second_step.py.Delete the files
ui/pages/first_page.pyandui/pages/second_page.py.Remove or rewrite the generated
tests/unit/test_solution_api.pyandtests/unit/test_solution_ui.pybecause they still exercise the deleted calculator step and page.Edit
solution/definition.pyto look exactly like this:from ansys.saf.glow.solution import Solution, StepsModel from saf.solutions.game_of_life.solution.game_of_life_step import GameOfLifeStep class Steps(StepsModel): """Workflow definition.""" game_of_life_step: GameOfLifeStep class GameOfLifeSolution(Solution): """Solution definition.""" display_name: str = "Game of Life" version: int = 1 steps: Steps
Save all the files. There is nothing to change in ui/pages/page.py: deleting the two
page files is enough for them to disappear from the navigation tree.
The step model#
Key concept — Step model
A step is one unit of the workflow, described by a class that inherits from
StepModel. It owns two things, and only two things:
fields: the typed, persistent state of the step;
transaction methods: the place where the business logic is called and the fields are read and written.
Open the freshly generated solution/game_of_life_step.py. The scaffold gives you a toy
adder:
from ansys.saf.glow.solution import StepModel, StepSpec, transaction
class GameOfLifeStep(StepModel):
"""Step definition of the game_of_life_step step."""
first_arg: float = 0
second_arg: float = 0
result: float = 0
@transaction(
self=StepSpec(upload=["result"], download=["first_arg", "second_arg"]),
)
def calculate(self) -> None:
"""Compute the sum of two numbers."""
self.result = self.first_arg + self.second_arg
That is the shape of every SAF step. You are going to replace the whole body.
Typed fields#
Key concept — Typed field
A field is nothing more than a class attribute with a type annotation and a default value. SAF uses those annotations to:
persist the field to storage between transactions;
expose it in the auto-generated REST API;
surface it to the frontend through the
DashClient.
Warning
Every field must carry a type annotation. Untyped attributes lead to data validation errors at runtime.
For the Game of Life, three inputs and two outputs are enough:
class GameOfLifeStep(StepModel):
"""Step definition of the game of life step."""
grid_size: int = 20
selected_pattern: str = "beacon"
max_iterations: int = 10
initial_grid_state: list[list[int]] = []
grid_states: list[list[list[int]]] = []
The first three fields are inputs: the user drives them from the UI. The last two are outputs: they are filled in by the transaction methods.
Warning
Field values must be JSON-serializable, because they travel over the REST API between
the backend and the frontend. That is why initial_grid_state and grid_states are
plain nested list of int rather than NumPy arrays — and why the transactions call
.tolist() before assigning a grid to a field.
The imports the step needs are equally short — the SAF primitives on one side, your own business logic on the other:
from ansys.saf.glow.solution import StepModel, StepSpec, long_running, transaction
from saf.solutions.examples.solution.logic.game_of_life import SimulationController
Practice
In game_of_life_step.py, replace the scaffold imports and the body of
GameOfLifeStep with the two snippets above. Delete the calculate method and the
first_arg, second_arg and result fields — you don’t need them.
SimulationController import at your business logic module:from saf.solutions.game_of_life.solution.logic.game_of_life import SimulationController.Transaction methods#
Key concept — Transaction method
Fields are inert data. Transaction methods are what make a step do something. In
SAF they are the place where the business logic is called and the fields are read and written.
Any method that touches a field must be decorated with @transaction.
Every transaction comes in one of two flavors:
Blocking (the default): The caller waits until the method returns. Use it for fast, deterministic work.
Long-running (
@long_running): The call returns immediately and the method keeps going in the background. Use it for anything slow or iterative.
Blocking or long-running?#
The two flavors are not interchangeable: the choice is driven by how long the work takes and by what the user should be able to do while it runs.
Blocking transaction |
Long-running transaction ( |
|
|---|---|---|
Declaration |
|
|
Caller behavior |
Waits for the method to return |
Returns immediately, work continues in the background |
Typical duration |
Milliseconds to a couple of seconds |
Seconds, minutes, hours |
UI during execution |
Frozen — the user can do nothing else |
Responsive — the user can navigate, cancel, watch progress |
Progress reporting |
None: the result appears all at once |
|
Monitoring and control |
Not applicable |
|
Typical use cases |
Validating inputs, preparing a preview, formatting results, reading a small file |
Running a solver, meshing, launching a PyAnsys session, iterative loops, batch post-processing |
Tip
Rule of thumb: If the user would notice a spinner, make it long-running. A blocking transaction that takes more than a second or two makes the whole application feel broken, and one that takes minutes will hit client-side timeouts.
Conversely, do not make everything long-running. The background machinery adds latency and forces the frontend to handle events and completion, which is needless complexity for a computation that takes 10 ms.
In this step, you will write one of each: display_initial_state is blocking, and
simulate — covered in the next section — is
long-running.
Writing a blocking transaction#
The user picks a pattern in the UI and immediately wants to see it drawn on the grid. That is a tiny, instantaneous computation, so a plain blocking transaction is the right tool:
@transaction(
self=StepSpec(upload=["initial_grid_state"], download=["selected_pattern", "grid_size"]),
enable_termination_event=True,
)
def display_initial_state(self) -> None:
"""Display the initial state of the Game of Life simulation."""
controller = SimulationController(grid_size=(self.grid_size, self.grid_size))
controller.initialize(self.selected_pattern)
state = controller.get_current_state()
self.initial_grid_state = state.grid.tolist()
Three lines of business logic, wrapped in a decorator. Look at that decorator closely — it carries two new concepts.
The @transaction decorator#
Key concept — @transaction
The decorator does three important things behind the scenes:
It runs the method in an isolated execution context, so it behaves identically on your laptop, in a Docker container, or on an on-prem cluster.
It publishes the method as a POST endpoint in the auto-generated REST API. You will call it from the Dash frontend in phase 4, but you could just as well call it with
curl.It records which fields the method reads and which it writes, so the SAF runtime knows what to load before the call and what to persist after it.
That last point is what the StepSpec argument is for.
Field dependencies with StepSpec#
Key concept — StepSpec
StepSpec declares the field dependencies of a transaction: download lists the
fields loaded from storage before the method runs, and upload lists the fields
persisted after it finishes. A field that is not declared either holds no meaningful
value on entry, or is silently dropped on exit.
In display_initial_state, that declaration reads:
self = StepSpec(
upload=["initial_grid_state"],
download=["selected_pattern", "grid_size"],
)
Read it like this:
Argument |
Meaning |
|---|---|
|
Fields the transaction reads from persistent storage before running. The method’s inputs. Only these fields are guaranteed to hold a meaningful value inside the body. |
|
Fields the transaction writes back to persistent storage after it finishes. The method’s outputs. Fields you assign but forget to declare are silently dropped. |
Tip
Rule of thumb: if a field appears on the left-hand side of an assignment inside the
transaction, it belongs in upload. If it appears on the right-hand side, it belongs in
download. Forgetting either is the number-one gotcha for new SAF developers.
Why declare this explicitly instead of letting SAF figure it out? Because download /
upload is precisely what lets a solution be split across process, container and machine
boundaries without you writing a single line of serialization code. The step model becomes a
contract that the runtime can honor anywhere.
Note
The keyword is self= because a transaction may depend on several steps, not just
its own. A step can declare other_step=StepSpec(download=[...]) to read fields produced
upstream — that is how a multi-step workflow chains data together.
Practice
Add the display_initial_state transaction to game_of_life_step.py.
You now have a fully working blocking transaction. It is already testable through the
auto-generated REST API — try it later with saf run --no-ui if you are curious.
Long-running transactions#
The simulation itself is a different beast: it is an iterative loop that can run for seconds or minutes depending on the grid size and the iteration count. It lands squarely in the right-hand column of the comparison table above. Blocking the UI for that long is not acceptable — and the user would much rather watch the grid evolve than stare at a spinner until the final result arrives.
Two more SAF features cover exactly this:
the
@long_runningdecorator, which turns the transaction into a non-blocking background job;self.transaction.raise_event(...), which streams events from the running method back to any interested listener.
Here is the resulting transaction:
@transaction(
self=StepSpec(
upload=["grid_states"],
download=["selected_pattern", "grid_size", "max_iterations"],
),
enable_termination_event=True,
)
@long_running
def simulate(self) -> None:
"""Run the Game of Life simulation."""
controller = SimulationController(grid_size=(self.grid_size, self.grid_size))
controller.initialize(self.selected_pattern)
state = controller.get_current_state()
self.grid_states = []
self.grid_states.append(state.grid.tolist())
self.transaction.raise_event(
message={"grid": state.grid.tolist(), "iteration": 0},
stream_name="my-stream",
)
iterations = 0
while state.live_cells > 0 and iterations < self.max_iterations:
iterations += 1
controller.step_forward()
state = controller.get_current_state()
self.grid_states.append(state.grid.tolist())
self.transaction.raise_event(
message={"grid": state.grid.tolist(), "iteration": iterations},
stream_name="my-stream",
)
The @long_running decorator#
Key concept — @long_running
Stacking @long_running under @transaction turns the method into a non-blocking
background job:
The call returns immediately on the client side. The transaction starts asynchronously and the SAF runtime tracks its state.
The client can poll the method status through
step.get_method_state("simulate"), or listen for the termination event.
Warning
Decorator order matters. @long_running sits immediately above the method and
below @transaction:
@transaction(...)
@long_running
def simulate(self) -> None: ...
Swap the two and the runtime cannot see the long-running semantics.
Also notice the download set: simulate needs the same inputs the user tuned before
launching the run (selected_pattern, grid_size) plus one that
display_initial_state does not care about — max_iterations. Each transaction declares
only what it truly needs, nothing more.
Events#
A long-running job that says nothing until it is done is barely better than a blocking one.
Key concept — Event
Events are how a SAF backend talks to the outside world while it is still running.
Inside a transaction, self.transaction.raise_event(message=..., stream_name=...)
publishes a JSON-serializable message on a named stream that any listener — typically the
frontend — can subscribe to in real time.
Every call to raise_event looks like this:
self.transaction.raise_event(
message={"grid": state.grid.tolist(), "iteration": iterations},
stream_name="my-stream",
)
messageis any JSON-serializable dict. Here, one full grid plus the generation number.stream_nameis a free-form identifier that you choose. The frontend subscribes to it by name in phase 4.
Multiple streams can coexist. A common pattern in real solutions is one stream per kind of update — progress, warnings, intermediate artifacts.
Note
Events are transient: they are delivered to whoever is listening at that moment and are
not replayed. That is why simulate also appends every grid to the grid_states
field. The events drive the live animation; the field is the durable record that survives
a page reload.
Termination events#
Key concept — Termination event
Passing enable_termination_event=True to @transaction asks SAF to emit an event on
a stream named after the method whenever it terminates — whether it succeeded, failed,
or was cancelled.
Both transactions of the step enable it, so the simulate method produces two kinds of
events:
Stream |
Emitted by |
Used by the frontend to… |
|---|---|---|
|
Your explicit |
Redraw the heatmap after every generation |
|
|
Re-enable the “Start simulation” button and show a notification |
Practice
Add the simulate transaction to game_of_life_step.py. Your file should now match
the complete listing at the top of this page — take a moment to diff yours against it.
The mental model#
At this point GameOfLifeStep embodies the whole backend and it demonstrates every SAF
building block you will ever need:
flowchart LR
subgraph Frontend
UI[Dash page]
end
subgraph Backend[SAF backend]
direction TB
F1[grid_size, selected_pattern, max_iterations]
F2[initial_grid_state]
F3[grid_states]
T1["display_initial_state()<br/>(blocking)"]
T2["simulate()<br/>(long_running)"]
end
UI -- sets fields --> F1
F1 -- download --> T1
F1 -- download --> T2
T1 -- upload --> F2
T2 -- upload --> F3
F2 -- read via DashClient --> UI
T2 -- raise_event --> UI
Fields hold state. They are typed and persistent.
Transactions are the place where the business logic is called and the fields are read and written.
download/uploaddependencies throughStepSpec.Blocking transactions return only after the work is done.
Long-running transactions return immediately and use events to stream progress and a termination event to signal completion.
Key takeaways#
Important
The solution definition lists the workflow steps in a
StepsModeland ties them together with theSolutionclass.A step model is a
StepModelsubclass whose typed class attributes are its fields. Every field needs a type annotation — no exceptions.Field values must be JSON-serializable. Convert NumPy arrays with
.tolist()before storing them in a field.Any method that touches fields is decorated with
@transactionand declares its field dependencies in aStepSpec(downloadfor reads,uploadfor writes).Stack
@long_runningunder@transactionto turn a method into a background job that returns immediately.Emit real-time updates from a long-running method with
self.transaction.raise_event(message=..., stream_name=...).Set
enable_termination_event=Trueon a transaction to get an automatic event on the stream named after the method when it finishes (success, failure or cancellation).