Skip to content

Changed() reader ordered before its writer sees writes once, then never #3

Description

@k9p5

Summary

If a system running a Changed(T) query is scheduled before the system that writes T in the same tick, the reader sees the writes on the first frame and never again. Ordered the other way round it works every frame.

Repro

import { World, Trait, Schedule, f32, Changed } from 'apecs';
const Position = new Trait({ x: f32(0) });
const run = (q) => { let n = 0; q.each(() => n++); return n; };

for (const readerFirst of [true, false]) {
  const w = new World();
  const e = w.spawn(Position);
  const seen = [];
  const reader = (w) => seen.push(run(w.query(Position, Changed(Position))));
  const writer = (w) => w.set(e, Position.x, w.tick);
  const s = readerFirst
    ? new Schedule().add('reader', reader).add('writer', writer, { after: 'reader' })
    : new Schedule().add('writer', writer).add('reader', reader, { after: 'writer' });
  for (let i = 0; i < 5; i++) s.run(w);
  console.log(readerFirst ? 'reader first' : 'writer first', seen.join(','));
  w.destroy();
}
// reader first 1,0,0,0,0
// writer first 1,1,1,1,1

Cause

RowFilter.begin in src/core/walk.ts sets horizon = lastSeen; lastSeen = ticks.tick, and accept requires row tick > horizon. With reader-first ordering at tick T:

  1. reader runs, lastSeen = T
  2. writer stamps the row with T
  3. next frame, tick T+1: reader runs with horizon = T, row tick T > T is false, the write is dropped
  4. writer stamps T+1, and the same thing repeats

Every write lands in the window the reader has already closed. Two runs of the same query inside one tick have the same problem: the second run closes the window on anything written between them.

Why it matters

The spec (§8.3) and README promise "only entities written since this query last ran". Nothing in the docs says readers must be ordered after their writers, and Schedule accepts either order without complaint. This is a silent lost-update, not an error.

Options

  • Per-system ticks. Bevy advances the change tick after each system, so a write is always stamped later than the previous run of every other system. Schedule.run could call world.step() before each system instead of once per frame. Cost: the tick counter advances N× faster, which matters for wraparound of the Uint32Array ticks.
  • Half-open window. Accept row tick >= horizon and set horizon = lastSeen + 1 only when the tick actually advanced. Same-tick re-runs would then see the same writes twice, which is arguably the correct answer.
  • Document the hard rule that a Changed reader must be ordered after every writer of that trait, and assert it in dev builds where the schedule can see both.

Found by a post-publish smoke test of 0.1.0.

Activity

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions