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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""The pool an instance opens on its database, and the names a schema and a DSN go by."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
import re
|
|
6
|
+
import weakref
|
|
7
|
+
from collections.abc import AsyncGenerator, Coroutine, Generator
|
|
8
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import psycopg
|
|
13
|
+
from psycopg.conninfo import conninfo_to_dict
|
|
14
|
+
from psycopg.rows import DictRow, dict_row
|
|
15
|
+
from psycopg_pool import AsyncConnectionPool, PoolTimeout
|
|
16
|
+
|
|
17
|
+
from pinecall.domain.errors import DeclarationRefused, StoreUnreachable
|
|
18
|
+
|
|
19
|
+
type Connection = psycopg.AsyncConnection[DictRow]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
type Pool = AsyncConnectionPool[Connection]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DEFAULT_SCHEMA = "public"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# A schema name also travels in the pool's libpq options string, where quoting does not reach.
|
|
29
|
+
_A_SCHEMA_NAME = re.compile(r"^[a-z_][a-z0-9_]*$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
CONNECT_TIMEOUT_S = 10
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# What a gateway holds open to Postgres unless PINECALL_DB_POOL says otherwise: its writer's and
|
|
36
|
+
# its doors' together.
|
|
37
|
+
POOL_SIZE = 10
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# SET LOCAL: they end with the transaction, and the connection goes back to the pool as it came.
|
|
41
|
+
UNBOUNDED = (
|
|
42
|
+
"SET LOCAL statement_timeout = 0",
|
|
43
|
+
"SET LOCAL idle_in_transaction_session_timeout = 0",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Row-level security (migration 0096): a connection that names an org sees and writes that org's
|
|
48
|
+
# rows alone; one that names none is the box's, as the operator's doors and the loops are. The
|
|
49
|
+
# gateway names the org of a tenant's key for the whole request; a connection taken then says it.
|
|
50
|
+
SCOPED_ORG: ContextVar[str | None] = ContextVar("scoped_org", default=None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
SCOPE = "select set_config('pinecall.org', %s, false)"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Timeouts:
|
|
58
|
+
"""How long a pool's statement may run, its transaction sit idle, and a request wait for it."""
|
|
59
|
+
|
|
60
|
+
# A door's statement past this is cancelled; what is long on purpose runs `unbounded`.
|
|
61
|
+
statement_ms: int = 30_000
|
|
62
|
+
# A transaction its process stopped talking in is ended, and its locks let go.
|
|
63
|
+
idle_in_transaction_ms: int = 60_000
|
|
64
|
+
# A request that finds the pool full this long is answered 503, which a worker outlasts.
|
|
65
|
+
wait_s: float = 2.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
TIMEOUTS = Timeouts()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# What each connection is known to say, so one that already says the org is asked nothing: a
|
|
72
|
+
# request's doors share a few connections, and the box's loops never name one. A connection is
|
|
73
|
+
# written here only once the server answered; absent means unknown, and unknown is asked.
|
|
74
|
+
_SAYS: weakref.WeakKeyDictionary[Connection, str] = weakref.WeakKeyDictionary()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def open_pool(
|
|
78
|
+
dsn: str,
|
|
79
|
+
*,
|
|
80
|
+
schema: str = DEFAULT_SCHEMA,
|
|
81
|
+
max_size: int = POOL_SIZE,
|
|
82
|
+
min_size: int = 1,
|
|
83
|
+
timeouts: Timeouts = TIMEOUTS,
|
|
84
|
+
) -> Pool:
|
|
85
|
+
"""Open a pool on the schema, rows as dicts; raise StoreUnreachable when nothing answers."""
|
|
86
|
+
options = (
|
|
87
|
+
f"-c search_path={','.join(schemas_of(schema))}"
|
|
88
|
+
f" -c statement_timeout={timeouts.statement_ms}"
|
|
89
|
+
f" -c idle_in_transaction_session_timeout={timeouts.idle_in_transaction_ms}"
|
|
90
|
+
)
|
|
91
|
+
# A DSN's own options (a role to act as, say) are kept, and the pool's follow them.
|
|
92
|
+
given = conninfo_to_dict(dsn).get("options")
|
|
93
|
+
pool: Pool = AsyncConnectionPool(
|
|
94
|
+
dsn,
|
|
95
|
+
connection_class=psycopg.AsyncConnection[DictRow],
|
|
96
|
+
# A statement alone is one round trip; what belongs together opens connection.transaction().
|
|
97
|
+
kwargs={
|
|
98
|
+
"autocommit": True,
|
|
99
|
+
"row_factory": dict_row,
|
|
100
|
+
"options": options if not given else f"{given} {options}",
|
|
101
|
+
},
|
|
102
|
+
min_size=min_size,
|
|
103
|
+
max_size=max_size,
|
|
104
|
+
timeout=timeouts.wait_s,
|
|
105
|
+
open=False,
|
|
106
|
+
check=_scoped,
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
await pool.open(wait=True, timeout=CONNECT_TIMEOUT_S)
|
|
110
|
+
except (PoolTimeout, psycopg.OperationalError) as refused:
|
|
111
|
+
await pool.close()
|
|
112
|
+
raise StoreUnreachable(f"{database_named(dsn)}: {refused}") from refused
|
|
113
|
+
return pool
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Erasure, an export, the nightly retention and a knowledge base's index are long on purpose.
|
|
117
|
+
@asynccontextmanager
|
|
118
|
+
async def unbounded(pool: Pool) -> AsyncGenerator[Connection]:
|
|
119
|
+
"""A connection in a transaction that no statement or idle timeout of the pool ends."""
|
|
120
|
+
async with pool.connection() as connection, connection.transaction():
|
|
121
|
+
for statement in UNBOUNDED:
|
|
122
|
+
await connection.execute(statement)
|
|
123
|
+
yield connection
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def scope_to(org: str) -> None:
|
|
127
|
+
"""Name the org every connection taken from here on in this request acts for."""
|
|
128
|
+
SCOPED_ORG.set(org)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# What a tenant's request reads across orgs on purpose (is this address in another org, does
|
|
132
|
+
# another org hold this number) is read box-wide, in a block that says so.
|
|
133
|
+
@contextmanager
|
|
134
|
+
def box_wide() -> Generator[None]:
|
|
135
|
+
"""Take connections as the box's within the block, whatever the request's org."""
|
|
136
|
+
token = SCOPED_ORG.set(None)
|
|
137
|
+
try:
|
|
138
|
+
yield
|
|
139
|
+
finally:
|
|
140
|
+
SCOPED_ORG.reset(token)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# A task of the process (the log's writer, the relay, a cache's listener) may start inside a
|
|
144
|
+
# tenant's request: it starts box-wide, or it would act for that org for the rest of its life.
|
|
145
|
+
def box_task[T](work: Coroutine[object, object, T]) -> asyncio.Task[T]:
|
|
146
|
+
"""Start the work as a task of the box's, whatever org the request starting it acts for."""
|
|
147
|
+
context = contextvars.copy_context()
|
|
148
|
+
context.run(SCOPED_ORG.set, None)
|
|
149
|
+
return asyncio.create_task(work, context=context)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def connect(dsn: str) -> Connection:
|
|
153
|
+
"""Open one autocommit connection, rows as dicts; the caller closes it."""
|
|
154
|
+
return await psycopg.AsyncConnection[DictRow].connect(
|
|
155
|
+
dsn, autocommit=True, row_factory=dict_row, connect_timeout=CONNECT_TIMEOUT_S
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Extension types and operators (halfvec, <=>, bm25's <@>) live in public, so another schema
|
|
160
|
+
# keeps public on its search path.
|
|
161
|
+
def schemas_of(schema: str) -> tuple[str, ...]:
|
|
162
|
+
"""Return the search path for a schema: the schema, then public."""
|
|
163
|
+
name = check_schema_name(schema)
|
|
164
|
+
return (name,) if name == DEFAULT_SCHEMA else (name, DEFAULT_SCHEMA)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_schema_name(schema: str) -> str:
|
|
168
|
+
"""Return the schema name, raising DeclarationRefused unless it is a plain lowercase word."""
|
|
169
|
+
if not _A_SCHEMA_NAME.match(schema):
|
|
170
|
+
raise DeclarationRefused(f"a schema name is a lowercase word, not {schema!r}")
|
|
171
|
+
return schema
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Every message that names the database goes through this: a DSN carries the password.
|
|
175
|
+
def database_named(dsn: str) -> str:
|
|
176
|
+
"""Return `host/database` from the DSN, with no credentials."""
|
|
177
|
+
parts = conninfo_to_dict(dsn)
|
|
178
|
+
return f"{parts.get('host', 'localhost')}/{parts.get('dbname', '')}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# The memory is dropped before the set and written after it: a set cut between the server's
|
|
182
|
+
# answer and ours (a request cancelled) leaves a connection the server has moved and we have
|
|
183
|
+
# forgotten, which the next checkout asks again, never one we remember wrong.
|
|
184
|
+
async def _scoped(connection: Connection) -> None:
|
|
185
|
+
wanted = SCOPED_ORG.get() or ""
|
|
186
|
+
if _SAYS.get(connection) == wanted:
|
|
187
|
+
return
|
|
188
|
+
_SAYS.pop(connection, None)
|
|
189
|
+
await connection.execute(SCOPE, (wanted,))
|
|
190
|
+
_SAYS[connection] = wanted
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The box's object store, spoken in S3: any endpoint that takes AWS's version 4 signature."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from urllib.parse import quote, urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from pinecall.process.settings import Settings
|
|
13
|
+
|
|
14
|
+
ALGORITHM = "AWS4-HMAC-SHA256"
|
|
15
|
+
|
|
16
|
+
# What S3 is told a request without a body hashes to.
|
|
17
|
+
NO_BODY = hashlib.sha256(b"").hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Signing:
|
|
22
|
+
"""What a request is signed with: the region the store names, and the key's id and secret."""
|
|
23
|
+
|
|
24
|
+
region: str
|
|
25
|
+
access_key_id: str
|
|
26
|
+
secret_access_key: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ObjectStore:
|
|
31
|
+
"""An S3-compatible endpoint and the key the box reads and writes it with."""
|
|
32
|
+
|
|
33
|
+
endpoint: str
|
|
34
|
+
signing: Signing
|
|
35
|
+
http: httpx.AsyncClient
|
|
36
|
+
|
|
37
|
+
# Path-style: the one address every S3-compatible store answers; a bucket as a host name is
|
|
38
|
+
# AWS's and a few others' alone.
|
|
39
|
+
def url_of(self, bucket: str, name: str) -> str:
|
|
40
|
+
"""The object's address."""
|
|
41
|
+
return f"{self.endpoint.rstrip('/')}/{bucket}/{quote(name, safe='/')}"
|
|
42
|
+
|
|
43
|
+
def signed(
|
|
44
|
+
self, method: str, url: str, headers: Mapping[str, str], payload_sha256: str = NO_BODY
|
|
45
|
+
) -> dict[str, str]:
|
|
46
|
+
"""The headers to send, signed now: the ones given, the date, the hash and the proof."""
|
|
47
|
+
return signature(method, url, headers, self.signing, (payload_sha256, datetime.now(UTC)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def object_store_of(settings: Settings, http: httpx.AsyncClient) -> ObjectStore | None:
|
|
51
|
+
"""The store the settings name whole; None when any of its four is missing."""
|
|
52
|
+
endpoint, region = settings.s3_endpoint, settings.s3_region
|
|
53
|
+
key, secret = settings.s3_access_key_id, settings.s3_secret_access_key
|
|
54
|
+
if endpoint is None or region is None or key is None or secret is None:
|
|
55
|
+
return None
|
|
56
|
+
return ObjectStore(endpoint, Signing(region, key, secret), http)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html. The store's
|
|
60
|
+
# requests name no query, so the canonical query is the empty line.
|
|
61
|
+
def signature(
|
|
62
|
+
method: str,
|
|
63
|
+
url: str,
|
|
64
|
+
headers: Mapping[str, str],
|
|
65
|
+
signing: Signing,
|
|
66
|
+
payload: tuple[str, datetime],
|
|
67
|
+
) -> dict[str, str]:
|
|
68
|
+
"""The headers with the date, the payload's hash and the Authorization that signs them all."""
|
|
69
|
+
payload_sha256, now = payload
|
|
70
|
+
stamp = now.strftime("%Y%m%dT%H%M%SZ")
|
|
71
|
+
parts = urlsplit(url)
|
|
72
|
+
signed = {
|
|
73
|
+
**{name.lower(): value.strip() for name, value in headers.items()},
|
|
74
|
+
"host": parts.netloc,
|
|
75
|
+
"x-amz-content-sha256": payload_sha256,
|
|
76
|
+
"x-amz-date": stamp,
|
|
77
|
+
}
|
|
78
|
+
names = sorted(signed)
|
|
79
|
+
canonical = "\n".join(
|
|
80
|
+
(
|
|
81
|
+
method,
|
|
82
|
+
parts.path or "/",
|
|
83
|
+
"",
|
|
84
|
+
"".join(f"{name}:{signed[name]}\n" for name in names),
|
|
85
|
+
";".join(names),
|
|
86
|
+
payload_sha256,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
scope = f"{stamp[:8]}/{signing.region}/s3/aws4_request"
|
|
90
|
+
to_sign = "\n".join((ALGORITHM, stamp, scope, hashlib.sha256(canonical.encode()).hexdigest()))
|
|
91
|
+
key = f"AWS4{signing.secret_access_key}".encode()
|
|
92
|
+
for step in (stamp[:8], signing.region, "s3", "aws4_request"):
|
|
93
|
+
key = hmac.new(key, step.encode(), hashlib.sha256).digest()
|
|
94
|
+
proof = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
|
|
95
|
+
credential = f"Credential={signing.access_key_id}/{scope}"
|
|
96
|
+
authorization = f"{ALGORITHM} {credential}, SignedHeaders={';'.join(names)}, Signature={proof}"
|
|
97
|
+
return {**signed, "authorization": authorization}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The box_settings rows: what the operator configured for this box, one JSON value per name."""
|
|
2
|
+
|
|
3
|
+
from psycopg.types.json import Jsonb
|
|
4
|
+
|
|
5
|
+
from pinecall.domain.names import JsonObject
|
|
6
|
+
from pinecall.postgres.pool import Connection
|
|
7
|
+
|
|
8
|
+
READ = "SELECT value FROM box_settings WHERE name = %(name)s"
|
|
9
|
+
WRITE = """
|
|
10
|
+
INSERT INTO box_settings (name, value) VALUES (%(name)s, %(value)s)
|
|
11
|
+
ON CONFLICT (name) DO UPDATE SET value = excluded.value, set_at = now()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def read(connection: Connection, name: str) -> JsonObject | None:
|
|
16
|
+
"""The row's value, or None when the operator never set it."""
|
|
17
|
+
row = await (await connection.execute(READ, {"name": name})).fetchone()
|
|
18
|
+
return None if row is None else row["value"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def write(connection: Connection, name: str, value: JsonObject) -> None:
|
|
22
|
+
"""Set the row's value, whole."""
|
|
23
|
+
await connection.execute(WRITE, {"name": name, "value": Jsonb(value)})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""What one process opens at start and closes at the end: the database, the vault, HTTP, LiveKit."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator, Mapping
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from cryptography.fernet import Fernet, MultiFernet
|
|
9
|
+
from livekit import api
|
|
10
|
+
|
|
11
|
+
from pinecall.domain.errors import NotAvailable, SettingsRefused
|
|
12
|
+
from pinecall.domain.names import PRODUCTION, SANDBOX, Env
|
|
13
|
+
from pinecall.postgres.pool import Pool, open_pool
|
|
14
|
+
from pinecall.process.settings import Settings
|
|
15
|
+
from pinecall.process.signal import LocalSignal, Signal, opened_signal
|
|
16
|
+
|
|
17
|
+
VARIABLE = "PINECALL_VAULT_KEY"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# The log's writer holds a connection per lane (log/_writer.py LANES), open for the process's
|
|
21
|
+
# life; the doors share the rest of PINECALL_DB_POOL.
|
|
22
|
+
WRITING = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
NO_LIVEKIT = "LIVEKIT_API_KEY and LIVEKIT_API_SECRET are unset: this process cannot reach the SFU"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Connections:
|
|
30
|
+
"""The settings a process was given, and the five things it holds open on them."""
|
|
31
|
+
|
|
32
|
+
settings: Settings
|
|
33
|
+
# The doors'.
|
|
34
|
+
pool: Pool
|
|
35
|
+
# The log writer's own: never waits for a door, and a door never waits for it.
|
|
36
|
+
writing: Pool
|
|
37
|
+
vault: MultiFernet
|
|
38
|
+
# The carriers', Meta's and the identity providers' HTTP, one pool per process.
|
|
39
|
+
http: httpx.AsyncClient
|
|
40
|
+
# Each world's LiveKit server API, one client per LiveKit: both worlds hold the one client
|
|
41
|
+
# when the sandbox has no LiveKit of its own.
|
|
42
|
+
servers: Mapping[Env, api.LiveKitAPI]
|
|
43
|
+
# What the gateways tell each other; a process given no Redis keeps it to itself.
|
|
44
|
+
signal: Signal = field(default_factory=LocalSignal)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
UNSET = (
|
|
48
|
+
f"{VARIABLE} is unset: every vendor key, mailbox and sign-in secret is sealed under it, "
|
|
49
|
+
"so the gateway does not start without it. `Fernet.generate_key()` makes one"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
NOT_A_KEY = (
|
|
54
|
+
f"{VARIABLE} holds something that is not a Fernet key: a comma-separated list of "
|
|
55
|
+
"32-byte url-safe base64 keys, the newest first"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@asynccontextmanager
|
|
60
|
+
async def opened(settings: Settings) -> AsyncGenerator[Connections]:
|
|
61
|
+
"""Open everything, the vault first: a box without its key starts nothing; closed in reverse."""
|
|
62
|
+
sealed = vault_of(settings.vault_key)
|
|
63
|
+
servers = servers_of(settings)
|
|
64
|
+
pool = await open_pool(settings.database_url, max_size=settings.db_pool - WRITING)
|
|
65
|
+
try:
|
|
66
|
+
writing = await open_pool(settings.database_url, max_size=WRITING, min_size=WRITING)
|
|
67
|
+
except BaseException:
|
|
68
|
+
await pool.close()
|
|
69
|
+
raise
|
|
70
|
+
try:
|
|
71
|
+
async with httpx.AsyncClient() as http, opened_signal(settings) as signal:
|
|
72
|
+
yield Connections(
|
|
73
|
+
settings=settings,
|
|
74
|
+
pool=pool,
|
|
75
|
+
writing=writing,
|
|
76
|
+
vault=sealed,
|
|
77
|
+
http=http,
|
|
78
|
+
servers=servers,
|
|
79
|
+
signal=signal,
|
|
80
|
+
)
|
|
81
|
+
finally:
|
|
82
|
+
await closed(servers)
|
|
83
|
+
await writing.close()
|
|
84
|
+
await pool.close()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# livekit-api opens an HTTP session per client: one per LiveKit and process, closed with it. Both
|
|
88
|
+
# LiveKits take the box's one key pair, so a token the gateway signs opens a room on either.
|
|
89
|
+
def servers_of(settings: Settings) -> dict[Env, api.LiveKitAPI]:
|
|
90
|
+
"""Each world's LiveKit server API, on the box's key pair: one client where they share one."""
|
|
91
|
+
if not settings.livekit_api_key or not settings.livekit_api_secret:
|
|
92
|
+
raise NotAvailable(NO_LIVEKIT)
|
|
93
|
+
key, secret = settings.livekit_api_key, settings.livekit_api_secret
|
|
94
|
+
production = api.LiveKitAPI(settings.livekit_url_of(PRODUCTION), key, secret)
|
|
95
|
+
if settings.livekit_url_of(SANDBOX) == settings.livekit_url_of(PRODUCTION):
|
|
96
|
+
return {PRODUCTION: production, SANDBOX: production}
|
|
97
|
+
return {
|
|
98
|
+
PRODUCTION: production,
|
|
99
|
+
SANDBOX: api.LiveKitAPI(settings.livekit_url_of(SANDBOX), key, secret),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def closed(servers: Mapping[Env, api.LiveKitAPI]) -> None:
|
|
104
|
+
"""Close each LiveKit's client once, however many worlds hold it."""
|
|
105
|
+
for server in dict.fromkeys(servers.values()):
|
|
106
|
+
await server.aclose()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def vault_of(keys: str | None) -> MultiFernet:
|
|
110
|
+
"""The vault built from the setting: the first key seals, any key listed opens."""
|
|
111
|
+
return MultiFernet(keyring_of(keys))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def keyring_of(keys: str | None) -> list[Fernet]:
|
|
115
|
+
"""Every key the setting lists, in its order: the first is the one that seals."""
|
|
116
|
+
if keys is None:
|
|
117
|
+
raise SettingsRefused(UNSET)
|
|
118
|
+
listed = [item.strip() for item in keys.split(",") if item.strip()]
|
|
119
|
+
if not listed:
|
|
120
|
+
raise SettingsRefused(NOT_A_KEY)
|
|
121
|
+
try:
|
|
122
|
+
return [Fernet(listed_one) for listed_one in listed]
|
|
123
|
+
except ValueError:
|
|
124
|
+
raise SettingsRefused(NOT_A_KEY) from None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""What a process counts as it works, in memory, and the Prometheus text it is read as."""
|
|
2
|
+
|
|
3
|
+
from bisect import bisect_left
|
|
4
|
+
from collections import Counter, deque
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
# Seconds: an append waits on one Postgres transaction, so the buckets are a transaction's.
|
|
9
|
+
APPEND_BOUNDS_S = (0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0)
|
|
10
|
+
|
|
11
|
+
# A stage's fallbacks are ordered by the last two minutes of each vendor: judged once five calls
|
|
12
|
+
# were handed it as a stage's first, and over the line once half of those calls saw it fail.
|
|
13
|
+
VENDOR_WINDOW_S = 120.0
|
|
14
|
+
CALLS_TO_JUDGE = 5
|
|
15
|
+
FAILED_SHARE = 0.5
|
|
16
|
+
# A busy window is thousands of calls: past this many, a vendor's oldest are forgotten early.
|
|
17
|
+
MOST_KEPT = 10_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
type Rows = Iterable[tuple[Mapping[str, str], float]]
|
|
21
|
+
|
|
22
|
+
type Kind = Literal["gauge", "counter"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Histogram:
|
|
26
|
+
"""Observations counted into fixed buckets, with their sum, as Prometheus reads one."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, bounds: tuple[float, ...]) -> None:
|
|
29
|
+
"""Every bucket empty."""
|
|
30
|
+
self.bounds = bounds
|
|
31
|
+
self.counts = [0] * (len(bounds) + 1)
|
|
32
|
+
self.total = 0.0
|
|
33
|
+
|
|
34
|
+
def observe(self, value: float) -> None:
|
|
35
|
+
"""Count one observation in the first bucket whose bound holds it."""
|
|
36
|
+
self.counts[bisect_left(self.bounds, value)] += 1
|
|
37
|
+
self.total += value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Called on the append path: a bisect and three additions, no lock (one event loop).
|
|
41
|
+
class Counters:
|
|
42
|
+
"""The gateway's counts since it started: appends, their time, and the errors workers wrote."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
"""Nothing counted yet."""
|
|
46
|
+
self.append_seconds = Histogram(APPEND_BOUNDS_S)
|
|
47
|
+
self.appended = 0
|
|
48
|
+
# By the entry's code and the vendor whose plugin failed, "" when none did.
|
|
49
|
+
self.errors: Counter[tuple[str, str]] = Counter()
|
|
50
|
+
# By vendor: when a call was handed it first for a stage, and when a call saw it fail.
|
|
51
|
+
self.handed: dict[str, deque[float]] = {}
|
|
52
|
+
self.failures: dict[str, deque[tuple[float, str]]] = {}
|
|
53
|
+
# By org: how many times its usual day today's spend is, while it is unusual.
|
|
54
|
+
self.unusual: dict[str, float] = {}
|
|
55
|
+
|
|
56
|
+
def appended_in(self, seconds: float, entries: int) -> None:
|
|
57
|
+
"""One append at the door: how long it took and how many entries it wrote."""
|
|
58
|
+
self.append_seconds.observe(seconds)
|
|
59
|
+
self.appended += entries
|
|
60
|
+
|
|
61
|
+
def failed(self, code: str, vendor: str) -> None:
|
|
62
|
+
"""One error entry a worker wrote."""
|
|
63
|
+
self.errors[(code, vendor)] += 1
|
|
64
|
+
|
|
65
|
+
def failed_on(self, vendor: str, call: str, at: float) -> None:
|
|
66
|
+
"""A call saw the vendor fail: an error naming it, or its stage's switch away from it."""
|
|
67
|
+
self.failures.setdefault(vendor, deque(maxlen=MOST_KEPT)).append((at, call))
|
|
68
|
+
|
|
69
|
+
def handed_out(self, vendors: Iterable[str], at: float) -> None:
|
|
70
|
+
"""A call's stages were resolved with these vendors first, each counted once."""
|
|
71
|
+
for vendor in set(vendors):
|
|
72
|
+
self.handed.setdefault(vendor, deque(maxlen=MOST_KEPT)).append(at)
|
|
73
|
+
|
|
74
|
+
def spending(self, org: str, multiple: float | None) -> None:
|
|
75
|
+
"""What the last seal found of the org's spend: unusual by this much, or None, usual."""
|
|
76
|
+
if multiple is None:
|
|
77
|
+
self.unusual.pop(org, None)
|
|
78
|
+
else:
|
|
79
|
+
self.unusual[org] = multiple
|
|
80
|
+
|
|
81
|
+
def failing(self, at: float) -> frozenset[str]:
|
|
82
|
+
"""The vendors over their error line in the window up to now."""
|
|
83
|
+
since = at - VENDOR_WINDOW_S
|
|
84
|
+
for failures in self.failures.values():
|
|
85
|
+
while failures and failures[0][0] < since:
|
|
86
|
+
failures.popleft()
|
|
87
|
+
over: set[str] = set()
|
|
88
|
+
for vendor, handed in self.handed.items():
|
|
89
|
+
while handed and handed[0] < since:
|
|
90
|
+
handed.popleft()
|
|
91
|
+
calls = len({call for _, call in self.failures.get(vendor, ())})
|
|
92
|
+
if len(handed) >= CALLS_TO_JUDGE and calls >= FAILED_SHARE * len(handed):
|
|
93
|
+
over.add(vendor)
|
|
94
|
+
return frozenset(over)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def family(name: str, what: str, kind: Kind, rows: Rows) -> str:
|
|
98
|
+
"""One gauge or counter family, a line per label set, with its help and type lines."""
|
|
99
|
+
lines = [f"# HELP {name} {what}", f"# TYPE {name} {kind}"]
|
|
100
|
+
lines += [f"{name}{_labelled(labels)} {_number(value)}" for labels, value in rows]
|
|
101
|
+
return "\n".join(lines) + "\n"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def histogram(name: str, what: str, observed: Histogram) -> str:
|
|
105
|
+
"""One histogram: its cumulative buckets, the sum and the count."""
|
|
106
|
+
lines = [f"# HELP {name} {what}", f"# TYPE {name} histogram"]
|
|
107
|
+
running = 0
|
|
108
|
+
for bound, count in zip((*observed.bounds, float("inf")), observed.counts, strict=True):
|
|
109
|
+
running += count
|
|
110
|
+
lines.append(f'{name}_bucket{{le="{_number(bound)}"}} {running}')
|
|
111
|
+
lines += [f"{name}_sum {_number(observed.total)}", f"{name}_count {running}"]
|
|
112
|
+
return "\n".join(lines) + "\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# The text format's three escapes in a label value: backslash, quote, newline.
|
|
116
|
+
def _labelled(labels: Mapping[str, str]) -> str:
|
|
117
|
+
if not labels:
|
|
118
|
+
return ""
|
|
119
|
+
pairs = (
|
|
120
|
+
f'{key}="{value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")}"'
|
|
121
|
+
for key, value in labels.items()
|
|
122
|
+
)
|
|
123
|
+
return "{" + ",".join(pairs) + "}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _number(value: float) -> str:
|
|
127
|
+
return "+Inf" if value == float("inf") else repr(float(value))
|