okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntest: the maintenance probe observes an empty run slot before it suspends0a3aefa

main
12.4 KiB292 linesraw
1%% Inspect real stream owners while publishers fill an unread TCP connection.
2-module(albedo_stream_pressure_probe).
3-export([pressure_json/1, subscriptions_json/0, removed_json/0,
4    progress_json/1, session_pressure_json/1, session_removed_json/1, burst_json/3,
5    suspend_session_json/1, resume_session_json/1, worker_exit_json/3, maintenance_json/1]).
6
7rows(Table) ->
8    case ets:whereis(Table) of
9        undefined -> [];
10        _ -> ets:tab2list(Table)
11    end.
12
13bytes(Binary) when is_binary(Binary) -> byte_size(Binary);
14bytes(Map) when is_map(Map) -> bytes(maps:to_list(Map));
15bytes(Tuple) when is_tuple(Tuple) -> bytes(tuple_to_list(Tuple));
16bytes([Head | Tail]) -> bytes(Head) + bytes(Tail);
17bytes(_) -> 0.
18
19blocked(Owner) ->
20    case process_info(Owner, current_stacktrace) of
21        {current_stacktrace, Frames} ->
22            case lists:any(fun({_, Function, _, _}) ->
23                lists:member(Function, [send, send_event, port_command])
24            end, Frames) of true -> 1; false -> 0 end;
25        undefined -> 0
26    end.
27
28sample({Ref, {Owner, _, Control, _}}) ->
29    {Count, Bytes} = case ets:lookup(albedo_bus_queues, Ref) of
30        [{_, {_, C, B, _}}] -> {C, B};
31        [] -> {0, 0}
32    end,
33    case process_info(Owner, [messages, message_queue_len]) of
34        undefined -> #{count => Count, bytes => Bytes, mailbox => 0,
35            mailbox_bytes => 0, blocked => 0, overflow => atomics:get(Control, 1)};
36        Info -> #{count => Count, bytes => Bytes,
37            mailbox => proplists:get_value(message_queue_len, Info),
38            mailbox_bytes => bytes(proplists:get_value(messages, Info)),
39            blocked => blocked(Owner), overflow => atomics:get(Control, 1)}
40    end.
41
42measure() ->
43    lists:foldl(fun(Row, Peak) ->
44        maps:merge_with(fun(_, A, B) -> max(A, B) end, Peak, sample(Row))
45    end, #{count => 0, bytes => 0, mailbox => 0, mailbox_bytes => 0, blocked => 0, overflow => 0}, rows(albedo_bus)).
46
47activity_event(Id, Index, Size) ->
48    Prefix = iolist_to_binary([<<"pressure:">>, Id, <<":">>, integer_to_binary(Index), <<":">>]),
49    Remaining = max(0, min(256 - byte_size(Prefix), Size)),
50    Scalar = case Size > 4096 of true -> <<240, 159, 152, 128>>; false -> <<"x">> end,
51    Text = <<Prefix/binary, (binary:copy(Scalar, Remaining))/binary>>,
52    Line = #{kind => <<"assistant">>, text => Text},
53    json:encode(#{type => <<"activity">>, data => #{session_id => Id,
54        cursor => #{generation => <<"AAAAAAAAAAAAAAAAAAAAAA">>, sequence => Index},
55        status => #{phase => <<"idle">>, run_id => null,
56            interrupt_requested => false, blocking_reason => null},
57        current_progress => [], activity => #{lines => lists:duplicate(12, Line),
58            output_scalars => Index, output_utf8_bytes => Index,
59            observed_at => <<"2026-10-03T00:00:00Z">>,
60            latest_input => null, latest_answer => null}}}).
61
62publish(Ids, Start, Count, Size) ->
63    lists:foreach(fun(Index) ->
64        Id = lists:nth(1 + ((Index - 1) rem length(Ids)), Ids),
65        albedo_bus:publish(iolist_to_binary(activity_event(Id, Index, Size)))
66    end, lists:seq(Start, Start + Count - 1)),
67    nil.
68
69burst_event(_Id, Size) when Size > 1048576 ->
70    %% Internal admission boundary: this oversized payload is deliberately not
71    %% a valid public event and must never reach a socket.
72    json:encode(#{type => <<"invalidate">>, data => #{padding => binary:copy(<<"x">>, Size)}});
73burst_event(Id, Size) when Size =< 16 ->
74    json:encode(#{type => <<"invalidate">>, data => #{session_ids => [Id],
75        urls => [<<"/sessions/", Id/binary>>], scope_dirty => false}});
76burst_event(Id, Size) -> activity_event(Id, 1, Size).
77
78publish_batch(Ids, Published) ->
79    Workers = [spawn_monitor(fun() ->
80        publish([Id], Published div length(Ids) + 1, 12, 4096)
81    end) || Id <- Ids],
82    lists:foreach(fun({Worker, Monitor}) ->
83        receive
84            {'DOWN', Monitor, process, Worker, normal} -> ok;
85            {'DOWN', Monitor, process, Worker, Reason} -> error({publisher_failed, Reason})
86        after 10000 -> error(publisher_blocked)
87        end
88    end, Workers).
89
90pressure_json(Ids) ->
91    io_lib:format("~s", [json:encode(pressure(Ids, 0, measure()))]).
92
93pressure(Ids, Published, Peak) when Published < 10000 ->
94    publish_batch(Ids, Published),
95    Current = measure(),
96    Next = maps:merge_with(fun(_, A, B) -> max(A, B) end, Peak, Current),
97    case maps:get(overflow, Current) > 0 andalso maps:get(blocked, Next) > 0 of
98        true -> Next#{published => Published + 96, pressure => true};
99        false ->
100            %% Pace below a responsive subscriber's per-flush queue budget.
101            receive after 150 -> ok end,
102            pressure(Ids, Published + 96, Next)
103    end;
104pressure(_, Published, Peak) -> Peak#{published => Published, pressure => false}.
105
106burst_json(Ids, Count, Size) ->
107    [{_, {Owner, _, _, _}}] = rows(albedo_bus),
108    %% Hold the real stream handler at a barrier while testing each budget.
109    ok = sys:suspend(Owner),
110    try
111        Peak = lists:foldl(fun(_, Acc) ->
112            [Id | _] = Ids,
113            albedo_bus:publish(iolist_to_binary(burst_event(Id, Size))),
114            maps:merge_with(fun(_, A, B) -> max(A, B) end, Acc, measure())
115        end, measure(), lists:seq(1, Count)),
116        json:encode(Peak)
117    after sys:resume(Owner)
118    end.
119
120subscriptions_json() -> json:encode(#{subscribers => length(rows(albedo_bus)),
121    queues => length(rows(albedo_bus_queues))}).
122
123removed_json() -> removed(1000).
124removed(0) -> subscriptions_json();
125removed(Left) ->
126    case {rows(albedo_bus), rows(albedo_bus_queues)} of
127        {[], []} -> subscriptions_json();
128        _ -> receive after 10 -> ok end, removed(Left - 1)
129    end.
130
131session_state(Id) ->
132    {some, Session} = 'albedo@daemon@session':live(Id),
133    {ok, Owner} = 'gleam@erlang@process':subject_owner(Session),
134    sys:get_state(Owner).
135
136wakes(wake) -> 1;
137wakes(Tuple) when is_tuple(Tuple) -> wakes(tuple_to_list(Tuple));
138wakes([Head | Tail]) -> wakes(Head) + wakes(Tail);
139wakes(_) -> 0.
140
141session_sample(Id) ->
142    State = session_state(Id),
143    Watchers = 'albedo@daemon@session_diagnostics':watcher_owners(State),
144    Samples = [case process_info(Watcher, messages) of
145        undefined -> #{wakes => 0, blocked => 0};
146        {messages, Messages} -> #{wakes => wakes(Messages), blocked => blocked(Watcher)}
147    end || Watcher <- Watchers],
148    Peak = lists:foldl(fun(Sample, Acc) ->
149        maps:merge_with(fun(_, A, B) -> max(A, B) end, Acc, Sample)
150    end, #{wakes => 0, blocked => 0}, Samples),
151    Peak#{watchers => length(Watchers), sequence => 'albedo@daemon@session_diagnostics':sequence(State)}.
152
153session_pressure_json(Id) -> json:encode(session_pressure(Id, 1500, #{})).
154session_pressure(Id, Left, Peak) ->
155    Sample = session_sample(Id),
156    Next = maps:merge_with(fun(_, A, B) -> max(A, B) end, Peak, Sample),
157    Complete = maps:get(blocked, Next) > 0 andalso not 'albedo@daemon@bus':is_running(Id),
158    case Complete orelse Left =:= 0 of
159        true -> Next;
160        false -> receive after 10 -> ok end, session_pressure(Id, Left - 1, Next)
161    end.
162
163session_removed_json(Id) -> json:encode(session_removed(Id, 1000)).
164session_removed(Id, Left) ->
165    Sample = session_sample(Id),
166    case maps:get(watchers, Sample) =:= 0 orelse Left =:= 0 of
167        true -> Sample;
168        false -> receive after 10 -> ok end, session_removed(Id, Left - 1)
169    end.
170
171%% The actor owns one projection regardless of subscriber count. Inspect that
172%% bounded value rather than copying the worker's full durable argument state.
173progress_json(Id) ->
174    State = session_state(Id),
175    Projection = 'albedo@daemon@session_diagnostics':progress(State),
176    json:encode(#{projection_bytes => bytes(Projection),
177        projection_size => erlang:external_size(Projection),
178        watchers => length('albedo@daemon@session_diagnostics':watcher_owners(State))}).
179
180%% sys calls acknowledge suspension/resumption of the real session owner.
181suspend_session_json(Id) ->
182    {some, Session} = 'albedo@daemon@session':live(Id),
183    {ok, Owner} = 'gleam@erlang@process':subject_owner(Session),
184    ok = sys:suspend(Owner),
185    json:encode(#{suspended => true}).
186
187resume_session_json(Id) ->
188    {some, Session} = 'albedo@daemon@session':live(Id),
189    {ok, Owner} = 'gleam@erlang@process':subject_owner(Session),
190    ok = sys:resume(Owner),
191    json:encode(#{resumed => true}).
192
193%% The ready file acknowledges the monitor before the HTTP client cancels.
194worker_exit_json(Id, RunId, ReadyPath) ->
195    State = session_state(Id),
196    {some, Worker} = 'albedo@daemon@session_diagnostics':worker(State, RunId),
197    Monitor = monitor(process, Worker),
198    true = is_process_alive(Worker),
199    ok = file:write_file(ReadyPath, <<"monitor_ready">>),
200    receive
201        {'DOWN', Monitor, process, Worker, Reason} ->
202            Classification = case Reason of
203                normal -> <<"normal">>;
204                killed -> <<"killed">>;
205                _ -> <<"other">>
206            end,
207            json:encode(#{reason => Classification})
208    after 30000 ->
209        demonitor(Monitor, [flush]),
210        error(worker_still_alive)
211    end.
212
213%% Hold a real owner across several timer ticks, then let the sweep encounter
214%% its death. Calls are traced at the worker boundary, without reading registry
215%% state or assuming where its maintenance field is stored.
216maintenance_json(Id) ->
217    {some, Session} = 'albedo@daemon@session':live(Id),
218    {ok, Owner} = 'gleam@erlang@process':subject_owner(Session),
219    Module = 'albedo@daemon@maintenance',
220    {module, Module} = code:ensure_loaded(Module),
221    %% The registry admits one maintenance worker at a time, so the probe
222    %% observes the run slot empty before it suspends anything: a worker
223    %% already inside run would hold the admission slot across the suspend,
224    %% its call untraced, and the entry window would see no run at all.
225    %% Tracing goes on first, so every run call is traced from the wait on.
226    erlang:trace_pattern({Module, run, 1}, true, [local]),
227    erlang:trace(all, true, [call, {tracer, self()}]),
228    try
229        await_run_slot(Module, erlang:monotonic_time(millisecond) + 15000),
230        erlang:suspend_process(Owner),
231        Worker = receive
232            {trace, Pid, call, {Module, run, [_]}} -> Pid
233        after 3000 -> error(maintenance_not_started)
234        end,
235        Monitor = monitor(process, Worker),
236        Deadline = erlang:monotonic_time(millisecond) + 2000,
237        Workers = maintenance_calls(Module, Deadline, [Worker]),
238        exit(Owner, kill),
239        Outcome = receive
240            {'DOWN', Monitor, process, Worker, normal} -> <<"completed">>;
241            {'DOWN', Monitor, process, Worker, Reason} -> error({sweep_failed, Reason})
242        after 7000 -> error(maintenance_still_blocked)
243        end,
244        json:encode(#{admitted => length(lists:usort(Workers)), outcome => Outcome})
245    after
246        erlang:trace(all, false, [call]),
247        erlang:trace_pattern({Module, run, 1}, false, [local]),
248        try erlang:resume_process(Owner) catch _:_ -> ok end
249    end.
250
251maintenance_calls(Module, Deadline, Workers) ->
252    Remaining = max(0, Deadline - erlang:monotonic_time(millisecond)),
253    receive
254        {trace, Pid, call, {Module, run, [_]}} ->
255            maintenance_calls(Module, Deadline, [Pid | Workers])
256    after Remaining -> Workers
257    end.
258
259%% No process is inside maintenance:run/1 when the probe suspends the owner.
260%% Calls traced while waiting belong to workers that finished before the
261%% suspend and are drained uncounted: the admission window starts at the
262%% suspend. A call that slips past the last drain comes from a worker at run
263%% entry by construction, so it still meets the held owner.
264await_run_slot(Module, Deadline) ->
265    drain_run_calls(Module),
266    case run_slot_busy(Module) of
267        false -> ok;
268        true ->
269            case erlang:monotonic_time(millisecond) >= Deadline of
270                true -> error(maintenance_worker_stuck);
271                false ->
272                    receive after 50 -> ok end,
273                    await_run_slot(Module, Deadline)
274            end
275    end.
276
277drain_run_calls(Module) ->
278    receive
279        {trace, _, call, {Module, run, [_]}} -> drain_run_calls(Module)
280    after 0 -> ok
281    end.
282
283run_slot_busy(Module) ->
284    lists:any(fun(Pid) -> in_run(Pid, Module) end, erlang:processes()).
285
286in_run(Pid, Module) ->
287    case process_info(Pid, current_stacktrace) of
288        {current_stacktrace, Frames} ->
289            lists:any(fun({M, F, A, _}) -> {M, F, A} =:= {Module, run, 1} end,
290                Frames);
291        undefined -> false
292    end.