Skip to main content

Introduction to Oryonix

What Is Oryonix?

Oryonix is a platform that enables you to build and run distributed, scalable, secure applications. It easily and automatically handles most aspects of infrastructure, such as deployment, scaling, fault recovery, and more. Within a service, it also simplifies all aspects of distributed computing—including state management, failure recovery, distributed coordination, and authorization. With Oryonix, you can focus purely on your business logic.

Oryonix takes care of:

  • API endpoint mapping
  • Function invocation and orchestration
  • State persistence and recovery
  • Parallel execution coordination
  • Database connection management
  • Automatic scaling based on load
  • Logging and observability

As a developer, you are responsible for:

  • Defining your applications and the architecture for those applications (e.g. which services there are and how they communicate)
  • Defining the APIs for each of your services
  • Implementing those APIs in Python using the Oryonix Python libraries
  • Validating that your application works as expected
  • Deciding when and what to deploy when it's time to launch or update your application

Organizing Your Applications

There is a hierarchy to how applications are managed within Oryonix:

  • Each Oryonix customer has access to their own, isolated set of Workspaces.
  • Each workspace contains a set of Applications.
  • Each application is composed of a set of Services.

Workspaces

A Workspace is purely an administrative grouping and otherwise has no significance. Related applications can be grouped under the same workspace, and different applications can be separated into different workspaces. Follow the structure that suits you best.

Applications

An Application is a collection of related services. Each application can be deployed to an environment, such as "Production" or "Staging", and all the services for the application will be deployed together.

Services

A Service is a collection of related APIs that can be called by other services or end users. The code for each service comes from the service's source code repository, where it is compiled into WebAssembly and uploaded to Oryonix to deploy and run.

Environments

Applications and services are uploaded to and run on Oryonix's infrastructure. In fact, Oryonix can be used throughout the whole software lifecycle, from development to production. Isolation is achieved through the use of environments, such as Production or Staging.

Environments are specific to applications. That is, each application will have its own environment named Production, or Staging (for example). When an application is deployed, it is always deployed into a specific environment. During deployment, the WebAssembly binaries for each service are attached to that environment, so they will be executed by the Oryonix infrastructure whenever API calls for the application come in.

Running Your Applications

Every request (or event) for each service is run in its own isolated context in the Oryonix infrastructure. This means that requests cannot share global state, except through persistence mechanisms such as writing to an external database. However, this isolation allows Oryonix to provide highly-reliable and secure service--isolating each request from any noisy neighbors, and locking down access to only those external resources needed to service the request.

Scaling

There are no scaling considerations for services, because services that are not actively in use do not consume any resources other than storage. Similarly, if a service is suddenly flooded with requests, the Oryonix infrastructure will run as many parallel instances of the service's WebAssembly binary as needed to satisfy the incoming requests in a timely fashion.

Failure Recovery

The Oryonix infrastructure will also take care of most transient failures without the need for an application to be aware of them. For example, if a service cannot query its database due to a database outage, the infrastructure will transparently retry the query until the database is available again. This also extends to failures of the Oryonix infrastructure itself--if the node running a service suffers a hardware failure in the middle of handling a request, the request will be resumed on another node right from the point it left off.

The tradeoff for this level of service reliability is that services must be structured in a way to allow the Oryonix platform to observe, track, and record their behavior.

Building Your Services

A service is the fundamental unit of deployment and execution in Oryonix. Each service is defined by a set of REST API endpoints, which are implemented by functions in your code. Oryonix provides a Python SDK to help you write these functions.

Specifying Your API

The API for each service is defined in an OpenAPI specification file (typically spec/service.yaml). This file specifies the endpoints, HTTP methods, request and response schemas, and other details about how clients can interact with your service.

REST API endpoints are linked back to your Python code through the operationId field in the OpenAPI spec. The value of operationId must match the name of a function in your code that implements the logic for that endpoint. For example, if you have an endpoint defined like this:

paths:
/hello:
post:
operationId: hello_world
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string

Then you must have a function in your code with the signature def hello_world(name: str): to handle requests to that endpoint. The parameters and return value of the function must match the properties defined in the OpenAPI for Oryonix to properly validate incoming requests and pass data into and out of your function.

