schematichq 1.5.2 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. checksums.yaml +4 -4
  2. data/.fern/metadata.json +4 -4
  3. data/.fern/replay.lock +14 -338
  4. data/.fernignore +8 -0
  5. data/.rubocop.yml +4 -0
  6. data/README.md +141 -0
  7. data/WASM_VERSION +1 -1
  8. data/custom.gemspec.rb +6 -0
  9. data/lib/schematic/accesstokens/client.rb +9 -1
  10. data/lib/schematic/accounts/client.rb +183 -21
  11. data/lib/schematic/accounts/types/update_environment_request_body.rb +2 -0
  12. data/lib/schematic/billing/client.rb +325 -34
  13. data/lib/schematic/billing/types/create_billing_price_request_body.rb +1 -1
  14. data/lib/schematic/billing/types/create_billing_product_request_body.rb +1 -1
  15. data/lib/schematic/billing/types/create_billing_subscription_request_body.rb +1 -1
  16. data/lib/schematic/billing/types/create_coupon_request_body.rb +2 -2
  17. data/lib/schematic/billing/types/create_invoice_request_body.rb +1 -1
  18. data/lib/schematic/billing/types/get_billing_settings_response.rb +13 -0
  19. data/lib/schematic/billing/types/list_billing_prices_params.rb +1 -1
  20. data/lib/schematic/billing/types/list_billing_prices_request.rb +1 -1
  21. data/lib/schematic/billing/types/list_billing_product_prices_params.rb +1 -1
  22. data/lib/schematic/billing/types/list_billing_product_prices_request.rb +1 -1
  23. data/lib/schematic/billing/types/update_billing_settings_request_body.rb +11 -0
  24. data/lib/schematic/billing/types/update_billing_settings_response.rb +13 -0
  25. data/lib/schematic/catalogs/client.rb +121 -14
  26. data/lib/schematic/checkout/client.rb +80 -9
  27. data/lib/schematic/checkout/types/checkout_data_request_body.rb +1 -1
  28. data/lib/schematic/client.rb +5 -3
  29. data/lib/schematic/companies/client.rb +380 -44
  30. data/lib/schematic/components/client.rb +70 -8
  31. data/lib/schematic/components/types/create_component_request_body.rb +1 -1
  32. data/lib/schematic/components/types/update_component_request_body.rb +1 -1
  33. data/lib/schematic/componentspublic/client.rb +8 -1
  34. data/lib/schematic/credits/client.rb +458 -46
  35. data/lib/schematic/credits/leases/check.rb +477 -0
  36. data/lib/schematic/credits/leases/lease_manager.rb +565 -0
  37. data/lib/schematic/credits/leases/lease_store.rb +240 -0
  38. data/lib/schematic/credits/leases/redis_lease_store.rb +358 -0
  39. data/lib/schematic/credits/leases/redis_reservation_store.rb +326 -0
  40. data/lib/schematic/credits/leases/reservation_store.rb +161 -0
  41. data/lib/schematic/credits/leases/server_check.rb +237 -0
  42. data/lib/schematic/credits/leases/track.rb +66 -0
  43. data/lib/schematic/credits/leases/types.rb +329 -0
  44. data/lib/schematic/credits/leases/wire_client.rb +83 -0
  45. data/lib/schematic/credits/types/acquire_credit_lease_request_body.rb +3 -1
  46. data/lib/schematic/credits/types/create_billing_credit_request_body.rb +1 -1
  47. data/lib/schematic/credits/types/create_company_credit_grant.rb +1 -1
  48. data/lib/schematic/credits/types/create_credit_bundle_request_body.rb +1 -1
  49. data/lib/schematic/credits/types/create_credit_spend_policy_request_body.rb +5 -1
  50. data/lib/schematic/credits/types/extend_credit_lease_request_body.rb +3 -1
  51. data/lib/schematic/credits/types/get_credit_balance_time_series_params.rb +18 -0
  52. data/lib/schematic/credits/types/get_credit_balance_time_series_request.rb +17 -0
  53. data/lib/schematic/credits/types/get_credit_balance_time_series_response.rb +13 -0
  54. data/lib/schematic/credits/types/get_credit_spend_policy_usage_params.rb +16 -0
  55. data/lib/schematic/credits/types/get_credit_spend_policy_usage_request.rb +15 -0
  56. data/lib/schematic/credits/types/get_credit_spend_policy_usage_response.rb +13 -0
  57. data/lib/schematic/credits/types/reserve_credits_request_body.rb +1 -1
  58. data/lib/schematic/credits/types/update_credit_spend_policy_request_body.rb +5 -1
  59. data/lib/schematic/dataexports/client.rb +36 -6
  60. data/lib/schematic/datastream/client.rb +32 -2
  61. data/lib/schematic/entitlements/client.rb +406 -25
  62. data/lib/schematic/entitlements/types/count_company_user_usage_params.rb +24 -0
  63. data/lib/schematic/entitlements/types/count_company_user_usage_request.rb +23 -0
  64. data/lib/schematic/entitlements/types/count_company_user_usage_response.rb +13 -0
  65. data/lib/schematic/entitlements/types/create_billing_linked_plan_entitlement_request_body.rb +4 -2
  66. data/lib/schematic/entitlements/types/create_company_override_request_body.rb +3 -1
  67. data/lib/schematic/entitlements/types/create_plan_entitlement_request_body.rb +4 -2
  68. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_params.rb +16 -0
  69. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_request.rb +15 -0
  70. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_response.rb +13 -0
  71. data/lib/schematic/entitlements/types/list_company_user_usage_params.rb +24 -0
  72. data/lib/schematic/entitlements/types/list_company_user_usage_request.rb +23 -0
  73. data/lib/schematic/entitlements/types/list_company_user_usage_response.rb +13 -0
  74. data/lib/schematic/entitlements/types/update_company_override_request_body.rb +3 -1
  75. data/lib/schematic/entitlements/types/update_plan_entitlement_request_body.rb +4 -2
  76. data/lib/schematic/errors/connection_error.rb +11 -0
  77. data/lib/schematic/errors/response_error.rb +23 -2
  78. data/lib/schematic/events/client.rb +78 -9
  79. data/lib/schematic/features/client.rb +160 -18
  80. data/lib/schematic/features/types/check_and_reserve_flag_request_body.rb +1 -1
  81. data/lib/schematic/features/types/create_billing_linked_feature_request_body.rb +2 -0
  82. data/lib/schematic/features/types/create_feature_request_body.rb +2 -0
  83. data/lib/schematic/features/types/update_feature_request_body.rb +2 -0
  84. data/lib/schematic/insights/client.rb +48 -6
  85. data/lib/schematic/integrationsapi/client.rb +120 -14
  86. data/lib/schematic/internal/http/base_request.rb +30 -0
  87. data/lib/schematic/internal/http/raw_client.rb +35 -5
  88. data/lib/schematic/internal/iterators/cursor_item_iterator.rb +7 -2
  89. data/lib/schematic/internal/iterators/cursor_page_iterator.rb +68 -14
  90. data/lib/schematic/internal/iterators/item_iterator.rb +61 -15
  91. data/lib/schematic/internal/iterators/offset_item_iterator.rb +7 -2
  92. data/lib/schematic/internal/iterators/offset_page_iterator.rb +57 -9
  93. data/lib/schematic/internal/json/request.rb +11 -2
  94. data/lib/schematic/internal/multipart/multipart_request.rb +1 -0
  95. data/lib/schematic/internal/types/model.rb +7 -3
  96. data/lib/schematic/internal/types/utils.rb +7 -0
  97. data/lib/schematic/licenses/client.rb +26 -3
  98. data/lib/schematic/planbundle/client.rb +27 -3
  99. data/lib/schematic/plangroups/client.rb +28 -3
  100. data/lib/schematic/plangroups/types/create_plan_group_request_body.rb +2 -0
  101. data/lib/schematic/plangroups/types/update_plan_group_request_body.rb +2 -0
  102. data/lib/schematic/planmigrations/client.rb +104 -11
  103. data/lib/schematic/planmigrations/types/count_migrations_params.rb +2 -0
  104. data/lib/schematic/planmigrations/types/count_migrations_request.rb +2 -0
  105. data/lib/schematic/planmigrations/types/create_migration_input.rb +2 -0
  106. data/lib/schematic/planmigrations/types/list_migrations_params.rb +2 -0
  107. data/lib/schematic/planmigrations/types/list_migrations_request.rb +2 -0
  108. data/lib/schematic/plans/client.rb +213 -23
  109. data/lib/schematic/plans/types/get_plan_version_diff_params.rb +12 -0
  110. data/lib/schematic/plans/types/get_plan_version_diff_request.rb +13 -0
  111. data/lib/schematic/plans/types/get_plan_version_diff_response.rb +13 -0
  112. data/lib/schematic/plans/types/publish_plan_version_request_body.rb +6 -0
  113. data/lib/schematic/plans/types/retry_custom_plan_billing_request_body.rb +2 -0
  114. data/lib/schematic/rules_engine.rb +49 -0
  115. data/lib/schematic/scheduledcheckout/client.rb +35 -4
  116. data/lib/schematic/schematic_client.rb +841 -33
  117. data/lib/schematic/types/billing_coupon_response_data.rb +2 -2
  118. data/lib/schematic/types/billing_credit_grant_response_data.rb +8 -8
  119. data/lib/schematic/types/billing_customer_subscription.rb +1 -1
  120. data/lib/schematic/types/billing_plan_credit_grant_response_data.rb +1 -1
  121. data/lib/schematic/types/billing_price_response_data.rb +1 -1
  122. data/lib/schematic/types/billing_price_view.rb +1 -1
  123. data/lib/schematic/types/billing_product_detail_response_data.rb +2 -2
  124. data/lib/schematic/types/billing_product_for_subscription_response_data.rb +2 -2
  125. data/lib/schematic/types/billing_product_price_response_data.rb +1 -1
  126. data/lib/schematic/types/billing_product_pricing.rb +1 -1
  127. data/lib/schematic/types/billing_product_response_data.rb +2 -2
  128. data/lib/schematic/types/billing_settings_response_data.rb +9 -0
  129. data/lib/schematic/types/billing_subscription_discount_view.rb +2 -2
  130. data/lib/schematic/types/billing_subscription_response_data.rb +1 -1
  131. data/lib/schematic/types/billing_subscription_view.rb +1 -1
  132. data/lib/schematic/types/capture_raw_event.rb +4 -0
  133. data/lib/schematic/types/catalog_config_ordered_plan.rb +2 -0
  134. data/lib/schematic/types/catalog_config_ordered_plan_response_data.rb +2 -0
  135. data/lib/schematic/types/catalog_config_plan_credit.rb +11 -0
  136. data/lib/schematic/types/catalog_config_plan_credit_response_data.rb +11 -0
  137. data/lib/schematic/types/change_subscription_internal_request_body.rb +1 -1
  138. data/lib/schematic/types/change_subscription_request_body.rb +1 -1
  139. data/lib/schematic/types/check_flags_response_data.rb +2 -0
  140. data/lib/schematic/types/checkout_problem_code.rb +54 -0
  141. data/lib/schematic/types/checkout_problem_response_data.rb +31 -0
  142. data/lib/schematic/types/checkout_problem_source.rb +13 -0
  143. data/lib/schematic/types/checkout_subscription.rb +1 -1
  144. data/lib/schematic/types/company_billing_entity_subscription_response_data.rb +1 -1
  145. data/lib/schematic/types/company_credit_balance.rb +3 -3
  146. data/lib/schematic/types/company_credit_balance_response_data.rb +4 -4
  147. data/lib/schematic/types/company_detail_response_data.rb +3 -1
  148. data/lib/schematic/types/company_override_response_data.rb +3 -1
  149. data/lib/schematic/types/company_plan_credit_grant_view.rb +1 -1
  150. data/lib/schematic/types/company_plan_detail_response_data.rb +4 -0
  151. data/lib/schematic/types/company_subscription_response_data.rb +1 -1
  152. data/lib/schematic/types/company_user_usage_metrics_response_data.rb +15 -0
  153. data/lib/schematic/types/company_user_usage_response_data.rb +19 -0
  154. data/lib/schematic/types/company_user_usage_row_response_data.rb +17 -0
  155. data/lib/schematic/types/component_display_settings.rb +2 -0
  156. data/lib/schematic/types/component_response_data.rb +1 -1
  157. data/lib/schematic/types/component_settings_response_data.rb +2 -0
  158. data/lib/schematic/types/condition.rb +3 -1
  159. data/lib/schematic/types/condition_response_data.rb +3 -1
  160. data/lib/schematic/types/condition_view.rb +3 -1
  161. data/lib/schematic/types/coupon_request_body.rb +2 -2
  162. data/lib/schematic/types/create_billing_plan_credit_grant_request_body.rb +1 -1
  163. data/lib/schematic/types/create_entitlement_in_bundle_request_body.rb +4 -2
  164. data/lib/schematic/types/create_entitlement_req_common.rb +3 -1
  165. data/lib/schematic/types/create_or_update_condition_request_body.rb +1 -1
  166. data/lib/schematic/types/credit_balance_event_response_data.rb +21 -0
  167. data/lib/schematic/types/credit_balance_feature_consumption_response_data.rb +13 -0
  168. data/lib/schematic/types/credit_balance_point_response_data.rb +15 -0
  169. data/lib/schematic/types/credit_balance_time_series_response_data.rb +15 -0
  170. data/lib/schematic/types/credit_bundle_currency_price.rb +1 -1
  171. data/lib/schematic/types/credit_bundle_currency_price_request_body.rb +1 -1
  172. data/lib/schematic/types/credit_bundle_currency_price_response_data.rb +1 -1
  173. data/lib/schematic/types/credit_burn_breakdown_line.rb +15 -0
  174. data/lib/schematic/types/credit_company_grant_view.rb +8 -8
  175. data/lib/schematic/types/credit_currency_price.rb +1 -1
  176. data/lib/schematic/types/credit_currency_price_request_body.rb +1 -1
  177. data/lib/schematic/types/credit_currency_price_response_data.rb +1 -1
  178. data/lib/schematic/types/credit_event_ledger_response_data.rb +14 -6
  179. data/lib/schematic/types/credit_event_type.rb +3 -0
  180. data/lib/schematic/types/credit_grant_detail.rb +1 -1
  181. data/lib/schematic/types/credit_lease_response_data.rb +2 -2
  182. data/lib/schematic/types/credit_ledger_entry_kind.rb +17 -0
  183. data/lib/schematic/types/credit_reservation_response_data.rb +2 -2
  184. data/lib/schematic/types/credit_spend_policy.rb +25 -0
  185. data/lib/schematic/types/credit_spend_policy_response_data.rb +13 -1
  186. data/lib/schematic/types/credit_spend_window.rb +11 -0
  187. data/lib/schematic/types/credit_spend_window_unit.rb +13 -0
  188. data/lib/schematic/types/credit_transfer_response_data.rb +1 -1
  189. data/lib/schematic/types/credit_transfer_view.rb +1 -1
  190. data/lib/schematic/types/credit_usage.rb +5 -5
  191. data/lib/schematic/types/credit_usage_aggregation.rb +4 -4
  192. data/lib/schematic/types/credit_usage_reason.rb +1 -0
  193. data/lib/schematic/types/credits_in_plan.rb +11 -0
  194. data/lib/schematic/types/currency.rb +144 -0
  195. data/lib/schematic/types/currency_price_request_body.rb +1 -1
  196. data/lib/schematic/types/custom_plan_billing_response_data.rb +8 -0
  197. data/lib/schematic/types/entitlement_currency_prices_response_data.rb +1 -1
  198. data/lib/schematic/types/environment_detail_response_data.rb +2 -0
  199. data/lib/schematic/types/environment_response_data.rb +2 -0
  200. data/lib/schematic/types/estimated_plan_total.rb +13 -0
  201. data/lib/schematic/types/feature_company_response_data.rb +7 -7
  202. data/lib/schematic/types/feature_detail_response_data.rb +2 -0
  203. data/lib/schematic/types/feature_entitlement.rb +8 -6
  204. data/lib/schematic/types/feature_event_source.rb +12 -0
  205. data/lib/schematic/types/feature_in_plan_response_data.rb +2 -0
  206. data/lib/schematic/types/feature_response_data.rb +2 -0
  207. data/lib/schematic/types/feature_usage_response_data.rb +7 -7
  208. data/lib/schematic/types/feature_view.rb +2 -0
  209. data/lib/schematic/types/flag_check_reservation_response_data.rb +3 -3
  210. data/lib/schematic/types/insights_summary_response_data.rb +1 -1
  211. data/lib/schematic/types/invoice_request_body.rb +1 -1
  212. data/lib/schematic/types/invoice_response_data.rb +1 -1
  213. data/lib/schematic/types/manage_plan_preview_response_response_data.rb +2 -0
  214. data/lib/schematic/types/manage_plan_request.rb +3 -1
  215. data/lib/schematic/types/manage_plan_response_response_data.rb +4 -0
  216. data/lib/schematic/types/mrr_response_data.rb +1 -1
  217. data/lib/schematic/types/onboarding_milestone_view.rb +1 -1
  218. data/lib/schematic/types/ordered_plans_in_group.rb +2 -0
  219. data/lib/schematic/types/pending_migration_response_data.rb +6 -0
  220. data/lib/schematic/types/plan_billing_source.rb +1 -0
  221. data/lib/schematic/types/plan_change_action.rb +1 -0
  222. data/lib/schematic/types/plan_change_amendment_status.rb +15 -0
  223. data/lib/schematic/types/plan_change_response_data.rb +4 -0
  224. data/lib/schematic/types/plan_credit_grant_view.rb +1 -1
  225. data/lib/schematic/types/plan_credit_order.rb +11 -0
  226. data/lib/schematic/types/plan_credit_visibility.rb +11 -0
  227. data/lib/schematic/types/plan_currency_price_request_body.rb +1 -1
  228. data/lib/schematic/types/plan_currency_prices_response_data.rb +1 -1
  229. data/lib/schematic/types/plan_entitlement_response_data.rb +3 -1
  230. data/lib/schematic/types/plan_group_plan_entitlements_order.rb +2 -0
  231. data/lib/schematic/types/plan_version_billing_product_change_response_data.rb +11 -0
  232. data/lib/schematic/types/plan_version_credit_grant_change_response_data.rb +17 -0
  233. data/lib/schematic/types/plan_version_diff_change_type.rb +13 -0
  234. data/lib/schematic/types/plan_version_diff_price_cadence.rb +14 -0
  235. data/lib/schematic/types/plan_version_diff_response_data.rb +23 -0
  236. data/lib/schematic/types/plan_version_entitlement_change_response_data.rb +17 -0
  237. data/lib/schematic/types/plan_version_field_change_response_data.rb +13 -0
  238. data/lib/schematic/types/plan_version_migration_response_data.rb +2 -0
  239. data/lib/schematic/types/plan_version_migration_strategy.rb +1 -0
  240. data/lib/schematic/types/plan_version_price_change_response_data.rb +15 -0
  241. data/lib/schematic/types/plan_view_public_response_data.rb +2 -0
  242. data/lib/schematic/types/preflight_request_body.rb +1 -1
  243. data/lib/schematic/types/preview_subscription_change_response_data.rb +3 -1
  244. data/lib/schematic/types/preview_subscription_discount_response_data.rb +2 -2
  245. data/lib/schematic/types/preview_subscription_finance_response_data.rb +2 -2
  246. data/lib/schematic/types/rules_engine_schema_version.rb +1 -1
  247. data/lib/schematic/types/rulesengine_company.rb +3 -1
  248. data/lib/schematic/types/rulesengine_condition.rb +3 -1
  249. data/lib/schematic/types/rulesengine_credit_postpaid_config.rb +1 -1
  250. data/lib/schematic/types/rulesengine_credit_spend_policy.rb +25 -0
  251. data/lib/schematic/types/rulesengine_credit_spend_policy_scope.rb +13 -0
  252. data/lib/schematic/types/rulesengine_credit_spend_window.rb +11 -0
  253. data/lib/schematic/types/rulesengine_feature_entitlement.rb +8 -6
  254. data/lib/schematic/types/rulesengine_user.rb +2 -0
  255. data/lib/schematic/types/scheduled_downgrade_response_data.rb +1 -1
  256. data/lib/schematic/types/top_feature_by_usage_response_data.rb +1 -1
  257. data/lib/schematic/types/upcoming_invoice_response_data.rb +1 -1
  258. data/lib/schematic/types/update_billing_plan_credit_grant_request_body.rb +1 -1
  259. data/lib/schematic/types/update_entitlement_req_common.rb +3 -1
  260. data/lib/schematic/types/upsert_billing_product_request_body.rb +1 -1
  261. data/lib/schematic/types/usage_based_entitlement_request_body.rb +1 -1
  262. data/lib/schematic/types/usage_based_entitlement_response_data.rb +1 -1
  263. data/lib/schematic/types/user_credit_usage_response_data.rb +2 -2
  264. data/lib/schematic/types/user_daily_credit_point_response_data.rb +1 -1
  265. data/lib/schematic/types/user_feature_usage_response_data.rb +1 -1
  266. data/lib/schematic/types/user_usage_metric.rb +12 -0
  267. data/lib/schematic/version.rb +1 -1
  268. data/lib/schematic/wasm/rulesengine.wasm +0 -0
  269. data/lib/schematic/webhooks/client.rb +88 -10
  270. data/lib/schematic.rb +64 -5
  271. data/lib/schematichq.rb +10 -0
  272. data/reference.md +888 -127
  273. metadata +71 -2
