Opsmas 2025 Day 5: ring & actionboard

Opsmas 2025 Day 5: ring & actionboard

TOC:

ring

into every developer’s life two things happen: they discover then fall in love with bloom filters and then the process repeats again when discovering the lmax disruptor.

we already have bloom filters over in datakit. now, here, now, we present: ring a high-performance C lmax-disruptor style producer/consumer ring buffer.

You know what a distruptor is already. It’s a contiguous memory allocation treated as a ring buffer with indepdnent producer and consumer indexes.

A typical ring buffer is used for things like circular logging where you want to keep recent logs without needing to manage storage or capacity yourself, so you make a 1MB log memory buffer and overwrite old logs with new logs in a ring when the buffer is full. The ring buffer/disruptor pattern adds one more superpower though: a disruptor ring buffer is also aware of how far consumers using the ring buffer have “consumed” into the current “ring buffer ring index” which easily prevents producer “next writes” from erasing “unread” entries across all consumers. feature packed power happening in a single block of memory with no locks to achieve, ideally, safely broadcasting atomic low latency messages/updates at 50+ GB/s inside a large multi-core server (or tiny multi-core laptop).

The primary benefit of using distruptor/mpmc ring buffers with participant cursor indexes is you avoid “big fat heavy” kernel/syscall locking which can be just slow and heavy enough in some cases to impact your performance advantage (not to mention things like pthread mutexes are insanely “big” and blow cache lines out of the water).

The interesting part is when you apply these practices to massively multi-core machines with shared memory because then every core can read and write to the same non-blocking memory space as fast as possible (thanks to the magic of modern processors and multi-word atomic store/load instructions) so all participants in the ring buffer all have their hands in the same memory bucket with no external conflicts.

My adaptation of the system here is interesting (i think) because I tried to allow the system to be shaped in any way feels natural for your own program to work. You can define the size/shape/content of the ring buffer slots then import the ring library and it’ll use your definitions instead of the default slot configuration (default is just every slot being a void * pointer):

You can see the ring system is also designed to be completely isolated/static/interned into your own program so you get the benefit of native compiler single-unit optimizations and inling and any other auto-vectorization or cpu-level speedups all where you need high performance interfaces the most.

Using multiple readers for one writer allows you to have one ‘primary data poster’ then other threads can have individual goals like “write to disk” or “replicate across network” from the same shared and protected high performance ring buffer system.

anyway, here’s some metrics (m1 macbook pro)

================================================================================
                    RING BUFFER PERFORMANCE REPORT
================================================================================
  Configuration: 3 runs, 1000 ms duration per run
  Platform: macOS
  Architecture: ARM64

--------------------------------------------------------------------------------
  THROUGHPUT TESTS
--------------------------------------------------------------------------------

  Single Producer, Single Consumer (small ring) ...
    Throughput:           10.91 M ops/sec (stddev: 4.66%)
    Latency avg:          63.95 ns
    Latency range:        60.15 - 67.58 ns
    Memory:                8960 bytes (8.75 KB)

  Single Producer, Single Consumer (medium ring) ...
    Throughput:           11.58 M ops/sec (stddev: 2.53%)
    Latency avg:          62.64 ns
    Latency range:        60.07 - 64.69 ns
    Memory:              131840 bytes (128.75 KB)

  Single Producer, Single Consumer (large ring) ...
    Throughput:           10.98 M ops/sec (stddev: 4.62%)
    Latency avg:          67.67 ns
    Latency range:        62.74 - 72.24 ns
    Memory:             1049344 bytes (1024.75 KB)

  Single Producer, Single Consumer (larger ring) ...
    Throughput:           11.16 M ops/sec (stddev: 3.08%)
    Latency avg:          65.97 ns
    Latency range:        62.98 - 68.75 ns
    Memory:             4195072 bytes (4096.75 KB)

  Single Producer, Multi Consumer (2)      ...
    Throughput:           12.15 M ops/sec (stddev: 2.81%)
    Latency avg:          58.74 ns
    Latency range:        56.22 - 61.87 ns
    Memory:             2098048 bytes (2048.88 KB)

  Single Producer, Multi Consumer (4)      ...
    Throughput:           12.18 M ops/sec (stddev: 5.41%)
    Latency avg:          57.59 ns
    Latency range:        54.54 - 63.49 ns
    Memory:             2098304 bytes (2049.12 KB)

  Multi Producer (2), Single Consumer      ...
    Throughput:           12.36 M ops/sec (stddev: 2.37%)
    Latency avg:         137.31 ns
    Latency range:       134.07 - 142.76 ns
    Memory:             2097920 bytes (2048.75 KB)

  Multi Producer (4), Single Consumer      ...
    Throughput:            0.26 M ops/sec (stddev: 2.14%)
    Latency avg:       14916.59 ns
    Latency range:     14678.66 - 15373.54 ns
    Memory:             2097920 bytes (2048.75 KB)

  Multi Producer (2), Multi Consumer (2)   ...
    Throughput:            8.89 M ops/sec (stddev: 13.45%)
    Latency avg:         203.99 ns
    Latency range:       179.11 - 251.38 ns
    Memory:             2098048 bytes (2048.88 KB)

  Multi Producer (4), Multi Consumer (4)   ...
    Throughput:            0.17 M ops/sec (stddev: 11.64%)
    Latency avg:       23624.80 ns
    Latency range:     20022.60 - 25708.30 ns
    Memory:             2098304 bytes (2049.12 KB)

