sonar

Scan files at memory bandwidth speed.
Log | Files | Refs

commit 33cca93aefa426d04d9950c42baa0b5c2f9dbebf
parent 2c30a3334f018f8de99f3dca9e59f88c7a5a2143
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:57:26 -0400

flow: let the manager judge a failed item

`manage` gave `work` one boolean for two unrelated answers: this item did not
work out, and abandon the whole run. Callers had to pick one. The walker picked
the first, reporting success for a directory it could not open, and so had no
way to report running out of room for the tree: it halted and returned .None,
losing a truncated scan silently.

`work` returning false now marks only the item. The manager is told which item
it was and what happened, and decides: skip it, queue it again, or stop. That
is flowmatic's split, which our version had collapsed.

Diffstat:
Mflow/manage.odin | 44++++++++++++++++++++++----------------------
Mflow/manage_test.odin | 53++++++++++++++++++++++++++++++++++++++++++++++++++---
Mwalk/walk.odin | 27++++++++++++++++++---------
3 files changed, 90 insertions(+), 34 deletions(-)

diff --git a/flow/manage.odin b/flow/manage.odin @@ -6,26 +6,24 @@ import "core:thread" /* Run work over a set that grows as the work discovers more of it. -`work` runs on many threads at once; `found` runs on one at a time, and is the only -thing that puts items in the queue. Workers take the next item the moment one exists -rather than in rounds, so a shape only learned by walking it never stalls waiting for -a round to end. +`seed` starts the queue; every later item arrives through `manager`, which runs one +at a time. `work` runs on many threads at once, taking the next item the moment one +exists rather than in rounds. Each worker owns one slot of `states`, so `work` needs no locks, exactly as in `each`. -What a worker discovers goes in its own slot; `found` moves it into the queue and is -serialised, so deciding what to explore next needs no reasoning about order. +`manager` sees that slot beside the item that filled it, and queues whatever comes +next, that item included. -Either procedure returning false ends the run: no further items are handed out, and -workers finish the item in hand. Report the reason through the state. +`work` returning false marks the item failed, not the run. `manager` returning false +ends the run; workers finish the item in hand and report through their state. -`len(states)` sets the width, capped by what the machine can use. The threads are -started once for the whole traversal rather than once per round. +`len(states)` sets the width, capped by what the machine can use. */ manage :: proc( seed: []$I, states: []$S, work: proc(item: I, state: ^S) -> bool, - found: proc(state: ^S, queue: ^[dynamic]I) -> bool, + manager: proc(item: I, ok: bool, state: ^S, queue: ^[dynamic]I) -> bool, load := Load.Io, ) { if len(seed) == 0 || len(states) == 0 { @@ -34,7 +32,7 @@ manage :: proc( q: Queue(I, S) q.states = states q.work = work - q.found = found + q.manager = manager q.items = make([dynamic]I, context.allocator) defer delete(q.items) append(&q.items, ..seed) @@ -65,15 +63,15 @@ manage :: proc( @(private) Queue :: struct($I: typeid, $S: typeid) { - items: [dynamic]I, - head: int, - active: int, // workers holding an item, which may yet produce more - over: bool, - mutex: sync.Mutex, - wake: sync.Cond, - states: []S, - work: proc(item: I, state: ^S) -> bool, - found: proc(state: ^S, queue: ^[dynamic]I) -> bool, + items: [dynamic]I, + head: int, + active: int, // workers holding an item, which may yet produce more + over: bool, + mutex: sync.Mutex, + wake: sync.Cond, + states: []S, + work: proc(item: I, state: ^S) -> bool, + manager: proc(item: I, ok: bool, state: ^S, queue: ^[dynamic]I) -> bool, } @(private) @@ -119,7 +117,9 @@ drain :: proc(q: ^Queue($I, $S), index: int) { sync.mutex_lock(&q.mutex) q.active -= 1 - if !ok || !q.found(state, &q.items) { + // The manager runs for a failed item too: deciding what a failure means is + // the whole of its job. + if !q.manager(item, ok, state, &q.items) { q.over = true } sync.cond_broadcast(&q.wake) diff --git a/flow/manage_test.odin b/flow/manage_test.odin @@ -31,12 +31,12 @@ descend :: proc(item: int, v: ^Visit) -> bool { } @(private = "file") -hand_over :: proc(v: ^Visit, queue: ^[dynamic]int) -> bool { +hand_over :: proc(item: int, ok: bool, v: ^Visit, queue: ^[dynamic]int) -> bool { for c in v.found { append(queue, c) } clear(&v.found) - return true + return ok } @(private = "file") @@ -127,7 +127,7 @@ test_manage_spreads_across_workers :: proc(t: ^testing.T) { } @(test) -test_manage_stops_when_work_returns_false :: proc(t: ^testing.T) { +test_manage_stops_when_the_manager_returns_false :: proc(t: ^testing.T) { // One worker keeps this exact: with several, those already holding an item // finish it, which is the documented behaviour. one := visitors(1, stop = 0) @@ -143,3 +143,50 @@ test_manage_tolerates_an_empty_seed :: proc(t: ^testing.T) { count, _ := totals(v) testing.expect_value(t, count, 0) } + +// A failed item is the manager's to judge, and the judgement it cannot make without +// being told which item failed is to try that one again. +@(private = "file") +Attempt :: struct { + id: int, + tries: int, +} + +@(private = "file") +Flaky :: struct { + seen: [dynamic]Attempt, +} + +@(private = "file") +refuse_twice :: proc(item: Attempt, f: ^Flaky) -> bool { + append(&f.seen, item) + return !(item.id == 1 && item.tries < 2) +} + +@(private = "file") +retry :: proc(item: Attempt, ok: bool, f: ^Flaky, queue: ^[dynamic]Attempt) -> bool { + if !ok { + append(queue, Attempt{id = item.id, tries = item.tries + 1}) + } + return true +} + +@(test) +test_manage_requeues_a_failed_item :: proc(t: ^testing.T) { + // One worker keeps the count exact; the point is the shape, not the width. + f := make([]Flaky, 1, context.temp_allocator) + f[0].seen = make([dynamic]Attempt, context.temp_allocator) + seed := []Attempt{{id = 0}, {id = 1}, {id = 2}} + manage(seed, f, refuse_twice, retry) + + // Three items, one of them attempted three times, and the run carried on past + // the failures rather than ending at the first. + testing.expect_value(t, len(f[0].seen), 5) + tries := 0 + for a in f[0].seen { + if a.id == 1 { + tries += 1 + } + } + testing.expect_value(t, tries, 3) +} diff --git a/walk/walk.odin b/walk/walk.odin @@ -82,21 +82,30 @@ scan :: proc(root: string, t: ^scan.Tree, cfg := Config{}) -> Error { seed := []Dir{{index = first, path = strings.clone(root, allocator)}} flow.manage(seed, states, walk_dir, hand_over) + for &s in states { + if s.err != nil { + return s.err + } + } if scan.cancelled(t) { return .Cancelled } return .None } -// Move what a worker found into the queue. Serialised by `manage`, so this is the -// one place the set of directories still to walk is touched. +// Move what a worker found into the queue, and judge whatever it could not read. +// Serialised by `manage`, so this is the one place the set of directories still to +// walk is touched. @(private) -hand_over :: proc(w: ^Worker, queue: ^[dynamic]Dir) -> bool { - for d in w.found { - append(queue, d) +hand_over :: proc(d: Dir, ok: bool, w: ^Worker, queue: ^[dynamic]Dir) -> bool { + for found in w.found { + append(queue, found) } clear(&w.found) - return !scan.cancelled(w.writer.tree) + // A directory that would not open is worth nothing and is not worth retrying, + // but it is no reason to abandon the rest of the volume. Running out of room + // for the tree is, since everything after it would be missing silently. + return w.err == nil && !scan.cancelled(w.writer.tree) } // A directory waiting to be read, and the node already standing for it. @@ -111,6 +120,7 @@ Worker :: struct { writer: scan.Writer, found: [dynamic]Dir, follow: bool, + err: Error, // why this worker gave up, which only the manager acts on allocator: mem.Allocator, } @@ -122,9 +132,7 @@ walk_dir :: proc(d: Dir, w: ^Worker) -> bool { } f, open_err := os.open(d.path) if open_err != nil { - // A directory we may not read is not a reason to abandon the scan; it is - // simply worth nothing. Permission denied is normal on a live system. - return true + return false } defer os.close(f) @@ -139,6 +147,7 @@ walk_dir :: proc(d: Dir, w: ^Worker) -> bool { } index, claim_err := scan.claim(&w.writer, 1) if claim_err != nil { + w.err = .Out_Of_Memory return false } n := scan.node(w.writer.tree, index)