@@ -59,6 +59,24 @@ module Schematic
59
59
  # Optional event metadata accepted via the `options:` keyword on track/identify.
60
60
  # identify only honors :idempotency_key; track also honors :sent_at,
61
61
  # :trusted_client_clock, and :backfill. Fields are only sent when set.
62
+ # Namespaces the idempotency key on the track event a reservation settles
63
+ # into. Deterministic per reservation, so a recovery emit (the work outlived
64
+ # the local reservation TTL) and an accidental double settle collapse to one
65
+ # billed event: the pipeline drops duplicates for 24h before any credit
66
+ # consumption runs.
67
+ RESERVATION_TRACK_IDEMPOTENCY_PREFIX = "lease-reservation:"
68
+
69
+ # The prefix Schematic's secure company ids carry, whatever key name they
70
+ # are passed under.
71
+ COMPANY_ID_PREFIX = "comp_"
72
+
73
+ # Knobs that only steer the local lease plumbing, which server mode never
74
+ # builds. Setting one there does nothing, so the client says so at startup.
75
+ CLIENT_ONLY_LEASE_OPTIONS = %i[
76
+ default_lease_duration default_lease_size low_water_mark sweep_interval_ms
77
+ redis_client redis_key_prefix prewarm_resolve_timeout_ms overrides
78
+ ].freeze
79
+
62
80
  TRACK_OPTION_KEYS = %i[idempotency_key sent_at trusted_client_clock backfill].freeze