--------------------------------------------------------------------------------
  SCALABILITY TESTS (Ring Size Impact)
--------------------------------------------------------------------------------

  Ring size: 16 entries                    ...
    Throughput:            6.21 M ops/sec (stddev: 2.54%)
    Latency avg:          28.70 ns
    Latency range:        28.02 - 29.68 ns
    Memory:                2816 bytes (2.75 KB)

  Ring size: 64 entries                    ...
    Throughput:           11.24 M ops/sec (stddev: 1.33%)
    Latency avg:          62.27 ns
    Latency range:        61.00 - 63.07 ns
    Memory:                8960 bytes (8.75 KB)

  Ring size: 256 entries                   ...
    Throughput:           10.97 M ops/sec (stddev: 2.44%)
    Latency avg:          66.68 ns
    Latency range:        64.25 - 68.92 ns
    Memory:               33536 bytes (32.75 KB)

  Ring size: 1024 entries                  ...
    Throughput:           11.56 M ops/sec (stddev: 2.12%)
    Latency avg:          63.28 ns
    Latency range:        60.92 - 65.16 ns
    Memory:              131840 bytes (128.75 KB)

  Ring size: 4096 entries                  ...
    Throughput:           11.32 M ops/sec (stddev: 3.01%)
    Latency avg:          64.92 ns
    Latency range:        62.33 - 68.50 ns
    Memory:              525056 bytes (512.75 KB)

  Ring size: 16384 entries                 ...
    Throughput:           11.13 M ops/sec (stddev: 2.02%)
    Latency avg:          66.37 ns
    Latency range:        64.73 - 68.78 ns
    Memory:             2097920 bytes (2,048.75 KB)

  Ring size: 262,144 entries                ...
    Throughput:           10.88 M ops/sec (stddev: 3.49%)
    Latency avg:          68.27 ns
    Latency range:        65.77 - 72.71 ns
    Memory:            33555200 bytes (32,768.75 KB)

  Ring size: 1,048,576 entries               ...
    Throughput:           10.79 M ops/sec (stddev: 8.36%)
    Latency avg:          69.63 ns
    Latency range:        63.78 - 80.75 ns
    Memory:           134218496 bytes (131,072.75 KB)

  Ring size: 33,554,432 entries              ...
    Throughput:           11.93 M ops/sec (stddev: 1.86%)
    Latency avg:          60.37 ns
    Latency range:        58.66 - 62.29 ns
    Memory:          4294968064 bytes (4,194,304.75 KB)

--------------------------------------------------------------------------------
  CONTENTION TESTS (Producer Scaling)
--------------------------------------------------------------------------------

  1 producer(s), 1 consumer                ...
    Throughput:           11.80 M ops/sec (stddev: 1.08%)
    Latency avg:          61.47 ns
    Latency range:        60.21 - 62.13 ns
    Memory:           134218496 bytes (131072.75 KB)

  2 producer(s), 1 consumer                ...
    Throughput:            8.56 M ops/sec (stddev: 12.90%)
    Latency avg:         211.77 ns
    Latency range:       173.10 - 240.58 ns
    Memory:           134218496 bytes (131072.75 KB)

  4 producer(s), 1 consumer                ...
    Throughput:            0.25 M ops/sec (stddev: 3.70%)
    Latency avg:       15574.92 ns
    Latency range:     14775.25 - 16011.19 ns
    Memory:           134218496 bytes (131072.75 KB)

  8 producer(s), 1 consumer                ...
    Throughput:            0.15 M ops/sec (stddev: 16.82%)
    Latency avg:       55969.88 ns
    Latency range:     47934.67 - 71041.73 ns
    Memory:           134218496 bytes (131072.75 KB)

--------------------------------------------------------------------------------
  MEMORY EFFICIENCY
--------------------------------------------------------------------------------

  Ring Size vs Memory Usage:
  Entries               Memory (bytes)     Bytes/Entry        Overhead
--------------------------------------------------------------------------------
  16                              2816          176.00          112.00
  64                              8960          140.00           76.00
  256                            33536          131.00           67.00
  1024                          131840          128.75           64.75
  4096                          525056          128.19           64.19
  16384                        2097920          128.05           64.05
  32768                        4195072          128.02           64.02
  65536                        8389376          128.01           64.01
  1048576                    134218496          128.00           64.00
  33554432                  4294968064          128.00           64.00

--------------------------------------------------------------------------------
  SUMMARY
--------------------------------------------------------------------------------

  Best throughput configuration:
    Single Producer, Multi Consumer (2): 12.29 M ops/sec

================================================================================
                         END OF PERFORMANCE REPORT
================================================================================

stats

actionboard

I was looking into stream deck things earlier this year, but even just a slightly outdated iPad should be able to do the same thing without need a dedicated “button machine.”

so i had code robot whip one up for me: actionboard

You can run it with:

action board is fully configurable for any python action. actionboard includes an API where it can be configured dynamically live while running (redundant?) for adding buttons, updating button images and text, and even showing live charts/sparklines inside buttons.

configs look like:

simple action configs

ui design configs

each outer key here is a “tab” on the page with its own icon/button/feature set

and “actions” are basically python functions doing anything a python function can do:

def greet_user_action(name: str = "User"):
    """
    A simple synchronous action that logs a greeting and returns a message.
    This action demonstrates how parameters from `action_params` in the UI config
    can be passed to an action function.

    Args:
        name (str): The name of the user to greet. Defaults to "User".

    Returns:
        dict: A dictionary containing the status and a greeting message.
              This dictionary is often used to provide feedback to the UI.
    """
    message = f"Hello, {name}! This greeting action was successfully triggered."
    logger.info(f"Executing greet_user_action for '{name}'")
    # The returned dictionary can be used by the frontend (e.g., to show a toast message)
    return {"status": "success", "message": message}


def log_current_time_action():
    """
    Logs the current ISO formatted time to the server console and returns it
    along with a success message.

    Returns:
        dict: A dictionary containing the status, current timestamp, and a message.
    """
    now = datetime.datetime.now().isoformat()
    message = f"Current server time: {now}"
    logger.info(f"Executing log_current_time_action. Time: {now}")
    return {"status": "success", "timestamp": now, "message": message}


async def example_async_action(duration: int = 1):
    """
    An example of an asynchronous action that simulates a delay (e.g., a long-running task).

    Args:
        duration (int): The number of seconds to wait. Defaults to 1.

    Returns:
        dict: A dictionary containing the status, a completion message, and the duration.
    """
    message_start = f"Starting async action (simulated duration: {duration}s)..."
    logger.info(message_start)

    await asyncio.sleep(duration)  # Simulate an I/O bound operation

    message_end = f"Async action completed after {duration}s."
    logger.info(message_end)
    return {"status": "success", "message": message_end, "duration": duration}


def another_action():
    """
    Another simple placeholder synchronous action for demonstration purposes.

    Returns:
        dict: A dictionary containing the status and a generic message.
    """
    message = "The 'another_action' was performed successfully!"
    logger.info("Executing another_action")
    return {"status": "success", "message": message}


# To add more actions:
# 1. Define your Python function here (or in another module).
#    - It can be synchronous (def my_action():) or asynchronous (async def my_action():).
#    - It can accept parameters, which will be supplied from `action_params` in `ui_config.yaml`.
#    - It should ideally return a dictionary, often with "status" and "message" keys,
#      to provide feedback to the UI (e.g., for toast notifications).
#
# 2. Register your action in an `actions_config.yaml` file.
#    - This file is typically `user_config/actions_config.yaml` (to override examples)
#      or `config_examples/actions_config.yaml` (for default/packaged actions).
#    - Example registration:
#      ```yaml
#      actions:
#        - id: "my_custom_action"  # Unique ID for this action
#          module: "visual_control_board.actions.built_in_actions" # Or your custom module path
#          function: "my_newly_defined_function_name"
#      ```
#
# 3. Reference the action `id` in your `ui_config.yaml` for a button:
#    ```yaml
#    buttons:
#      - id: "my_button_for_custom_action"
#        text: "Run My Custom Action"
#        action_id: "my_custom_action"
#        action_params: # Optional parameters for your action
#          param1: "value1"
#          count: 10
#    ```