This function must additionally be an Oryonix flow function.

Implementing Your API

APIs in Oryonix are implemented as Python functions. Unlike other programming environments, Python functions in Oryonix are broken down into a few different types. This is because Oryonix needs to be able to observe and manage the execution of your code in order to provide its reliability guarantees. In addition to regular Python functions, Oryonix provides two special types of function: Action Functions and Flow Functions.

This distinction is important because failures are handled specially for action and flow functions. In normal Python code, if a failure occurs, an exception is thrown. If the exception is not explicitly caught and handled in an except block, the entire program will crash. In Oryonix, failures are divided into transient and non-transient failures. Non-transient failures still need to be handled like normal, but transient failures are handled automatically by the system.

Action Functions

Action Functions are the basic building blocks of an Oryonix service. Anything that interacts with the outside world must be defined as an action function. In general, if you can call the same function twice with the same arguments, and it might return different results or have outside impacts, that function must be defined as an action function.

Here are some examples of operations that should be written as action functions:

  • Querying and/or updating a database
  • Making an API call to another service
  • Getting the current date/time
  • Generating a random number or UUID

If an action function fails due to a transient failure (e.g. a temporary network issue, a database timeout, etc.), Oryonix will automatically retry it until it succeeds. If it fails due to a non-transient failure (e.g. invalid input, an unhandled exception in the code, etc.), the failure will be propagated back to the caller.

Flow Functions

Flow functions are the "glue" of an Oryonix service. That is, they are higher-level functions that are typically used to implement business logic and end-to-end workflows. All API endpoints for an Oryonix service must be implemented as flow functions, and these flow functions can then call action functions or other flow functions to perform the actual work.

This means that flow functions can be used in a wide variety of situations, including:

  • Implementing API endpoints
  • Orchestrating multi-step workflows
  • Coordinating parallel execution of action functions
  • Waiting for and reacting to external events (described later)

Unlike action functions, if a flow function experiences a transient failure (e.g. a temporary network issue, a failure of the underlying infrastructure, etc.), Oryonix will, in effect, resume the flow function right from where it left off. To do this, it first records all the actions taken by the flow function along with their results. Then, when the function fails, Oryonix restarts the flow function from the beginning, and replays the saved results back to the flow function until it catches up to where it left off.

When to Use Each Function Type

There is one guiding principle to remember:

tip

Action functions are for interacting directly with the world, and flow functions are for deciding when and how to interact with the world.

Indeed, this principle is baked into Oryonix's architecture, because there are two rules governing how action and flow functions interact:

  • Flow functions may call any other function. This includes other flow functions, action functions, and regular Python functions.
  • Action functions may only call regular Python functions. They cannot call flow functions or other action functions.

Here are some general guidelines for when to use each function type:

  • If the function implements an API endpoint for your service, it must be a flow function.
  • If the function implements a multi-step workflow, it should be a flow function that calls action functions (and/or other flow functions) to perform the individual steps.
  • If the function interacts with the outside world (e.g. makes an API call, queries a database, etc.), it must be an action function.
  • Prefer writing smaller action functions that perform single operations (e.g. a single database query, or a single API call) over larger action functions that perform multiple operations.
  • Split out operations that generate IDs or timestamps into action functions that are separate from the action functions that use those IDs and timestamps.

You can learn more about how action and flow functions work together in Flow and Action Functions.

Example: Payment Processing

Let's look at a simple payment-processing example to see how action and flow functions work together in a real application, and how Oryonix's failure handling and durable execution features allow for simplified business logic and reliable execution.

Action Functions Are Small, Idempotent Operations

Here is an example action function that records a payment in an invoices table. We can tell this is an action function because of the @onix.action decorator attached to it. Note how it's written to be idempotent--if it is called multiple times with the same invoice_id and amount, it will only create one payment record for that invoice and amount, because it uses the SQL ON CONFLICT clause to ensure that only one record can exist for a given invoice_id and amount:

from datetime import datetime
import onix

@onix.action
def record_payment(invoice_id: int, payment_id: str, amount: float):
db = onix.create_connection("finance")

# Note: It's okay that we're generating a timestamp inside this action
# function, because the action function is idempotent with respect to
# invoice_id and payment_id. We don't actually care that the timestamp might
# be different on each retry.
timestamp = datetime.now()

onix.query_database(db,
"""INSERT INTO invoice_payments
(invoice_id, payment_id, date, amount) VALUES (?, ?, ?, ?)
ON CONFLICT (invoice_id, payment_id) DO UPDATE SET date = EXCLUDED.date, amount = EXCLUDED.amount
RETURNING payment_id""",
invoice_id, payment_id, timestamp, amount)

If this function fails due to a transient issue (e.g. a temporary database outage, or a crash of the action function's runtime), Oryonix will automatically retry it from the beginning until it succeeds. If it fails due to a non-transient issue (e.g. invalid input), the failure will be propagated back to the caller.

Because the SQL statement uses ON CONFLICT, if this function is retried multiple times with the same invoice_id and payment_id, it will only create one record in the invoice_payments table for that invoice and amount. This idempotency is important for failure recovery in flow functions to work properly, as we will see below.

Flow Functions Orchestrate Action Functions

Here's an example flow function that processes and records a payment for an invoice. We can tell this is a flow function because of the @onix.flow decorator attached to it. It generates a unique payment ID, charges the user's credit card, and then calls the record_payment action function, defined above, to perform the actual database operation:

import onix
from payment_processor import charge_credit_card, CreditCardDeclinedError

@onix.flow
def process_payment(invoice_id: int, cc_number: str, amount: float) -> str:
# Generate a unique ID for this payment. This is done in a separate action
# function so that later action functions can use the payment_id to
# determine if they have been retried or not.
payment_id = gen_payment_id()

# Charge the user's credit card--this might be a flow itself, or it could be
# an action function that makes an external API call.
try:
charge_credit_card(payment_id=payment_id, cc_number=cc_number, amount=amount)
except CreditCardDeclinedError:
raise PaymentFailedError("Credit card was declined")

# Record the successful payment in the database.
record_payment(invoice_id=invoice_id, payment_id=payment_id, amount=amount)

return payment_id

# Helper action function for generating a unique payment ID
@onix.action
def gen_payment_id() -> str:
return str(uuid.uuid4())

class PaymentFailedError(Exception):
pass
note

Flow and action functions must be invoked using keyword arguments. Positional arguments are not allowed.

This is because execution state is persistent within Oryonix, and using keyword arguments provides a more stable API for actions and flows to interact as they change over time. It also makes for a more reliable mapping to REST APIs, which are generally keyword-based.

Apart from the simplified handling of the user's credit card information, this flow function is written the way you would expect a production-grade flow function to be written. It calls action functions to generate a payment ID, process the payment, and update the database, and it handles a potential non-transient failure (the credit card being declined) by catching the exception and re-throwing an error appropriate to the API.

Notably, it does not record any information in a database prior to charging the user's credit card. In a service without durable execution, this step would be necessary to avoid a situation where the user's card is charged but the service has no record of the charge--potentially leading to confusion about whether the user has paid their invoice or not. In Oryonix, this extra logic is unnecessary, because Oryonix durably records the execution state of the flow function itself.

What Happens if a Failure Occurs?

If a failure happens while execution is within process_payment() itself (i.e. while no action functions are running), Oryonix will simply resume from the last checkpoint, and replay the results of any action functions that were called after that checkpoint and before the crash.

If a failure happens during execution of an action function, Oryonix will replay process_payment() as described above, and then retry the failed action function from the beginning. No matter where the failure occurred, only one action function would need to be retried. The action functions that had already completed would simply have their results replayed to process_payment() without needing to be re-executed.

For example, if the failure occurred in charge_credit_card(), then gen_payment_id() would not need to be retried--the same payment ID would be reused for the second charge attempt. Similarly, if the failure occurred in record_payment(), then both gen_payment_id() and charge_credit_card() would not need to be retried--the same payment ID would be reused, and the credit card would not be charged again.

Oryonix's Execution Guarantees

In any case, there is no risk of the user's card being charged without a corresponding record in the database, because Oryonix guarantees that process_payment() either does not run at all, or eventually runs to completion.

Similarly, there is no risk of duplicate charges or database entries so long as each action function is idempotent, because Oryonix will never retry an action function that has returned its result to its caller.