63
81
  IDENTIFY_OPTION_KEYS = %i[idempotency_key].freeze
64
82
 
@@ -72,6 +90,7 @@ module Schematic
72
90
  event_capture_base_url: nil,
73
91
  use_data_stream: false,
74
92
  datastream_options: {},
93
+ credit_leases: nil,
75
94
  logger: nil,
76
95
  log_level: :warn
77
96
  )
@@ -89,6 +108,13 @@ module Schematic
89
108
  end
90
109
  @offline = offline
91
110
 
111
+ # Validated here rather than alongside the rest of the lease setup below:
112
+ # a rejected knob raises out of the constructor, so the caller never gets
113
+ # a client to close, and anything already running by then (the event
114
+ # buffer's flush thread, the DataStream socket) is leaked for the life of
115
+ # the process. Nothing has started yet at this point.
116
+ validated_leases = validated_credit_lease_config(credit_leases)
117
+
92
118
  # Initialize Fern-generated API client
93
119
  @api_client = if @offline
94
120
  nil
@@ -118,6 +144,25 @@ module Schematic
118
144
  @rules_engine = nil
119
145
  setup_datastream(datastream_options) if use_data_stream && !@offline
120
146
 
147
+ # Credit lease + reservation plumbing, if the caller opted in.
148
+ @credit_lease_config = nil
149
+ @credit_lease_mode = nil
150
+ @credit_lease_manager = nil
151
+ @lease_store = nil
152
+ @reservations = nil
153
+ # True when lease state lives in a shared backend that sibling processes
154
+ # may also be drawing on. close must then NOT release leases.
155
+ @lease_backend_shared = false
156
+ @server_reservation_ttl_ms = Credits::Leases::DEFAULT_RESERVATION_TTL_MS
157
+ @prewarm_resolve_timeout_ms = Credits::Leases::DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS
158
+ # Prewarms identify spawned and nobody joins. close waits them out: an
159
+ # acquire that lands after the release installs a lease nothing releases,
160
+ # and its credits stay held until the server expires them.
161
+ @pending_prewarms = []
162
+ @pending_prewarms_mutex = Mutex.new
163
+ @closing = false
164
+ setup_credit_leases(validated_leases, datastream_options) if credit_leases
165
+
121
166
  # Register shutdown hook to ensure graceful cleanup on process exit
122
167
  at_exit { close }
123
168
  end
@@ -128,11 +173,18 @@ module Schematic
128
173
  check_flag_with_entitlement(flag_key, company: company, user: user).value
129
174
  end
130
175
 
131
- def check_flag_with_entitlement(flag_key, company: nil, user: nil)
176
+ # default_value overrides the registered flag default on every path that
177
+ # cannot answer from the flag itself: offline, an API error, or a response
178
+ # with no value. Leaving it nil keeps the registered default. timeout_ms
179
+ # bounds each attempt of the API call.
180
+ def check_flag_with_entitlement(flag_key, company: nil, user: nil, preflight: nil, default_value: nil,
181
+ timeout_ms: nil)
182
+ get_default = -> { resolve_default_value(flag_key, default_value) }
183
+
132
184
  # Offline mode
133
185
  if @offline
134
186
  return CheckFlagResponse.new(
135
- value: get_flag_default(flag_key),
187
+ value: get_default.call,
136
188
  flag_key: flag_key,
137
189
  reason: "offline mode"
138
190
  )
@@ -142,8 +194,22 @@ module Schematic
142
194
  if @datastream_client&.connected?
143
195
  begin
144
196
  eval_ctx = build_eval_context(company, user)