and in API mode you can access the entire config system as read/write live updates re-rendering the interface when changes are posted back which enables (what i think are) unique features like live button additions, live icon/text changes based on external systems, animated button content (sparklines), etc:

def send_button_content_update(
    button_id: str,
    text: str = None,
    icon_class: str = None,
    style_class: str = None,
    sparkline_payload: dict[str, str] | None = None,
):
    """Sends a live content update for a specific button, optionally with sparkline data."""
    payload = {"button_id": button_id}
    has_update = False

    if text is not None:
        payload["text"] = text
        has_update = True
    if icon_class is not None:
        payload["icon_class"] = icon_class
        has_update = True
    if style_class is not None:
        payload["style_class"] = style_class
        has_update = True
    if sparkline_payload is not None:
        payload["sparkline"] = sparkline_payload
        has_update = True

    if not has_update:
        # print(f"No content changes specified for button '{button_id}'. Skipping update.")
        return False

    try:
        response = requests.post(BUTTON_UPDATE_URL, json=payload, timeout=5)
        response.raise_for_status()
        # print(f"Button '{button_id}' content update sent. Response: {response.json().get('message')}")
        return True
    except requests.exceptions.RequestException as e:
        print(f"ERROR: Failed to update button '{button_id}' content.")
        if hasattr(e, "response") and e.response is not None:
            print(
                f"Status Code: {e.response.status_code}, Response: {e.response.text[:200]}"
            )
        else:
            print(f"Error details: {e}")
        return False


def run_all_demos(
    icon_text_button_id: str, sparkline_button_id: str, duration_seconds: int = 60
):
    print(f"\n--- Starting All Demos (Duration: {duration_seconds}s) ---")

    initialize_sparkline_data()
    icon_idx = 0

    start_time = time.time()
    last_icon_text_update_time = 0
    last_sparkline_update_time = 0

    try:
        while time.time() - start_time < duration_seconds:
            current_loop_time = time.time()

            # --- Icon/Text Demo Update (every 2 seconds) ---
            if current_loop_time - last_icon_text_update_time >= 2:
                time_str = time.strftime("%H:%M:%S")
                random_val = random.randint(100, 999)
                new_text_content = f"Icon: {random_val} @ {time_str}"
                new_icon_class = ICONS_TO_CYCLE[icon_idx % len(ICONS_TO_CYCLE)]

                print(
                    f'Updating ICON/TEXT for \'{icon_text_button_id}\': Text "{new_text_content}", Icon "{new_icon_class}"'
                )
                send_button_content_update(
                    icon_text_button_id,
                    text=new_text_content,
                    icon_class=new_icon_class,
                )

                icon_idx += 1
                last_icon_text_update_time = current_loop_time

            # --- Sparkline Demo Update (every 0.5 seconds) ---
            if current_loop_time - last_sparkline_update_time >= 0.5:
                update_sparkline_data_list()
                sparkline_payload = {
                    "data": list(sparkline_data_points),
                    "color": SPARKLINE_BASE_COLOR,
                    "stroke_width": 2,
                }
                sparkline_text = f"Data Points: {len(sparkline_data_points)}"

                # print(f"Updating SPARKLINE for '{sparkline_button_id}': Color {sparkline_payload['color']}, Points {len(sparkline_payload['data'])}")
                send_button_content_update(
                    sparkline_button_id,
                    text=sparkline_text,
                    sparkline_payload=sparkline_payload,
                )
                last_sparkline_update_time = current_loop_time

            time.sleep(0.1)  # Main loop interval

anyway, actionboard is a useful web button board with full yaml design config and even an API driven config where you can read the system config live then splat it back for complete interface rewrites from external data sources or other inputs all triggered on-demand.

the usefulness of the actionboard isn’t really “oh wow buttons on a webpage so what” but more being dynamically reconfigurable and re-designable from a static or online config system so it can be attached to other live systems with good UI to intent to feedback full binding interfaces.

stats

actionboard is built using:

  • Python, FastAPI, HTMX, Jinja2, YAML, Pydantic, WebSockets, and uv

more details in the architecture document too.

lively crowd