pinecall-runtime 0.1.6__py3-none-any.whl

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 (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,416 @@
1
+ """The live signal between gateways: lossy publish and subscribe, on Redis or inside one process."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections import deque
6
+ from collections.abc import AsyncGenerator, Callable
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import dataclass
9
+ from typing import Self
10
+ from uuid import uuid4
11
+
12
+ from redis.asyncio import Redis
13
+ from redis.asyncio.client import PubSub
14
+ from redis.asyncio.retry import Retry
15
+ from redis.backoff import NoBackoff
16
+ from redis.exceptions import ConnectionError as RedisConnectionError
17
+ from redis.exceptions import RedisError
18
+
19
+ from pinecall.domain.errors import NotAvailable, SettingsRefused
20
+ from pinecall.postgres.pool import box_task
21
+ from pinecall.process.settings import Settings
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Ours start here: LiveKit shares the Redis, and a channel's name is global to it, whatever db.
26
+ PREFIX = "pinecall:"
27
+
28
+ # How far a listener may fall behind before it is dropped, as a log's live reader is; how many
29
+ # messages wait for Redis before the oldest is dropped (a reader resumes from the store); and how
30
+ # many go in one pipeline, so the sender writes to Redis once a turn, not once a message.
31
+ LISTENING_DEPTH = 256
32
+ UNSENT_AT_MOST = 10_000
33
+ MOST_A_PIPELINE = 512
34
+
35
+ # How long a connect, a command or the confirmation of a subscription may take.
36
+ ANSWER_S = 2.0
37
+ # The listening connection is asked every PING_S, and is lost when silent this long; one read
38
+ # waits TICK_S before the listener looks at its clock again.
39
+ PING_S = 5.0
40
+ SILENT_AT_MOST_S = 15.0
41
+ TICK_S = 1.0
42
+
43
+ # Redis away: a lost connection is tried again at once, then from half a second doubling to five.
44
+ FIRST_PAUSE_S = 0.5
45
+ LONGEST_PAUSE_S = 5.0
46
+ # The channel each process listens on from the moment it connects: what keeps it subscribed.
47
+ OWN = "signal:{id}"
48
+
49
+ # What the log and the operator read when the signal cannot do its part.
50
+ DOWN = "the live signal between gateways is down: no Redis answers at PINECALL_REDIS_URL"
51
+ NOT_A_URL = "PINECALL_REDIS_URL is not a Redis URL ({why}): redis://host:port/db"
52
+ SILENT = "Redis said nothing for {seconds:.0f} s, not even to a ping"
53
+ LOST = "the live signal lost Redis: every listener resumes from the store; trying again"
54
+ BEHIND_OUR_BACK = "the client reconnected the listening connection, missing what came between"
55
+ NOT_SENT = "the live signal cannot publish to Redis: its messages are dropped until it can"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Heard:
60
+ """One message off the listening connection: its kind, its channel, what it carried."""
61
+
62
+ kind: str
63
+ channel: str | None
64
+ data: bytes | None
65
+
66
+
67
+ class Listening:
68
+ """One listener of one channel, in order; ended once closed, or dropped when cut off."""
69
+
70
+ def __init__(self, channel: str, leave: Callable[["Listening"], None]) -> None:
71
+ """Nothing heard yet; `leave` takes it off the channel."""
72
+ self.channel = channel
73
+ # True when it fell behind or its connection was lost: what it missed is the store's.
74
+ self.dropped = False
75
+ self._leave = leave
76
+ self._heard: deque[bytes] = deque()
77
+ self._moved = asyncio.Event()
78
+ self._over = False
79
+
80
+ def offer(self, data: bytes) -> bool:
81
+ """Keep the message for the listener; False once it is over."""
82
+ if self._over:
83
+ return False
84
+ if len(self._heard) >= LISTENING_DEPTH:
85
+ self.drop()
86
+ return False
87
+ self._heard.append(data)
88
+ self._moved.set()
89
+ return True
90
+
91
+ def drop(self) -> None:
92
+ """End it as cut off, after what it was already given."""
93
+ self.dropped = True
94
+ self._finish()
95
+
96
+ def close(self) -> None:
97
+ """End it after what it was already given, and stop listening."""
98
+ self._finish()
99
+ self._leave(self)
100
+
101
+ def _finish(self) -> None:
102
+ self._over = True
103
+ self._moved.set()
104
+
105
+ def __aiter__(self) -> Self:
106
+ return self
107
+
108
+ async def __anext__(self) -> bytes:
109
+ """The next message, in the order it was published; the end once it is over."""
110
+ while not self._heard:
111
+ if self._over:
112
+ raise StopAsyncIteration
113
+ self._moved.clear()
114
+ await self._moved.wait()
115
+ return self._heard.popleft()
116
+
117
+
118
+ class LocalSignal:
119
+ """The signal inside one process: a gateway alone on its box, with no PINECALL_REDIS_URL."""
120
+
121
+ def __init__(self) -> None:
122
+ """Nobody listening."""
123
+ self.id = f"gw_{uuid4().hex[:12]}"
124
+ self._listening: dict[str, set[Listening]] = {}
125
+
126
+ @property
127
+ def up(self) -> bool:
128
+ """Always: nothing between the publisher and the listener can go away."""
129
+ return True
130
+
131
+ async def connected(self) -> None:
132
+ """Return at once: a process is always connected to itself."""
133
+
134
+ def publish(self, channel: str, data: bytes) -> None:
135
+ """Hand the message to every listener of the channel, never waiting for one."""
136
+ for listening in tuple(self._listening.get(channel, ())):
137
+ if not listening.offer(data):
138
+ self._left(listening)
139
+
140
+ async def published(self, channel: str, data: bytes) -> int:
141
+ """Hand the message to every listener of the channel; how many there were."""
142
+ heard = len(self._listening.get(channel, ()))
143
+ self.publish(channel, data)
144
+ return heard
145
+
146
+ async def subscribe(self, channel: str) -> Listening:
147
+ """A listener of what is published on the channel from now on."""
148
+ listening = Listening(channel, self._left)
149
+ self._listening.setdefault(channel, set()).add(listening)
150
+ return listening
151
+
152
+ async def close(self) -> None:
153
+ """End every listener."""
154
+ for listeners in list(self._listening.values()):
155
+ for listening in tuple(listeners):
156
+ listening.close()
157
+
158
+ def _left(self, listening: Listening) -> None:
159
+ listeners = self._listening.get(listening.channel, set())
160
+ listeners.discard(listening)
161
+ if not listeners:
162
+ self._listening.pop(listening.channel, None)
163
+
164
+
165
+ # Lossy on purpose: a message published while Redis is away, or to a listener that fell behind,
166
+ # is gone, and whoever listened resumes from the store. redis-py retries nothing here (its PubSub
167
+ # would subscribe again behind our back and hide what was missed): the reconnection is ours.
168
+ class RedisSignal:
169
+ """The signal on Redis: one connection publishes in pipelines, one listens; both come back."""
170
+
171
+ def __init__(self, url: str, *, prefix: str = PREFIX) -> None:
172
+ """Not connected until `start`; the URL is refused here when it is not one."""
173
+ self.id = f"gw_{uuid4().hex[:12]}"
174
+ self.prefix = prefix
175
+ # Whether the listening connection is subscribed now: a subscription is refused otherwise.
176
+ self.up = False
177
+ # Set with `up`, for whoever waits for the connection to come back.
178
+ self._connected = asyncio.Event()
179
+ # Messages published and never sent.
180
+ self.dropped = 0
181
+ # Two pools, so the listening connection is never handed to the publisher after it.
182
+ self._client = _client_of(url, self.id)
183
+ self._listener = _client_of(url, self.id)
184
+ self._unsent: deque[tuple[str, bytes]] = deque()
185
+ self._waiting = asyncio.Event()
186
+ self._listening: dict[str, set[Listening]] = {}
187
+ self._confirmed: dict[str, asyncio.Future[bool]] = {}
188
+ self._leaving: set[str] = set()
189
+ self._pubsub: PubSub | None = None
190
+ self._reconnected = False
191
+ self._tasks: list[asyncio.Task[None]] = []
192
+
193
+ # What a reader cut off waits for before it follows its channels again.
194
+ async def connected(self) -> None:
195
+ """Return once the listening connection is subscribed, at once when it is."""
196
+ await self._connected.wait()
197
+
198
+ def start(self) -> None:
199
+ """Start sending and listening; a Redis that is away is waited for, never a failure."""
200
+ self._tasks = [box_task(self._sending()), box_task(self._listened())]
201
+
202
+ # The one publish that waits: a command must know whether anybody runs its call.
203
+ async def published(self, channel: str, data: bytes) -> int:
204
+ """Publish now and answer how many listeners Redis handed it to; NotAvailable while down."""
205
+ try:
206
+ async with asyncio.timeout(ANSWER_S):
207
+ heard = await self._client.publish(self.prefix + channel, data) # pyright: ignore[reportUnknownMemberType]
208
+ except (RedisError, OSError, TimeoutError) as away:
209
+ raise NotAvailable(DOWN) from away
210
+ return int(heard)
211
+
212
+ def publish(self, channel: str, data: bytes) -> None:
213
+ """Queue the message for every gateway listening on the channel; never waits for Redis."""
214
+ if len(self._unsent) >= UNSENT_AT_MOST:
215
+ self._unsent.popleft()
216
+ self.dropped += 1
217
+ self._unsent.append((self.prefix + channel, data))
218
+ self._waiting.set()
219
+
220
+ # Returns once Redis confirmed the subscription: whatever is published after it is heard.
221
+ async def subscribe(self, channel: str) -> Listening:
222
+ """A listener of what is published on the channel from now on; NotAvailable while down."""
223
+ pubsub = self._pubsub
224
+ if not self.up or pubsub is None:
225
+ raise NotAvailable(DOWN)
226
+ name = self.prefix + channel
227
+ listening = Listening(channel, self._left)
228
+ self._listening.setdefault(name, set()).add(listening)
229
+ confirmed = self._confirmed.get(name)
230
+ try:
231
+ if confirmed is None:
232
+ confirmed = self._confirmed[name] = asyncio.get_running_loop().create_future()
233
+ await pubsub.subscribe(name)
234
+ async with asyncio.timeout(ANSWER_S):
235
+ is_confirmed = await asyncio.shield(confirmed)
236
+ except (RedisError, OSError, TimeoutError):
237
+ is_confirmed = False
238
+ except asyncio.CancelledError:
239
+ listening.close()
240
+ raise
241
+ if not is_confirmed:
242
+ listening.close()
243
+ raise NotAvailable(DOWN)
244
+ return listening
245
+
246
+ async def close(self) -> None:
247
+ """Stop both connections; every listener ends, and what was not sent is dropped."""
248
+ for task in self._tasks:
249
+ task.cancel()
250
+ await asyncio.gather(*self._tasks, return_exceptions=True)
251
+ for listeners in list(self._listening.values()):
252
+ for listening in tuple(listeners):
253
+ listening.close()
254
+ await self._forget_the_connection()
255
+ await self._client.aclose()
256
+ await self._listener.aclose()
257
+
258
+ async def _sending(self) -> None:
259
+ pause = 0.0
260
+ while True:
261
+ await self._waiting.wait()
262
+ self._waiting.clear()
263
+ while self._unsent:
264
+ taken = min(len(self._unsent), MOST_A_PIPELINE)
265
+ batch = [self._unsent.popleft() for _ in range(taken)]
266
+ try:
267
+ async with self._client.pipeline(transaction=False) as pipe:
268
+ for name, data in batch:
269
+ pipe.publish(name, data) # pyright: ignore[reportUnknownMemberType]
270
+ await pipe.execute()
271
+ pause = 0.0
272
+ except (RedisError, OSError):
273
+ self.dropped += len(batch)
274
+ if not pause:
275
+ logger.warning(NOT_SENT, exc_info=True)
276
+ pause = _longer(pause)
277
+ await asyncio.sleep(pause)
278
+
279
+ # Said once when a connection is lost or the first one fails, not on every try after.
280
+ async def _listened(self) -> None:
281
+ pause = 0.0
282
+ while True:
283
+ try:
284
+ await self._heard()
285
+ except (RedisError, OSError, TimeoutError):
286
+ if not pause:
287
+ logger.warning(LOST, exc_info=True)
288
+ pause = 0.0 if self.up else _longer(pause)
289
+ await self._lost()
290
+ await asyncio.sleep(pause)
291
+
292
+ # Runs until the connection is lost: every message handed on, a ping when quiet, and the
293
+ # channels nobody listens to any more let go.
294
+ async def _heard(self) -> None:
295
+ pubsub = self._pubsub = PubSub(self._listener.connection_pool)
296
+ own = self.prefix + OWN.format(id=self.id)
297
+ await pubsub.subscribe(own)
298
+ self._reconnected = False
299
+ if pubsub.connection is not None:
300
+ pubsub.connection.register_connect_callback(self._connected_again) # pyright: ignore[reportUnknownMemberType]
301
+ clock = asyncio.get_running_loop().time
302
+ heard_at = pinged_at = clock()
303
+ while True:
304
+ heard = _heard_of(await pubsub.get_message(timeout=TICK_S)) # pyright: ignore[reportUnknownArgumentType]
305
+ now = clock()
306
+ if heard is not None:
307
+ heard_at = now
308
+ self._handled(heard, own)
309
+ if now - heard_at > SILENT_AT_MOST_S:
310
+ raise RedisConnectionError(SILENT.format(seconds=now - heard_at))
311
+ if self._reconnected:
312
+ raise RedisConnectionError(BEHIND_OUR_BACK)
313
+ if now - pinged_at >= PING_S:
314
+ pinged_at = now
315
+ await pubsub.execute_command("PING")
316
+ leaving, self._leaving = self._leaving, set()
317
+ for name in leaving - self._listening.keys():
318
+ await pubsub.execute_command("UNSUBSCRIBE", name)
319
+
320
+ # redis-py reconnects a connection a failed command was on, and subscribes it again: a
321
+ # listener would then go on with a hole in what it heard, so that connection is given up.
322
+ def _connected_again(self, _connection: object) -> None:
323
+ self._reconnected = True
324
+
325
+ def _handled(self, heard: Heard, own: str) -> None:
326
+ if heard.kind == "subscribe" and heard.channel is not None:
327
+ if heard.channel == own:
328
+ self.up = True
329
+ self._connected.set()
330
+ confirmed = self._confirmed.get(heard.channel)
331
+ if confirmed is not None and not confirmed.done():
332
+ confirmed.set_result(True)
333
+ return
334
+ if heard.kind != "message" or heard.channel is None or heard.data is None:
335
+ return
336
+ for listening in tuple(self._listening.get(heard.channel, ())):
337
+ if not listening.offer(heard.data):
338
+ self._left(listening)
339
+
340
+ # Every listener is dropped, not moved to the next connection: what was published between
341
+ # the two is lost, and only the store gives it back.
342
+ async def _lost(self) -> None:
343
+ self.up = False
344
+ self._connected.clear()
345
+ listening, self._listening = self._listening, {}
346
+ confirmed, self._confirmed = self._confirmed, {}
347
+ self._leaving.clear()
348
+ for waiting in confirmed.values():
349
+ if not waiting.done():
350
+ waiting.set_result(False)
351
+ for listeners in listening.values():
352
+ for listener in listeners:
353
+ listener.drop()
354
+ await self._forget_the_connection()
355
+
356
+ async def _forget_the_connection(self) -> None:
357
+ pubsub, self._pubsub = self._pubsub, None
358
+ if pubsub is not None:
359
+ await pubsub.aclose()
360
+
361
+ def _left(self, listening: Listening) -> None:
362
+ name = self.prefix + listening.channel
363
+ listeners = self._listening.get(name)
364
+ if listeners is None or listening not in listeners:
365
+ return
366
+ listeners.discard(listening)
367
+ if not listeners:
368
+ del self._listening[name]
369
+ self._confirmed.pop(name, None)
370
+ self._leaving.add(name)
371
+
372
+
373
+ type Signal = LocalSignal | RedisSignal
374
+
375
+
376
+ @asynccontextmanager
377
+ async def opened_signal(settings: Settings) -> AsyncGenerator[Signal]:
378
+ """Redis at PINECALL_REDIS_URL, or this process alone when unset; closed after."""
379
+ signal: Signal
380
+ if settings.redis_url is None:
381
+ signal = LocalSignal()
382
+ else:
383
+ signal = RedisSignal(settings.redis_url)
384
+ signal.start()
385
+ try:
386
+ yield signal
387
+ finally:
388
+ await signal.close()
389
+
390
+
391
+ # Retries are ours: redis-py's would reconnect the listening connection behind our back.
392
+ def _client_of(url: str, name: str) -> Redis:
393
+ try:
394
+ return Redis.from_url( # pyright: ignore[reportUnknownMemberType]
395
+ url,
396
+ retry=Retry(NoBackoff(), 0),
397
+ socket_connect_timeout=ANSWER_S,
398
+ socket_timeout=ANSWER_S,
399
+ client_name=f"pinecall-{name}",
400
+ )
401
+ except ValueError as wrong:
402
+ raise SettingsRefused(NOT_A_URL.format(why=wrong)) from None
403
+
404
+
405
+ # redis-py hands its messages over as dicts it does not type.
406
+ def _heard_of(message: object) -> Heard | None:
407
+ match message:
408
+ case {"type": str() as kind, "channel": object() as channel, "data": object() as data}:
409
+ name = channel.decode() if isinstance(channel, bytes) else None
410
+ return Heard(kind, name, data if isinstance(data, bytes) else None)
411
+ case _:
412
+ return None
413
+
414
+
415
+ def _longer(pause: float) -> float:
416
+ return FIRST_PAUSE_S if not pause else min(pause * 2, LONGEST_PAUSE_S)
File without changes