145
- result = @datastream_client.check_flag(eval_ctx, flag_key)
146
-
197
+ # Only widen the call when there is something to pass: a DataStream
198
+ # double written against the two-argument form still works, and the
199
+ # preflight envelope reaches the engine when a lease check supplies it.
200
+ result = if preflight.nil?
201
+ @datastream_client.check_flag(eval_ctx, flag_key)
202
+ else
203
+ @datastream_client.check_flag(eval_ctx, flag_key, preflight)
204
+ end
205
+
206
+ # A nil value is the engine declining to answer, not a false. The
207
+ # caller's default stands in, falling back to the registered one,
208
+ # which is what CheckFlagResponse's own coercion to false would
209
+ # otherwise hide. Resolved the same way the offline and API paths
210
+ # resolve it, or one call would honour default_value and another
211
+ # would not.
212
+ result[:value] = get_default.call if result[:value].nil?
147
213
  response = CheckFlagResponse.new(result)
148
214
  enqueue_flag_check_event(flag_key, response, company, user)
149
215
  return response
@@ -155,11 +221,12 @@ module Schematic
155
221
  end
156
222
 
157
223
  # API path with caching
158
- check_flag_via_api(flag_key, company, user)
224
+ check_flag_via_api(flag_key, company, user, preflight: preflight, timeout_ms: timeout_ms,
225
+ get_default: get_default)
159
226
  rescue StandardError => e
160
227
  @logger.error("check_flag_with_entitlement error for '#{flag_key}': #{e.message}")
161
228
  CheckFlagResponse.new(
162
- value: get_flag_default(flag_key),
229
+ value: get_default.call,
163
230
  flag_key: flag_key,
164
231
  reason: "error: #{e.message}"
165
232
  )
@@ -255,29 +322,215 @@ module Schematic
255
322
  end
256
323
  end
257
324
 
258
- # --- Event Submission ---
325
+ # --- Credit-aware Flag Checking ---
326
+
327
+ # Credit-aware feature check. With credit_leases configured and a usage
328
+ # passed (optionally qualified by an event_subtype), this gates the check
329
+ # against the company's credit balance and returns a reservation handle on
330
+ # success. Hand that handle to track_with_reservation when the work
331
+ # completes.
332
+ #
333
+ # In client mode (DataStream enabled) the hold is carved out of a local
334
+ # lease and the flag is evaluated by the WASM engine. In server mode it is a
335
+ # single check-and-reserve API call that evaluates the flag and takes the
336
+ # hold server-side. credit_leases[:mode] picks; the default, :auto, uses
337
+ # client mode when DataStream is enabled and server mode otherwise.
338
+ #
339
+ # Without credit_leases (or without a usage) this falls through to a plain
340
+ # flag check and returns a result with no reservation. The caller's
341
+ # preflight is still threaded through that plain check, so any client-side
342
+ # evaluation path gates on the post-call balance, just without a
343
+ # reservation, and the REST path sends the preflight too. default_value
344
+ # governs that fallback too, so a check that cannot reach the credit path
345
+ # still answers the way the caller asked.
346
+ #
347
+ # timeout_ms bounds each attempt of the API calls the check makes (the
348
+ # plain flag check, check-and-reserve, or a lease acquire or extend), and
349
+ # caps how long the check waits on another caller's acquire or extend.
350
+ def check(flag_key, company: nil, user: nil, usage: nil, event_subtype: nil, on_acquire_failure: nil,
351
+ default_value: nil, timeout_ms: nil)
352
+ options = {
353
+ usage: usage,
354
+ event_subtype: event_subtype,
355
+ on_acquire_failure: on_acquire_failure,
356
+ default_value: default_value,
357
+ timeout_ms: timeout_ms
358
+ }
359
+ eval_ctx = build_eval_context(company, user)
360
+ fallback = -> { plain_check_result(flag_key, company, user, options) }
361
+
362
+ mode = effective_lease_mode
363
+ # With gating configured, the lease and server paths own a malformed
364
+ # usage and refuse it by the caller's failure mode. With no gating there
365
+ # is nothing to refuse: the value would only reach the preflight builder,
366
+ # which the engine and the REST body both take as an integer, so drop it
367
+ # and ask the plain question. Ruby has no type to catch it at the boundary
368
+ # the way the other SDKs do.
369
+ if mode.nil? && !usage.nil? && !Credits::Leases.valid_quantity?(usage)
370
+ @logger.warn(
371
+ "check: invalid usage #{usage.inspect} for flag #{flag_key}, must be a finite non-negative " \
372
+ "number; continuing without one"
373
+ )
374
+ options[:usage] = nil
375
+ usage = nil
376
+ end
377
+ return fallback.call if usage.nil? || mode.nil?
378
+
379
+ if mode == :server
380
+ return Credits::Leases.check_with_server_reservation(
381
+ Credits::Leases::ServerCheckDeps.new(
382
+ features: features, credits: credits, logger: @logger,
383
+ reservation_ttl_ms: @server_reservation_ttl_ms,
384
+ default_value: -> { resolve_default_value(flag_key, default_value) }
385
+ ),
386
+ flag_key, eval_ctx, options, &fallback
387
+ )
388
+ end
259
389
 
260
- def identify(body, options: nil)
390
+ # Client mode without the local plumbing (mode: :client and no DataStream)
391
+ # keeps the old behavior: a plain, ungated flag check.
392
+ return fallback.call unless @credit_lease_manager && @lease_store && @reservations
393
+
394
+ Credits::Leases.check_with_lease(
395
+ Credits::Leases::CheckDeps.new(
396
+ lease_store: @lease_store, reservations: @reservations, manager: @credit_lease_manager,
397
+ datastream: @datastream_client, logger: @logger,
398
+ # Lease-path checks must stay visible to flag-check analytics and
399
+ # company last-seen, the same as every plain check path.
400
+ enqueue_flag_check_event: ->(body) { enqueue_lease_flag_check_event(body) }
401
+ ),
402
+ flag_key, eval_ctx, options, &fallback
403
+ )
404
+ end
405
+
406
+ # Consume a reservation issued by check. Refunds the unused slice back to
407
+ # the lease's local balance and enqueues a track event with the actual
408
+ # quantity; the server-side event processor consumes
409
+ # actual_quantity x consumption_rate from the company's real credit balance.
410
+ #
411
+ # A server-mode handle has no local hold to refund: the track event carries
412
+ # the reservation id, and the server settles the hold when it processes the
413
+ # event.
414
+ #
415
+ # If the work outlived the reservation's TTL and the sweeper already
416
+ # returned the hold to the lease, the local refund has happened but the
417
+ # usage must still be billed, so the track is emitted anyway as a recovery
418
+ # emit. Double billing is prevented server-side: the track carries a
419
+ # deterministic idempotency key derived from the reservation id, and the
420
+ # events pipeline drops duplicates for 24h before any credit consumption
421
+ # runs. So a recovery emit racing the normal emit, or an accidental second
422
+ # settle, collapses to a single billed event, across processes and restarts.
423
+ def track_with_reservation(reservation, actual_quantity, traits: nil)
261
424
  return if @offline
262
425
 
263
- @event_buffer.push(build_event("identify", body, options, IDENTIFY_OPTION_KEYS))
264
- rescue StandardError => e
265
- @logger.error("Error sending identify event: #{e.message}")
426
+ # check allows without a hold in several ordinary cases: the feature is
427
+ # not credit-metered, the check failed open, usage was 0, or credit leases
428
+ # are not configured. Callers pass result.reservation straight through, so
429
+ # take the nil and tell them how to bill the usage instead of raising on a
430
+ # settle that has nothing to settle.
431
+ if reservation.nil?
432
+ @logger.error(
433
+ "track_with_reservation called without a reservation: the check allowed without taking a hold, " \
434
+ "so there is nothing to settle. Report the usage with track instead."
435
+ )
436
+ return
437
+ end
438
+
439
+ # Mirror the check-path usage guard: a non-finite quantity must reach
440
+ # neither the store (clamping against NaN claims the reservation with NO
441
+ # refund of the unspent slice) nor the billing event, and a negative one
442
+ # would bill negative usage. Skipping the settle leaves the reservation to
443
+ # expire at its TTL, where the sweeper refunds the full hold, so no
444
+ # credits are lost and nothing bogus is billed.
445
+ unless Credits::Leases.valid_quantity?(actual_quantity)
446
+ @logger.error(
447
+ "track_with_reservation: invalid actual_quantity #{actual_quantity.inspect} for reservation " \
448
+ "#{reservation.id}, must be a finite non-negative number; skipping settle " \
449
+ "(the hold is refunded at its TTL)"
450
+ )
451
+ return
452
+ end
453
+
454
+ settle_reservation(reservation, actual_quantity, traits)
266
455
  end
267
456
 
