Skip to content

Native rewrites every open wait position on each reorder (Workflow #899 parity) #783

Closed

Description

@rmcdaniel

Problem

Native maintains workflow_run_waits.position in Store::order_open_waits (rust/src/runtime/wait_projection.rs). It reads the run's open waits in chronological order (OPEN_WAIT_ORDER: opened_at, then wait_id) and then issues UPDATE workflow_run_waits SET position=$1 WHERE id=$2 AND workflow_run_id=$3 for every open wait, every time, even when that wait's position hasn't changed.

That is the same write-churn class Workflow #899 removes from PHP's RunWaitProjector: unchanged waits rewritten on each projection, which is especially costly on MySQL. A run with N open waits costs N UPDATEs per reorder.

Parity

The ordering itself already matches #899's new storage semantics:

  • Positions are a chronological ordinal over open waits.
  • Native's observation read (run_observation_waits.rs) selects only open waits, ordered by position, wait_id.

So a database written by PHP with #899, or without it, reads the same through native. No HTTP or protocol shape changes, so no fixture changes.

Proposed fix

Read each open wait's current position along with its id, and update only the rows whose position differs. When nothing moved, emit no writes.

Add a test that an unchanged set of open waits causes zero position updates, and that one new or resolved wait updates only the waits whose ordinal moved. Run it on SQLite, PostgreSQL, MySQL and MariaDB.

Follows Workflow #899 (Cloud) and belongs to the Rust Server #325 line.

🤖 Generated with Claude Code

Activity

  1. rmcdaniel commented on Oct 26, 2026

    @rmcdaniel
    MemberAuthor

    Correction to this issue's parity section, after reading Workflow PR #941 at c608431d.

    What #941 stores. RunWaitProjector::chronologicalPositions ranks every retained wait of the run, whatever its status, by opened_at in milliseconds (null last), then wait_id. WorkflowRunWait::scopeInDisplayOrder applies the status ranking only when reading.

    What native stores today. order_open_waits ranks only open waits. A resolved or cancelled wait keeps whatever ordinal it last had as an open wait, so resolved rows can share positions or fall out of chronological order. Native's own reads aren't affected, because run_observation_waits selects only open waits and both orderings rank open waits the same way. A PHP reader of a native-written database would see resolved waits out of order until PHP projects the run again. So the line "native already stores chronological ordinals" holds for open waits only.

    Fix on branch fix/783-native-wait-position-churn

    • Native stores #941's ordinal over all retained waits, using the same millisecond and wait_id ordering it already used for open waits.
    • One query per page ranks the run's waits in the database (ROW_NUMBER()) and returns only the rows whose stored position differs. Native then updates those rows by primary key.
    • Resolving a wait no longer moves any other wait, so most transitions write no positions. A new wait is normally inserted at its final ordinal (sequence - 1), so it writes nothing either.
    • Ranking stays a plain consistent-read SELECT followed by primary-key updates. That keeps MySQL from taking the gap locks a multi-table UPDATE … JOIN would take.
    • If a row still differs after it was written in the same pass, the transition fails with wait_position_not_applied instead of looping.

    Test (rust/tests/execution/wait_positions.rs, run on SQLite, PostgreSQL, MySQL and MariaDB)

    • Retained clocks are set against wait_id order.
    • Resolving a wait writes no positions. The open-only ordinal would move one timer and rewrite another in place.
    • A new wait is the only row that may be written.
    • Positions written by another projector converge, and only those rows are written.

    Trigger logging covers both cases: SQLite and PostgreSQL log every write of the column, including unchanged rewrites; MySQL and MariaDB log value changes.

    No HTTP, protocol or fixture shape changes. Activation follows the queue: #774, then the #773 successor.

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions