SaddlePollState.vala 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. using Invercargill.DataStructures;
  2. using Inversion;
  3. namespace ClankerSaddle.Services {
  4. /**
  5. * Process-wide saddle poll bookkeeping: the block answers and human
  6. * comments already handed to a saddle (each delivered exactly once;
  7. * a restart re-delivers and the daemon deduplicates), plus the
  8. * plan-10 ask/wake liveness bookkeeping: the outcome of a saddle's
  9. * last `POST /api/saddle/next-task` ask (a saddle counts as SLEEPING
  10. * when its last ask returned 204) and the pending wake each sleeping
  11. * saddle was sent (the lost-wake guard's timer), and the phase-2
  12. * push-liveness clock: the last chunks/results arrival per session
  13. * (the staleness probe's timer — one exceptional probe per silent
  14. * episode, reset by the next push).
  15. *
  16. * Plan 16 item 23 verdict: the delivered-answer/comment ledger
  17. * STAYS — it is not dead bookkeeping. `is_delivered` still gates
  18. * ensure_block's re-raise dedupe (an answered-but-undelivered block
  19. * still represents its prompt through the resume window) and
  20. * `is_comment_delivered` gates the dispatcher's comment pass
  21. * (without it every tick would re-POST every user comment to the
  22. * running daemon). The saddle-row cache columns it was checked
  23. * against are gone; this ledger is not a row cache — it is the
  24. * delivery-once record for a channel with no other memory.
  25. *
  26. * The plan-13 entry-ingestion dedup deliberately does NOT live
  27. * here: it is daemon-global and persistent (the
  28. * `saddle_entry_dedup` table), so it survives restarts and session
  29. * churn — see SaddleService.store_pushed_entries.
  30. *
  31. * A singleton by necessity: {@link SaddleService} is request-scoped,
  32. * so this object is where the cross-request cursors live. Ids are
  33. * stringified: the Invercargill collections cannot key on value types.
  34. */
  35. public class SaddlePollState : Object {
  36. private Dictionary<string, string> probe_state = new Dictionary<string, string>();
  37. private Dictionary<string, string> probe_uptime = new Dictionary<string, string>();
  38. private Dictionary<string, string> probe_activity = new Dictionary<string, string>();
  39. private Dictionary<string, string> probe_contact = new Dictionary<string, string>();
  40. private HashSet<string> delivered_answers = new HashSet<string>();
  41. private HashSet<string> delivered_comments = new HashSet<string>();
  42. private Dictionary<string, string> ask_outcome = new Dictionary<string, string>();
  43. private HashSet<string> ask_seen = new HashSet<string>();
  44. private Dictionary<string, string> wake_at_ms = new Dictionary<string, string>();
  45. private Dictionary<string, string> wake_task = new Dictionary<string, string>();
  46. private Dictionary<string, string> push_at_ms = new Dictionary<string, string>();
  47. private HashSet<string> push_seen = new HashSet<string>();
  48. private HashSet<string> stale_probed = new HashSet<string>();
  49. // Plan 15 C4: the daemon-reported per-repo push flags of the
  50. // goal branches, as the LAST /status probe saw them — live
  51. // probe state only, NEVER a stored snapshot on the goal row.
  52. private Dictionary<string, string> probe_goal_branches = new Dictionary<string, string>();
  53. private Mutex state_lock = Mutex();
  54. /**
  55. * Records a daemon /status observation for the saddles-page
  56. * health panel: reported state, uptime, last-activity epoch and
  57. * the web-side contact time (monotonic ms).
  58. */
  59. public void record_probe(int64 saddle_id, string state, int64 uptime_seconds,
  60. int64 last_activity_epoch) {
  61. state_lock.lock();
  62. string key = saddle_id.to_string();
  63. probe_state.set(key, state);
  64. probe_uptime.set(key, uptime_seconds.to_string());
  65. probe_activity.set(key, last_activity_epoch.to_string());
  66. probe_contact.set(key, (get_monotonic_time() / 1000).to_string());
  67. state_lock.unlock();
  68. }
  69. /**
  70. * Records the daemon-reported goal-branch push flags (plan 15
  71. * C4): raw JSON as the probed daemon sent it — the goal page
  72. * and the completion-accept gate read it back live. A missing
  73. * member clears the record (nothing to render or enforce).
  74. */
  75. public void record_goal_branches(int64 saddle_id, string raw_json) {
  76. state_lock.lock();
  77. probe_goal_branches.set(saddle_id.to_string(), raw_json);
  78. state_lock.unlock();
  79. }
  80. /** The last probed goal-branch flags ("" when never reported). */
  81. public string goal_branches_for(int64 saddle_id) {
  82. state_lock.lock();
  83. string stored = "";
  84. bool found = probe_goal_branches.try_get(saddle_id.to_string(), out stored);
  85. state_lock.unlock();
  86. return found ? stored : "";
  87. }
  88. /** The last probe snapshot for `saddle_id` ("" when never probed). */
  89. public void health_for(int64 saddle_id, out string state, out int64 uptime,
  90. out int64 activity, out int64 contact_ms) {
  91. state_lock.lock();
  92. string key = saddle_id.to_string();
  93. string stored = "";
  94. state = probe_state.try_get(key, out stored) ? stored : "";
  95. uptime = int64.parse(probe_uptime.get_or_default(key) ?? "0");
  96. activity = int64.parse(probe_activity.get_or_default(key) ?? "0");
  97. contact_ms = int64.parse(probe_contact.get_or_default(key) ?? "0");
  98. state_lock.unlock();
  99. }
  100. /**
  101. * Marks `block_id`'s answer as delivered; returns false when it
  102. * had already been handed over.
  103. */
  104. public bool mark_delivered(int64 block_id) {
  105. state_lock.lock();
  106. bool already = delivered_answers.has(block_id.to_string());
  107. if (!already) {
  108. delivered_answers.add(block_id.to_string());
  109. }
  110. state_lock.unlock();
  111. return !already;
  112. }
  113. /** True when `block_id`'s answer was already handed over. */
  114. public bool is_delivered(int64 block_id) {
  115. state_lock.lock();
  116. bool already = delivered_answers.has(block_id.to_string());
  117. state_lock.unlock();
  118. return already;
  119. }
  120. /**
  121. * Marks `comment_id` as delivered to its saddle; returns false
  122. * when it had already been handed over.
  123. */
  124. public bool mark_comment_delivered(int64 comment_id) {
  125. state_lock.lock();
  126. bool already = delivered_comments.has(comment_id.to_string());
  127. if (!already) {
  128. delivered_comments.add(comment_id.to_string());
  129. }
  130. state_lock.unlock();
  131. return !already;
  132. }
  133. /** True when `comment_id` was already handed over. */
  134. public bool is_comment_delivered(int64 comment_id) {
  135. state_lock.lock();
  136. bool already = delivered_comments.has(comment_id.to_string());
  137. state_lock.unlock();
  138. return already;
  139. }
  140. /**
  141. * Records an ask arriving from `saddle_id` (plan 10): the
  142. * pending wake is answered — the lost-wake guard must not fire
  143. * for it — and the saddle is awake until the ask resolves. The
  144. * first ask also marks the daemon ask-capable (post-flip): its
  145. * parked-task resumes flow through wake + next-task from then
  146. * on, never through /start pushes.
  147. */
  148. public void mark_ask(int64 saddle_id) {
  149. state_lock.lock();
  150. string key = saddle_id.to_string();
  151. ask_outcome.set(key, "awake");
  152. ask_seen.add(key);
  153. wake_at_ms.remove(key);
  154. wake_task.remove(key);
  155. state_lock.unlock();
  156. }
  157. /**
  158. * Records the outcome of `saddle_id`'s ask: `204` puts it to
  159. * sleep (a later wake targets it), anything else leaves it
  160. * awake.
  161. */
  162. public void mark_ask_result(int64 saddle_id, bool assigned) {
  163. state_lock.lock();
  164. ask_outcome.set(saddle_id.to_string(), assigned ? "assigned" : "204");
  165. state_lock.unlock();
  166. }
  167. /**
  168. * True when `saddle_id`'s last ask returned 204 — the sleeping
  169. * state a wake targets.
  170. */
  171. public bool is_sleeping(int64 saddle_id) {
  172. state_lock.lock();
  173. string stored = "";
  174. bool sleeping = ask_outcome.try_get(saddle_id.to_string(), out stored)
  175. && stored == "204";
  176. state_lock.unlock();
  177. return sleeping;
  178. }
  179. /**
  180. * True once `saddle_id` has ever asked (post-flip daemon): it
  181. * asks whenever idle-awake, so its work — fresh or resumed —
  182. * always flows through the allocation.
  183. */
  184. public bool has_ever_asked(int64 saddle_id) {
  185. state_lock.lock();
  186. bool seen = ask_seen.has(saddle_id.to_string());
  187. state_lock.unlock();
  188. return seen;
  189. }
  190. /** Forgets the ask state (the saddle went offline / was removed). */
  191. public void clear_ask(int64 saddle_id) {
  192. state_lock.lock();
  193. string key = saddle_id.to_string();
  194. ask_outcome.remove(key);
  195. wake_at_ms.remove(key);
  196. wake_task.remove(key);
  197. state_lock.unlock();
  198. }
  199. /**
  200. * Records a wake sent to `saddle_id` for `task_id` — starts the
  201. * lost-wake guard's timer.
  202. */
  203. public void note_wake(int64 saddle_id, int64 task_id) {
  204. state_lock.lock();
  205. string key = saddle_id.to_string();
  206. wake_at_ms.set(key, (get_monotonic_time() / 1000).to_string());
  207. wake_task.set(key, task_id.to_string());
  208. state_lock.unlock();
  209. }
  210. /**
  211. * True when `saddle_id` has a pending wake at least
  212. * `guard_ms` old — an ask should have arrived by now; the
  213. * guard declares the saddle offline and frees the work for
  214. * the next capable one.
  215. */
  216. public bool wake_overdue(int64 saddle_id, uint guard_ms) {
  217. state_lock.lock();
  218. string stored = "";
  219. bool overdue = wake_at_ms.try_get(saddle_id.to_string(), out stored)
  220. && (get_monotonic_time() / 1000 - int64.parse(stored)) >= guard_ms;
  221. state_lock.unlock();
  222. return overdue;
  223. }
  224. /**
  225. * True when `saddle_id` carries a pending wake younger than
  226. * `guard_ms` — the wake pass skips it instead of re-waking on
  227. * every tick while the guard window runs.
  228. */
  229. public bool wake_pending(int64 saddle_id, uint guard_ms) {
  230. state_lock.lock();
  231. string stored = "";
  232. bool pending = wake_at_ms.try_get(saddle_id.to_string(), out stored)
  233. && (get_monotonic_time() / 1000 - int64.parse(stored)) < guard_ms;
  234. state_lock.unlock();
  235. return pending;
  236. }
  237. /** The task id `saddle_id` was last woken for (0 when none). */
  238. public int64 wake_task_of(int64 saddle_id) {
  239. state_lock.lock();
  240. string stored = "";
  241. bool found = wake_task.try_get(saddle_id.to_string(), out stored);
  242. state_lock.unlock();
  243. return found ? int64.parse(stored) : 0;
  244. }
  245. /**
  246. * Records a chunks/results push arriving for `session_id`
  247. * (plan 10 phase 2): the running daemon is alive, and any
  248. * running staleness episode ends — a later silent episode
  249. * earns its own exceptional probe.
  250. */
  251. public void note_push(int64 session_id) {
  252. state_lock.lock();
  253. string key = session_id.to_string();
  254. push_at_ms.set(key, (get_monotonic_time() / 1000).to_string());
  255. push_seen.add(key);
  256. stale_probed.remove(key);
  257. state_lock.unlock();
  258. }
  259. /**
  260. * Atomically decides the one exceptional staleness probe for
  261. * `session_id`: true exactly when pushes have been seen, the
  262. * silence has reached `stale_ms` and no probe fired for this
  263. * episode yet (the flag flips here, so a slow probe is never
  264. * double-fired by the next tick). A session with no recorded
  265. * push (a web restart wiped the clock) is seeded now and
  266. * starts its window instead of firing immediately — a fresh
  267. * baseline must not masquerade as 90 s of silence.
  268. */
  269. public bool take_stale_probe(int64 session_id, uint stale_ms) {
  270. state_lock.lock();
  271. string key = session_id.to_string();
  272. int64 now_ms = get_monotonic_time() / 1000;
  273. if (!push_seen.has(key)) {
  274. push_seen.add(key);
  275. push_at_ms.set(key, now_ms.to_string());
  276. state_lock.unlock();
  277. return false;
  278. }
  279. if (stale_probed.has(key)) {
  280. state_lock.unlock();
  281. return false;
  282. }
  283. string stored = "";
  284. bool known = push_at_ms.try_get(key, out stored);
  285. bool due = known && (now_ms - int64.parse(stored)) >= stale_ms;
  286. if (due) {
  287. stale_probed.add(key);
  288. }
  289. state_lock.unlock();
  290. return due;
  291. }
  292. }
  293. }