268
- def track(body, options: nil)
457
+ # Pre-warm a credit lease for each given credit type id, so the first check
458
+ # against it does not pay the acquire round trip. Failures are logged, never
459
+ # raised.
460
+ #
461
+ # When the company carries only secondary keys (no id), prewarm actively
462
+ # fetches it over the datastream, waiting up to
463
+ # credit_leases[:prewarm_resolve_timeout_ms], which both resolves the id and
464
+ # warms the cache so the first check hits the lease path.
465
+ def prewarm(credit_type_ids, company: nil)
466
+ if @credit_lease_manager.nil? || @lease_store.nil?
467
+ @logger.debug(
468
+ effective_lease_mode == :server ? "prewarm is a no-op in server mode, there is no local lease to warm" : "prewarm called but credit_leases is not configured"
469
+ )
470
+ return
471
+ end
472
+ if company.nil? || company.empty?
473
+ @logger.debug("prewarm requires a company")
474
+ return
475
+ end
476
+ # Documented as never raising, and a caller reading ids out of config can
477
+ # hand over nil or an empty list without meaning to.
478
+ if credit_type_ids.nil? || credit_type_ids.empty?
479
+ @logger.debug("prewarm requires at least one credit type id")
480
+ return
481
+ end
482
+ if @closing
483
+ # close only waits out the prewarms it spawned; a caller invoking
484
+ # prewarm directly would otherwise install a lease after the release has
485
+ # already listed the store.
486
+ @logger.debug("prewarm: client is closing, skipping acquire")
487
+ return
488
+ end
489
+
490
+ company_id = resolve_company_id_with_wait(company)
491
+ if company_id.nil?
492
+ @logger.debug(
493
+ "prewarm: company not resolved within #{@prewarm_resolve_timeout_ms}ms for keys #{company} " \
494
+ "(first check will acquire)"
495
+ )
496
+ return
497
+ end
498
+
499
+ credit_type_ids.each do |credit_type_id|
500
+ @credit_lease_manager.acquire_if_needed(company_id, credit_type_id)
501
+ rescue StandardError => e
502
+ @logger.warn("prewarm: failed to acquire lease for #{credit_type_id}: #{e.message}")
503
+ end
504
+ nil
505
+ end
506
+
507
+ # --- Event Submission ---
508
+
509
+ # prewarm names credit type ids to acquire leases for in the background once
510
+ # the identify event is enqueued. Failures never surface to the caller, and
511
+ # it is a no-op unless credit_leases is configured.
512
+ def identify(body, options: nil, prewarm: nil)
269
513
  return if @offline
270
514
 
271
- @event_buffer.push(build_event("track", body, options, TRACK_OPTION_KEYS))
515
+ begin
516
+ @event_buffer.push(build_event("identify", body, options, IDENTIFY_OPTION_KEYS))
517
+ rescue StandardError => e
518
+ @logger.error("Error sending identify event: #{e.message}")
519
+ end
272
520
 
273
- # Update company metrics locally if DataStream is active and connected
274
- if @datastream_client&.connected? && body[:company]
275
- event_name = body[:event] || body["event"]
276
- quantity = body[:quantity] || body["quantity"] || 1
277
- @datastream_client.update_company_metrics(body[:company], event_name, quantity)
521
+ return if prewarm.nil? || prewarm.empty?
522
+
523
+ begin
524
+ prewarm_after_identify(body, prewarm)
525
+ rescue StandardError => e
526
+ # identify never raises into its caller, and a prewarm is the least of
527
+ # the reasons it should start.
528
+ @logger.warn("identify prewarm setup failed: #{e.message}")
278
529
  end
279
- rescue StandardError => e
280
- @logger.error("Error sending track event: #{e.message}")
530
+ end
531
+
532
+ def track(body, options: nil)
533
+ emit_track(body, options, update_metrics: true)
281
534
  end
282
535
 
283
536
  # --- Flag Defaults ---
@@ -370,12 +623,27 @@ module Schematic
370
623
 
371
624
  # --- Lifecycle ---
372
625
 
626
+ # Credit leases: with the per-process in-memory backend this process is the
627
+ # only holder of its leases, so they are released here (best-effort), which
628
+ # returns their unspent remainder to the company balance immediately instead
629
+ # of waiting out the lease expiry. With a shared backend, leases are
630
+ # deliberately NOT released: one row per company and credit is shared across
631
+ # every SDK instance pointed at that backend, so a single process shutting
632
+ # down must not release a lease its siblings are still drawing on. Shared
633
+ # leases reclaim themselves by expiring or being fully consumed.
634
+ #
635
+ # Lease work already in flight is waited out, bounded, before the release,
636
+ # so an acquire that lands mid-shutdown is one the release can see.
373
637
  def close
374
638
  return if @closed
375
639
 
376
640
  @closed = true
377
- @event_buffer.stop
641
+ @closing = true
642
+ shut_down_credit_leases
643
+ # DataStream first, then the buffer: its stop flushes, and a flush is the
644
+ # last thing that should still be running.
378
645
  @datastream_client&.close
646
+ @event_buffer.stop
379
647
  @flag_check_cache_providers.each { |c| c.stop if c.respond_to?(:stop) }
380
648
  @logger.debug("SchematicClient closed")
381
649
  end
@@ -387,20 +655,41 @@ module Schematic
387
655
  # Cache providers that serialize (RedisCacheProvider) hand back a Hash, not
388
656
  # the CheckFlagResponse that was stored; the in-memory cache returns the
389
657
  # object itself. Normalize so callers can use the response API either way.
658
+ # The REST preflight body has no event_quantities yet; only the local
659
+ # engine applies it. Sending it would ask the API a question it cannot
660
+ # read, so it comes off here, and the check answers without it. A preflight
661
+ # left empty becomes nil, so the check is a plain, cacheable one.
662
+ def rest_preflight(flag_key, preflight)
663
+ return preflight unless preflight.is_a?(Hash)
664
+ return preflight unless preflight.key?(:event_quantities) || preflight.key?("event_quantities")
665
+
666
+ @logger.warn("Preflight event_quantities for flag '#{flag_key}' is only applied by local evaluation; " \
667
+ "the API check ignores it, so the check answers without it")
668
+ rest = preflight.reject { |key, _| key.to_s == "event_quantities" }
669
+ rest.empty? ? nil : rest
670
+ end
671
+
390
672
  def coerce_cached_response(cached)
391
673
  return cached unless cached.is_a?(Hash)
392
674
 
393
675
  CheckFlagResponse.new(cached)
394
676
  end
395
677
 
396
- def check_flag_via_api(flag_key, company, user)
397
- # Check cache
678
+ def check_flag_via_api(flag_key, company, user, preflight: nil, timeout_ms: nil, get_default: nil)
679
+ get_default ||= -> { get_flag_default(flag_key) }
680
+ preflight = rest_preflight(flag_key, preflight)
398
681
  cache_key = build_cache_key(flag_key, company, user)
399
- @flag_check_cache_providers.each do |provider|
400
- cached = coerce_cached_response(provider.get(cache_key))
401
- if cached
402
- @logger.debug("Flag '#{flag_key}' found in cache (value=#{cached.value})")
403
- return cached
682
+ # The cache is keyed by flag, company and user, so a preflighted check and
683
+ # a plain one collide on one entry while asking different questions ("is
684
+ # this allowed after the action" versus "is it allowed now"). A
685
+ # preflighted check therefore neither reads the cache nor writes to it.
686
+ if preflight.nil?
687
+ @flag_check_cache_providers.each do |provider|
688
+ cached = coerce_cached_response(provider.get(cache_key))
689
+ if cached
690
+ @logger.debug("Flag '#{flag_key}' found in cache (value=#{cached.value})")
691
+ return cached
692
+ end
404
693
  end
405
694
  end
406
695
 
@@ -410,11 +699,19 @@ module Schematic
410
699
  eval_body = {}
411
700
  eval_body[:company] = company if company&.any?
412
701
  eval_body[:user] = user if user&.any?
702
+ eval_body[:preflight] = preflight if preflight
413
703
 
414
- api_response = @api_client.features.check_flag(key: flag_key, **eval_body)
704
+ api_response = @api_client.features.check_flag(
705
+ request_options: api_request_options(timeout_ms), key: flag_key, **eval_body
706
+ )
415
707
  data = api_response.data
416
708
  @logger.debug("API returned flag '#{flag_key}' value=#{data.value}, reason=#{data.reason}")
417
709
 
710
+ if data.value.nil?
711
+ @logger.debug("No value returned from feature flag API for flag '#{flag_key}', falling back to default")
712
+ return CheckFlagResponse.new(value: get_default.call, flag_key: flag_key, reason: "flag default")
713
+ end
714
+
418
715
  response = CheckFlagResponse.new(
419
716
  value: data.value,
420
717
  flag_key: data.flag,
@@ -433,22 +730,33 @@ module Schematic
433
730
  feature_usage_reset_at: data.respond_to?(:feature_usage_reset_at) ? data.feature_usage_reset_at : nil
434
731
  )
435
732
 
436
- # Cache the response
437
- @flag_check_cache_providers.each do |provider|
438
- provider.set(cache_key, response)
733
+ # Cache the response, unless the verdict was preflighted: it answers a
734
+ # question a later plain check is not asking.
735
+ if preflight.nil?
736
+ @flag_check_cache_providers.each do |provider|
737
+ provider.set(cache_key, response)
738
+ end
439
739
  end
440
740
 
441
741
  response
442
742
  rescue StandardError => e
443
743
  @logger.error("API flag check failed for '#{flag_key}': #{e.message}")
