StateService.vala 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using InvercargillJson;
  4. using Inversion;
  5. namespace Statum {
  6. /**
  7. * Error domain for state service operations.
  8. */
  9. public errordomain StateError {
  10. /** No slot is cached for the given key. */
  11. SLOT_NOT_FOUND,
  12. }
  13. public class StateService : Object {
  14. private const int RETRANSMIT_SECONDS = 60; // 1 minute
  15. private const int EXPIRY_SECONDS = 604800; // 7 days
  16. private Cryptography.EncryptionProvider encryption_provider = inject<Cryptography.EncryptionProvider>();
  17. private Cryptography.SigningProvider signing_provider = inject<Cryptography.SigningProvider>();
  18. /**
  19. * The realtime channel service. Injected (singleton) so that every state
  20. * mutation auto-pushes the resulting frame to subscribed channels. The
  21. * dependency is one-way: {@link ChannelService} never injects this
  22. * service, so there is no DI cycle.
  23. */
  24. private ChannelService channel_service = inject<ChannelService>();
  25. private Dictionary<string, Slot> slots = new Dictionary<string, Slot>();
  26. public Slot new_slot(Scope slot_scope, State initial_state) {
  27. var id = new uint8[32];
  28. Sodium.Random.random_bytes(id);
  29. initial_state.last_touched = new DateTime.now_utc();
  30. var slot = new Slot() {
  31. id = Base64.encode(id),
  32. scope = slot_scope,
  33. type_name = initial_state.type_name,
  34. current_state = initial_state,
  35. };
  36. slots.set(slot.id, slot);
  37. return slot;
  38. }
  39. /**
  40. * Reads the cached slot for `id` without mutating it.
  41. *
  42. * Liveness is now driven by the `transmit_after`/`invalid_after` windows
  43. * and explicit {@link update} calls, so reading a slot no longer touches
  44. * its `last_touched`.
  45. */
  46. public Slot? get_slot(string id) {
  47. Slot? slot = null;
  48. slots.try_get(id, out slot);
  49. return slot;
  50. }
  51. public Model.FrameDto? sign_slot(string id, bool unidirectional = false) throws GLib.Error {
  52. var slot = get_slot(id);
  53. if (slot == null) {
  54. return null;
  55. }
  56. return sign_slot_for((!)slot, unidirectional);
  57. }
  58. /**
  59. * Mutates the slot cached under `key` to `state`, signs the resulting
  60. * snapshot into a frame, auto-pushes that frame to any channel
  61. * subscribed to `key` (a no-op when nothing is subscribed), and returns
  62. * the signed frame so the caller can reuse it for its response `set`
  63. * directive (one sign → push + response).
  64. */
  65. public async Model.FrameDto update(string key, State state) throws GLib.Error {
  66. Slot? slot = null;
  67. if (!slots.try_get(key, out slot) || slot == null) {
  68. throw new StateError.SLOT_NOT_FOUND(@"No slot is cached for key \"$key\"");
  69. }
  70. state.last_touched = new DateTime.now_utc();
  71. slot.current_state = state;
  72. var frame = sign_slot_for(slot, false);
  73. var frame_json = serialize_frame(frame);
  74. yield channel_service.push_set(key, frame_json);
  75. return frame;
  76. }
  77. /**
  78. * Removes the slot cached under `key` and auto-pushes a `clear` event to
  79. * any channel subscribed to it (a no-op when nothing is subscribed).
  80. */
  81. public async void clear_slot(string key) {
  82. slots.remove(key);
  83. yield channel_service.push_clear(key);
  84. }
  85. // ------------------------------------------------------------------
  86. // Global slots (deterministic-key broadcast state)
  87. // ------------------------------------------------------------------
  88. public const string GLOBAL_KEY_PREFIX = "global:";
  89. /**
  90. * Creates (or updates) a global slot with the deterministic key
  91. * `"global:" + type_name`. Global slots share one key across all clients,
  92. * so every subscriber sees the same updates.
  93. */
  94. public Slot new_global_slot(string type_name, Scope slot_scope, State state) {
  95. var key = GLOBAL_KEY_PREFIX + type_name;
  96. state.last_touched = new DateTime.now_utc();
  97. state.type_name = type_name;
  98. Slot? existing = null;
  99. if (slots.try_get(key, out existing) && existing != null) {
  100. ((!)existing).current_state = state;
  101. return (!)existing;
  102. }
  103. var slot = new Slot() {
  104. id = key,
  105. scope = slot_scope,
  106. type_name = type_name,
  107. current_state = state
  108. };
  109. slots.set(key, slot);
  110. return slot;
  111. }
  112. /** Updates a global slot by type name (sign + push to all subscribers). */
  113. public async Model.FrameDto update_global(string type_name, State state) throws GLib.Error {
  114. return yield update(GLOBAL_KEY_PREFIX + type_name, state);
  115. }
  116. /**
  117. * Typed read-mutate-write of a global slot: maps its current public data
  118. * to `TPublic`, applies `mutator`, maps back, signs and pushes. Used from
  119. * background contexts (timers, webhooks) that have no request scope.
  120. *
  121. * Like {@link DirectiveBuilder.update_held}, JSON-array fields the typed
  122. * model cannot read back are carried forward unchanged rather than
  123. * dropped.
  124. */
  125. public async void update_global_typed<TPublic>(string type_name, owned UpdateMutator<TPublic> mutator) throws GLib.Error {
  126. var key = GLOBAL_KEY_PREFIX + type_name;
  127. var slot = get_slot(key);
  128. if (slot == null || ((!)slot).current_state == null) {
  129. throw new StateError.SLOT_NOT_FOUND(@"No global slot for type \"$type_name\"");
  130. }
  131. var current = ((!)slot).current_state;
  132. var model = GObjectMapping.from_properties_typed<TPublic>(typeof(TPublic),
  133. current.public_data ?? new PropertyDictionary());
  134. mutator(model);
  135. var new_state = new State() {
  136. type_name = current.type_name,
  137. public_data = GObjectMapping.preserving_arrays(current.public_data ?? new PropertyDictionary(),
  138. GObjectMapping.to_properties((Object) model)),
  139. private_data = current.private_data ?? new PropertyDictionary()
  140. };
  141. yield update(key, new_state);
  142. }
  143. /** Clears a global slot by type name (remove + push clear to subscribers). */
  144. public async void clear_global(string type_name) {
  145. yield clear_slot(GLOBAL_KEY_PREFIX + type_name);
  146. }
  147. public bool restore_slot(Model.FrameDto frame) throws GLib.Error {
  148. signing_provider.verify_frame(frame);
  149. var content_element = new JsonElement.from_string(frame.content);
  150. var json_object = content_element.as<JsonObject>();
  151. var snapshot = Model.SnapshotDto.get_mapper().materialise(json_object);
  152. var slot = get_slot(snapshot.slot.key);
  153. if (slot != null) {
  154. // No-op: slot not empty.
  155. return false;
  156. }
  157. slot = new Slot() {
  158. id = snapshot.slot.key,
  159. type_name = snapshot.slot.slot_type,
  160. scope = snapshot.slot.scope,
  161. current_state = new State() {
  162. type_name = snapshot.slot.slot_type,
  163. public_data = snapshot.public,
  164. private_data = encryption_provider.read_properties(snapshot.slot.key, snapshot.private),
  165. last_touched = new DateTime.now_utc()
  166. }
  167. };
  168. slots.set(slot.id, slot);
  169. return true;
  170. }
  171. /**
  172. * Evicts slots whose `invalid_after` window has elapsed — i.e. slots that
  173. * have not been touched for {@link EXPIRY_SECONDS}. The client resends a
  174. * fresh frame via `/_statum/slots` when it next needs one of these.
  175. */
  176. public void cleanup() {
  177. var cutoff = new DateTime.now_utc().add_seconds(-EXPIRY_SECONDS);
  178. var to_remove = slots.values
  179. .where(s => s.current_state.last_touched.compare(cutoff) < 0)
  180. .to_series();
  181. foreach (var item in to_remove) {
  182. slots.remove(item.id);
  183. }
  184. }
  185. private Model.FrameDto sign_slot_for(Slot slot, bool unidirectional) throws GLib.Error {
  186. var slot_dto = new Model.SlotDto() {
  187. key = slot.id,
  188. scope = slot.scope,
  189. slot_type = slot.type_name
  190. };
  191. var timestamp = new DateTime.now_utc();
  192. var snapshot_dto = new Model.SnapshotDto() {
  193. as_at = timestamp,
  194. slot = slot_dto,
  195. transmit_after = unidirectional ? null : timestamp.add_seconds(RETRANSMIT_SECONDS),
  196. invalid_after = timestamp.add_seconds(EXPIRY_SECONDS),
  197. @private = encryption_provider.author_properties(slot.id, slot.current_state.private_data),
  198. @public = slot.current_state.public_data
  199. };
  200. var json = new JsonElement.from_properties(Model.SnapshotDto.get_mapper().map_from(snapshot_dto)).stringify();
  201. return signing_provider.author_frame(json);
  202. }
  203. private static string serialize_frame(Model.FrameDto frame) throws GLib.Error {
  204. return new JsonElement.from_properties(Model.FrameDto.get_mapper().map_from(frame)).stringify();
  205. }
  206. }
  207. }