444
744
  CheckFlagResponse.new(
445
- value: get_flag_default(flag_key),
745
+ value: get_default.call,
446
746
  flag_key: flag_key,
447
747
  reason: "error: #{e.message}"
448
748
  )
449
749
  end
450
750
  end
451
751
 
752
+ # The generated transport reads timeout_in_seconds per request, falling
753
+ # back to its client-wide timeout when the key is absent.
754
+ def api_request_options(timeout_ms)
755
+ return {} if timeout_ms.nil?
756
+
757
+ { timeout_in_seconds: timeout_ms / 1000.0 }
758
+ end
759
+
452
760
  def get_flag_default(flag_key)
453
761
  @flag_defaults_mutex.synchronize do
454
762
  @flag_defaults.fetch(flag_key, false)
@@ -557,6 +865,506 @@ module Schematic
557
865
  })
558
866
  end
559
867
 
868
+ # --- Credit Leases ---
869
+
870
+ # Normalize and validate before the constructor starts anything, so a bad
871
+ # knob is a clean raise rather than a raise on top of a running flush thread
872
+ # and an open socket. Offline is left alone: it starts neither, and the
873
+ # warning below already says lease gating is off.
874
+ def validated_credit_lease_config(config)
875
+ return nil if config.nil? || @offline
876
+
877
+ normalize_credit_lease_config(config).tap { |normalized| validate_credit_lease_config(normalized) }
878
+ end
879
+
880
+ def setup_credit_leases(config, datastream_options)
881
+ if @offline
882
+ @logger.warn(
883
+ "credit_leases is configured but the client is in offline mode; lease-gated checks are disabled " \
884
+ "and check will return flag defaults with no credit gating."
885
+ )
886
+ return
887
+ end
888
+
889
+ # Already normalized and validated in the constructor.
890
+ @credit_lease_config = config
891
+ @credit_lease_mode = config[:mode] || :auto
892
+ resolve_server_reservation_ttl(config)
893
+ warn_about_mode(config)
894
+ return unless credit_lease_mode_uses_leases?
895
+
896
+ build_lease_plumbing(config, datastream_options)
897
+ end
898
+
899
+ # Reject a knob that cannot mean anything, at construction, where the stack
900
+ # still points at the caller. Left to run, a zero sweep interval spins a
901
+ # thread flat out, a non-positive lease size or duration acquires a lease
902
+ # nothing can reserve against, and a water mark outside (0, 1) either never
903
+ # extends or extends on every check.
904
+ def validate_credit_lease_config(config)
905
+ %i[default_lease_duration default_reservation_ttl default_lease_size sweep_interval_ms
906
+ prewarm_resolve_timeout_ms].each do |knob|
907
+ # prewarm_resolve_timeout_ms documents 0 as cache-only, so it alone may
908
+ # be zero.
909
+ validate_positive_number(config, knob, allow_zero: knob == :prewarm_resolve_timeout_ms)
910
+ end
911
+ validate_low_water_mark(config)
912
+ (config[:overrides] || {}).each_value { |override| validate_credit_lease_config(override) }
913
+ nil
914
+ end
915
+
916
+ def validate_positive_number(config, knob, allow_zero: false)
917
+ value = config[knob]
918
+ return if value.nil?
919
+
920
+ # finite? rejects NaN and both infinities, neither of which can size a
921
+ # lease, a sweep, or a timeout.
922
+ valid = value.is_a?(Numeric) && value.to_f.finite? && (allow_zero ? value >= 0 : value.positive?)
923
+ return if valid
924
+
925
+ raise ArgumentError,
926
+ "credit_leases[:#{knob}] must be a finite #{allow_zero ? "non-negative" : "positive"} number, " \
927
+ "got #{value.inspect}"
928
+ end
929
+
930
+ def validate_low_water_mark(config)
931
+ value = config[:low_water_mark]
932
+ return if value.nil?
933
+ return if value.is_a?(Numeric) && value.to_f.finite? && value.positive? && value < 1
934
+
935
+ raise ArgumentError, "credit_leases[:low_water_mark] must be a number between 0 and 1, got #{value.inspect}"
936
+ end
937
+
938
+ # Accept the hyphenated spellings the other SDKs use for the two enum-ish
939
+ # knobs, so one config shape travels across a mixed fleet.
940
+ def normalize_credit_lease_config(config)
941
+ normalized = config.transform_keys(&:to_sym)
942
+ normalized[:mode] = resolve_credit_lease_mode(normalized[:mode])
943
+ overrides = normalized[:overrides]
944
+ return normalized unless overrides.is_a?(Hash)
945
+
946
+ # A credit type id is a string, but { "ct_1": {} } in Ruby is the symbol
947
+ # :ct_1, and the knobs inside are read by symbol. Settle both spellings
948
+ # here: read the wrong way round, an override silently falls back to the
949
+ # client-wide defaults.
950
+ normalized[:overrides] = overrides.each_with_object({}) do |(credit_type_id, knobs), out|
951
+ out[credit_type_id.to_s] = knobs.is_a?(Hash) ? knobs.transform_keys(&:to_sym) : knobs
952
+ end
953
+ normalized
954
+ end
955
+
956
+ # An unrecognized mode must not read as :auto in silence: the branches test
957
+ # for :client and :server and everything else falls through, so a typo would
958
+ # quietly pick a mode the caller did not ask for. Name the value, then use
959
+ # the documented default.
960
+ def resolve_credit_lease_mode(value)
961
+ return :auto if value.nil?
962
+
963
+ mode = Credits::Leases.normalize_symbol(value)
964
+ return mode if Credits::Leases::MODES.include?(mode)
965
+
966
+ @logger.warn(
967
+ "Unrecognized credit_leases[:mode] #{value.inspect}; expected one of " \
968
+ "#{Credits::Leases::MODES.join(", ")}. Using :auto."
969
+ )
970
+ :auto
971
+ end
972
+
973
+ # The API refuses a hold expiring more than an hour after its own clock, and
974
+ # this TTL is applied to the caller's, so clamp a step below the cap to leave
975
+ # room for skew. Only server mode sends the value to the API: in client mode
976
+ # it sizes the local sweep, so clamping there would shorten holds for no
977
+ # reason and the warning would be untrue.
978
+ def resolve_server_reservation_ttl(config)
979
+ configured = config[:default_reservation_ttl] || Credits::Leases::DEFAULT_RESERVATION_TTL_MS
980
+ max_ttl = Credits::Leases::MAX_RESERVATION_TTL_MS - Credits::Leases::RESERVATION_TTL_SKEW_ALLOWANCE_MS
981
+ @server_reservation_ttl_ms = @credit_lease_mode == :client ? configured : [configured, max_ttl].min
982
+ return unless @credit_lease_mode != :client && configured > max_ttl
983
+
984
+ @logger.warn(
985
+ "credit_leases[:default_reservation_ttl] of #{configured}ms is longer than the API will hold credits " \
986
+ "for; server-mode holds will be clamped to #{max_ttl}ms (the " \
987
+ "#{Credits::Leases::MAX_RESERVATION_TTL_MS}ms maximum, less " \
988
+ "#{Credits::Leases::RESERVATION_TTL_SKEW_ALLOWANCE_MS}ms of room for clock skew)."
989
+ )
990
+ end
991
+
992
+ def warn_about_mode(config)
993
+ # Server mode holds credits over the API, so none of the local lease
994
+ # plumbing is built and options that only steer it would silently do
995
+ # nothing. Say so once, at startup. :auto with no DataStream lands in
996
+ # server mode too, and is the likelier way to get here.
997
+ if @credit_lease_mode == :server || (@credit_lease_mode == :auto && @datastream_client.nil?)
998
+ client_only = CLIENT_ONLY_LEASE_OPTIONS.reject { |name| config[name].nil? }
999
+ if client_only.any?
1000
+ @logger.warn(
1001
+ "credit_leases resolves to server mode, so #{client_only.join(", ")} will be ignored: " \
1002
+ "those options only apply to client mode (local leases over DataStream)."
1003
+ )
1004
+ end
1005
+ end
1006
+
1007
+ # :auto with no DataStream is the server-mode default, not a
1008
+ # misconfiguration: check-and-reserve gates over the API instead. :client
1009
+ # without DataStream is the degraded path, where every check falls back to
1010
+ # a plain flag check with usage ignored, so it warns.
1011
+ if @credit_lease_mode == :auto && @datastream_client.nil?
1012
+ @logger.info(
1013
+ "credit_leases is configured and DataStream is not enabled; credit reservations will run in server " \
1014
+ "mode (one check-and-reserve API call per check). Set use_data_stream: true (or replicator mode) " \
1015
+ "for client-side leases."
1016
+ )
1017
+ end
1018
+ return unless @credit_lease_mode == :client && @datastream_client.nil?
1019
+
1020
+ @logger.warn(
1021
+ "credit_leases is configured but DataStream is not enabled; check will fall back to plain flag checks " \
1022
+ "with NO credit gating (usage is ignored). Set use_data_stream: true (or replicator mode) to enable " \
1023
+ "lease-gated checks."
1024
+ )
1025
+ end
1026
+
1027
+ def build_lease_plumbing(config, datastream_options)
1028
+ sweep_ms = config[:sweep_interval_ms] || Credits::Leases::DEFAULT_SWEEP_INTERVAL_MS
1029
+ # Lease and reservation state belongs in a shared cache so gating holds
1030
+ # across horizontally scaled processes. Prefer an explicit client, but
1031
+ # otherwise reuse the one the DataStream cache is already configured with,
1032
+ # so an existing Redis setup backs leases automatically. Same for the key
1033
+ # prefix.
1034
+ redis_client = config[:redis_client] || datastream_options[:redis_client]
1035
+ key_prefix = config[:redis_key_prefix] || datastream_options[:redis_key_prefix]
1036
+
1037
+ if redis_client
1038
+ # Shared-state backend: the lease balance and the reservation table live
1039
+ # in Redis, and the Lua-driven reserve and consume paths give atomic
1040
+ # cross-process gating without a separate lock service.
1041
+ @lease_backend_shared = true
1042
+ @lease_store = Credits::Leases::RedisLeaseStore.new(
1043
+ client: redis_client, key_prefix: key_prefix,
1044
+ default_lease_duration_ms: config[:default_lease_duration] || Credits::Leases::DEFAULT_LEASE_DURATION_MS
1045
+ )
1046
+ @reservations = Credits::Leases::RedisReservationStore.new(
1047
+ client: redis_client, lease_store: @lease_store, sweep_interval_ms: sweep_ms,
1048
+ key_prefix: key_prefix, logger: @logger
1049
+ )
1050
+ else
1051
+ # No shared backend configured. In a horizontally scaled deployment each
1052
+ # process then acquires and gates against its own leases, which defeats
1053
+ # the cross-process over-spend protection that is the point of leasing,
1054
+ # so warn rather than degrade silently.
1055
+ @logger.warn(
1056
+ "credit_leases is enabled without a shared Redis backend; lease and reservation state will be kept " \
1057
+ "per-process. Configure datastream_options[:redis_client] (or credit_leases[:redis_client]) so " \
1058
+ "leases gate correctly across multiple SDK instances."
1059
+ )
1060
+ @lease_store = Credits::Leases::LeaseStore.new
1061
+ @reservations = Credits::Leases::ReservationStore.new(@lease_store, sweep_ms, logger: @logger)
1062
+ end
1063
+
1064
+ @reservations.start_sweep
1065
+ @credit_lease_manager = Credits::Leases::LeaseManager.new(
1066
+ wire_client: Credits::Leases::ApiWireClient.new(credits_client: credits),
1067
+ lease_store: @lease_store,
1068
+ logger: @logger,
1069
+ config: config
1070
+ )
1071
+ @prewarm_resolve_timeout_ms =
1072
+ config[:prewarm_resolve_timeout_ms] || Credits::Leases::DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS
1073
+ end
1074
+
1075
+ # Whether the configured mode wants the local lease plumbing. Read during
1076
+ # construction, after the DataStream client has been wired, so :auto can
1077
+ # resolve against it.
1078
+ def credit_lease_mode_uses_leases?
1079
+ return false if @credit_lease_mode.nil? || @credit_lease_mode == :server
1080
+ return true if @credit_lease_mode == :client
1081
+
1082
+ !@datastream_client.nil?
1083
+ end
1084
+
1085
+ # Which reservation mode a check with usage resolves to right now. Nil means
1086
+ # no credit gating at all: credit_leases is not configured, or the client is
1087
+ # offline.
1088
+ #
1089
+ # :auto is resolved per check rather than once at startup, so a DataStream
1090
+ # that failed to start after construction falls to server mode instead of
1091
+ # silently dropping every check to a plain, ungated flag check.
1092
+ def effective_lease_mode
1093
+ return nil if @credit_lease_mode.nil? || @offline
1094
+ return :server if @credit_lease_mode == :server
1095
+ return :client if @credit_lease_mode == :client
1096
+
1097
+ plumbing_ready = !@credit_lease_manager.nil? && !@lease_store.nil? && !@reservations.nil?
1098
+ @datastream_client && plumbing_ready ? :client : :server
1099
+ end
1100
+
1101
+ # The plain (non-lease) check the credit paths fall back to, with the
1102
+ # caller's preflight threaded through so a client-side evaluation still
1103
+ # gates on the post-call balance.
1104
+ def plain_check_result(flag_key, company, user, options)
1105
+ # The caller's default_value governs this path too. Without it a check
1106
+ # that falls back and then fails would answer with the registered flag
1107
+ # default, denying where the caller asked to allow.
1108
+ response = check_flag_with_entitlement(
1109
+ flag_key, company: company, user: user,
1110
+ preflight: Credits::Leases.build_preflight_options(options),
1111
+ default_value: options[:default_value], timeout_ms: options[:timeout_ms]
1112
+ )
1113
+ value = response.value
1114
+ Credits::Leases::CheckResult.new(
1115
+ allowed: value, value: value, reason: response.reason, entitlement: response.entitlement,
1116
+ flag_key: response.flag_key || flag_key, flag_id: response.flag_id, error: response.error
1117
+ )
1118
+ end
1119
+
1120
+ def resolve_default_value(flag_key, default_value)
1121
+ return get_flag_default(flag_key) if default_value.nil?
1122
+ return default_value.call if default_value.respond_to?(:call)
1123
+
1124
+ default_value
1125
+ end
1126
+
1127
+ def enqueue_lease_flag_check_event(body)
1128
+ payload = {
1129
+ flag_key: body[:flag_key],
1130
+ value: body[:value],
1131
+ reason: body[:reason]
1132
+ }
1133
+ payload[:error] = body[:error] if body[:error]
1134
+ payload[:flag_id] = body[:flag_id] if body[:flag_id]
1135
+ payload[:rule_id] = body[:rule_id] if body[:rule_id]
1136
+ payload[:company_id] = body[:company_id] if body[:company_id]
1137
+ payload[:user_id] = body[:user_id] if body[:user_id]
1138
+ payload[:company] = body[:req_company] if body[:req_company]&.any?
1139
+ payload[:user] = body[:req_user] if body[:req_user]&.any?
1140
+
1141
+ @event_buffer.push({ event_type: "flag_check", body: payload, sent_at: Time.now.utc.iso8601 })
1142
+ rescue StandardError => e
1143
+ @logger.error("Error enqueueing flag_check event: #{e.message}")
1144
+ end
1145
+
1146
+ def settle_reservation(reservation, actual_quantity, traits)
1147
+ idempotency_key = "#{RESERVATION_TRACK_IDEMPOTENCY_PREFIX}#{reservation.id}"
1148
+ # Server mode: the hold lives on the server and settles by id, so there is
1149
+ # nothing local to consume or refund. Just emit the track.
1150
+ if reservation.server_mode? || @reservations.nil?
1151
+ @logger.warn("track_with_reservation called but credit_leases is not configured; emitting unsettled track") if @reservations.nil? && !reservation.server_mode?
1152
+ # Without a local store there is nothing to settle against, but the
1153
+ # billing event must still carry the lease id (the handle was issued by
1154
+ # a lease-configured client, and dropping it would double-debit the
1155
+ # grant) and the deterministic idempotency key.
1156
+ track(Credits::Leases.build_reservation_track_event(reservation, actual_quantity, traits: traits),
1157
+ options: { idempotency_key: idempotency_key })
1158
+ return
1159
+ end
1160
+
1161
+ outcome = begin
1162
+ Credits::Leases.consume_reservation_and_build_event(@reservations, reservation, actual_quantity,
1163
+ traits: traits)
1164
+ rescue StandardError => e
1165
+ # The local settle failed, most likely an unreachable Redis. The usage
1166
+ # still has to be billed: build the track from the caller-held handle
1167
+ # and emit it anyway. The unsettled local hold is reclaimed by the
1168
+ # sweeper at its TTL or at lease expiry, and the idempotency key keeps a
1169
+ # retried settle from double billing.
1170
+ @logger.warn(
1171
+ "track_with_reservation: failed to settle reservation #{reservation.id} locally (#{e.message}), " \
1172
+ "emitting track anyway"
1173
+ )
1174
+ Credits::Leases::SettleOutcome.new(
1175
+ track: Credits::Leases.build_reservation_track_event(reservation, actual_quantity, traits: traits),
1176
+ settled_locally: false
1177
+ )
1178
+ end
1179
+
1180
+ unless outcome.settled_locally
1181
+ @logger.debug(
1182
+ "track_with_reservation: reservation #{reservation.id} was not settled locally (expired or swept, " \
1183
+ "already settled, or store unreachable), emitting track keyed for idempotent server-side dedupe"
1184
+ )
1185
+ end
1186
+ # The cached company metric moves only when this call moved local state
1187
+ # with it: the server drops a duplicate event on the key, so bumping the
1188
+ # metric for one would have a caller's retry deny its own next
1189
+ # numeric-limit check until the stream pushes the real figure.
1190
+ emit_track(outcome.track, { idempotency_key: idempotency_key }, update_metrics: outcome.settled_locally)
1191
+ end
1192
+
1193
+ # Enqueue a track event, optimistically bumping the cached company metric
1194
+ # with it unless the caller says not to. The bump is a local prediction of
1195
+ # what the stream will push back, so it belongs only to an event that
1196
+ # records usage the server has not already counted.
1197
+ def emit_track(body, options, update_metrics:)
1198
+ return if @offline
1199
+
1200
+ @event_buffer.push(build_event("track", body, options, TRACK_OPTION_KEYS))
1201
+
1202
+ if update_metrics && @datastream_client&.connected? && body[:company]
1203
+ event_name = body[:event] || body["event"]
1204
+ quantity = body[:quantity] || body["quantity"] || 1
1205
+ @datastream_client.update_company_metrics(body[:company], event_name, quantity)
1206
+ end
1207
+ rescue StandardError => e
1208
+ @logger.error("Error sending track event: #{e.message}")
1209
+ end
1210
+
1211
+ def prewarm_after_identify(body, credit_type_ids)
1212
+ # A thread that would only log "no-op in server mode" is still a thread,
1213
+ # and close still has to wait it out. Decide before spawning one.
1214
+ return nil if @credit_lease_manager.nil? || @lease_store.nil?
1215
+
1216
+ company = identify_company_keys(body)
1217
+
1218
+ thread = Thread.new do
1219
+ # Force a flush so the server processes the identify as soon as
1220
+ # possible. Without it the company may sit in the local buffer for up to
1221
+ # the flush interval before the server even sees it, and prewarm's
1222
+ # bounded poll would just be waiting on us. It runs here rather than on
1223
+ # the caller's thread because a flush is an HTTP post with retries, and
1224
+ # identify with a prewarm must stay the buffer push that identify
1225
+ # without one is. The ordering the poll needs still holds: the flush and
1226
+ # the poll are the same thread, in that order. close waits on this
1227
+ # thread, so a shutdown still covers the flush.
1228
+ begin
1229
+ @event_buffer.flush
1230
+ rescue StandardError => e
1231
+ @logger.debug("identify flush before prewarm failed: #{e.message}")
1232
+ end
1233
+ prewarm(credit_type_ids, company: company)
1234
+ rescue StandardError => e
1235
+ @logger.warn("identify prewarm failed: #{e.message}")
1236
+ end
1237
+ thread.abort_on_exception = false
1238
+ @pending_prewarms_mutex.synchronize do
1239
+ @pending_prewarms.select!(&:alive?)
1240
+ @pending_prewarms << thread
1241
+ end
1242
+ nil
1243
+ end
1244
+
1245
+ # The Schematic company id for a set of entity keys, resolved in the
1246
+ # server's order: every supplied key/value pair is an ordinary entity key
1247
+ # and gets looked up first; only when nothing matches is a value read as the
1248
+ # company's own id, by its comp_ prefix rather than by the name of the key
1249
+ # it sits under. An account is free to define a key called "id" holding its
1250
+ # own identifier, so the name alone settles nothing.
1251
+ #
1252
+ # On a cache miss this actively fetches the company over the datastream,
1253
+ # warming the cache as a side effect. identify does not push a company into
1254
+ # that cache: companies are only streamed in response to a request. So this
1255
+ # fetches rather than passively polling the cache, which would watch an
1256
+ # empty cache until it times out. Fetching also primes the cache so the
1257
+ # first real check hits the lease path instead of falling back.
1258
+ def resolve_company_id_with_wait(company)
1259
+ return schematic_company_id(company) if @datastream_client.nil?
1260
+
1261
+ cached = @datastream_client.get_cached_company(company)
1262
+ cached_id = cached && (cached[:id] || cached["id"])
1263
+ return cached_id if cached_id
1264
+ # A zero or negative timeout means cache-only: answer from what the
1265
+ # DataStream already holds and never fetch or poll. A prewarm still
1266
+ # acquires when an earlier check warmed the company.
1267
+ return schematic_company_id(company) if @prewarm_resolve_timeout_ms <= 0
1268
+
1269
+ deadline = monotonic_ms + @prewarm_resolve_timeout_ms
1270
+ loop do
1271
+ # A company resolved for a client that is shutting down warms nothing,
1272
+ # and close would be waiting out the rest of this poll.
1273
+ return nil if @closing
1274
+
1275
+ if @datastream_client.connected?
1276
+ resolved_id = fetch_company_id_within(company, deadline)
1277
+ return resolved_id if resolved_id
1278
+ else
1279
+ # A request sent over a closed socket is dropped without an error, so
1280
+ # the fetch would just sit out its own timeout. Poll the cache until
1281
+ # the socket returns or the budget runs out.
1282
+ cached = @datastream_client.get_cached_company(company)
1283
+ cached_id = cached && (cached[:id] || cached["id"])
1284
+ return cached_id if cached_id
1285
+ end
1286
+ # The keys never resolved, so fall back to a comp_ value the way the
1287
+ # server does once its own key lookup comes up empty.
1288
+ return schematic_company_id(company) if monotonic_ms >= deadline
1289
+
1290
+ sleep([Credits::Leases::DEFAULT_PREWARM_POLL_INTERVAL_MS, deadline - monotonic_ms].min / 1000.0)
1291
+ end
1292
+ end
1293
+
1294
+ # The Schematic id hiding among a set of entity keys, recognized by its
1295
+ # secure-id prefix. The server reads keys this way once a key lookup has
1296
+ # come up empty, so { account_id: "comp_1" } resolves and { id: "acme" }
1297
+ # does not: the prefix decides, not the key's name.
1298
+ def schematic_company_id(keys)
1299
+ return nil unless keys.is_a?(Hash)
1300
+
1301
+ keys.values.find { |v| v.is_a?(String) && v.start_with?(COMPANY_ID_PREFIX) }
1302
+ end
1303
+
1304
+ # A caller can hand identify any shape, so read the company keys without
1305
+ # assuming one.
1306
+ def identify_company_keys(body)
1307
+ return nil unless body.is_a?(Hash)
1308
+
1309
+ company = body[:company] || body["company"]
1310
+ return nil unless company.is_a?(Hash)
1311
+
1312
+ company[:keys] || company["keys"]
1313
+ end
1314
+
1315
+ # get_company waits on the DataStream's own resource timeout, which is many
1316
+ # times the prewarm budget, so it runs on a thread joined to what is left.
1317
+ # An abandoned fetch is left to finish: it still warms the cache for the
1318
+ # next poll or the first real check.
1319
+ def fetch_company_id_within(company, deadline)
1320
+ remaining = deadline - monotonic_ms
1321
+ return nil if remaining <= 0
1322
+
1323
+ fetch = Thread.new { @datastream_client.get_company(company) }
1324
+ fetch.abort_on_exception = false
1325
+ resolved = fetch.join(remaining / 1000.0)&.value
1326
+ resolved && (resolved[:id] || resolved["id"])
1327
+ rescue StandardError => e
1328
+ @logger.debug("prewarm: datastream company fetch failed (#{e.message})")
1329
+ nil
1330
+ end
1331
+
1332
+ def shut_down_credit_leases
1333
+ @reservations&.stop
1334
+ return if @credit_lease_manager.nil?
1335
+
1336
+ # Refuse new lease work first, so the waits below are waiting on work that
1337
+ # is already unwinding rather than work still starting. Both steps run for
1338
+ # a shared backend too: the work must not outlive the client, even where
1339
+ # there is nothing to release.
1340
+ @credit_lease_manager.stop
1341
+ # One budget across both waits, not each timeout in turn: a caller closing
1342
+ # a client wants a bounded shutdown, not the sum of every wait inside it.
1343
+ deadline = monotonic_ms + Credits::Leases::SHUTDOWN_DRAIN_TIMEOUT_MS
1344
+ prewarms = @pending_prewarms_mutex.synchronize { @pending_prewarms.dup }
1345
+ prewarms.each do |thread|
1346
+ remaining = deadline - monotonic_ms
1347
+ break if remaining <= 0
1348
+
1349
+ thread.join(remaining / 1000.0)
1350
+ end
1351
+ if prewarms.any?(&:alive?)
1352
+ @logger.warn(
1353
+ "Timed out after #{Credits::Leases::SHUTDOWN_DRAIN_TIMEOUT_MS}ms waiting for in-flight prewarms on close"
1354
+ )
1355
+ end
1356
+ @credit_lease_manager.drain([deadline - monotonic_ms, 0].max)
1357
+ return if @lease_backend_shared
1358
+
1359
+ # The releases share the shutdown budget too, so a slow API cannot stretch
1360
+ # close past what the caller was promised.
1361
+ @credit_lease_manager.release_all_local_leases([deadline - monotonic_ms, 0].max)
1362
+ end
1363
+
1364
+ def monotonic_ms
1365
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
1366
+ end
1367
+
560
1368
  def setup_datastream(options)
561
1369
  @rules_engine = RulesEngine.new(logger: @logger)
562
1370