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
pinecall/log/_writer.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""The writer: every log's appends written a group per transaction, fed ones in a lane apart."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal, LiteralString
|
|
9
|
+
|
|
10
|
+
import psycopg
|
|
11
|
+
from psycopg.rows import DictRow
|
|
12
|
+
from psycopg.types.json import Jsonb
|
|
13
|
+
|
|
14
|
+
from pinecall.domain.errors import Conflict
|
|
15
|
+
from pinecall.domain.names import JsonObject
|
|
16
|
+
from pinecall.log.facts import record
|
|
17
|
+
from pinecall.log.reduce import METERED_TYPES
|
|
18
|
+
from pinecall.postgres.pool import Connection, Pool, box_task
|
|
19
|
+
from pinecall.wire.frames import Entry
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# The most entries one group carries.
|
|
24
|
+
MOST_IN_A_GROUP = 500
|
|
25
|
+
|
|
26
|
+
# The types read across every log by position: the usage feed, the callbacks, the codes, and
|
|
27
|
+
# WhatsApp's queue. A position is given at insert, not at commit, so a transaction that writes one
|
|
28
|
+
# of these takes FEED_ORDER first and holds it to its commit: their positions come in commit order,
|
|
29
|
+
# and a reader whose cursor passed one has seen every one before it.
|
|
30
|
+
FED_TYPES = frozenset(
|
|
31
|
+
{
|
|
32
|
+
*METERED_TYPES,
|
|
33
|
+
"code.issued",
|
|
34
|
+
"code.claimed",
|
|
35
|
+
"callback.requested",
|
|
36
|
+
"message.waiting",
|
|
37
|
+
"message.taken",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Outside the 32 bits of hashtext, which keys the runtime's other advisory locks.
|
|
42
|
+
FEED_ORDER_KEY = 0x9E3_C411_FEED
|
|
43
|
+
|
|
44
|
+
FEED_ORDER = "select pg_advisory_xact_lock(%(key)s)"
|
|
45
|
+
|
|
46
|
+
# Locked in the order of their names, so two writers never wait on each other's rows in a circle.
|
|
47
|
+
HEADS_LOCKED = """
|
|
48
|
+
select log, seq, sealed, written, written_seq from call_log_head
|
|
49
|
+
where log = any(%(logs)s)
|
|
50
|
+
order by log
|
|
51
|
+
for update
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# A head at seq 0 for each log that begins in this group, rare beside the ones already there. One
|
|
55
|
+
# another writer made first is locked and read as it is: the update changes nothing of it.
|
|
56
|
+
HEADS_BEGUN = """
|
|
57
|
+
insert into call_log_head as head (log, agent, call)
|
|
58
|
+
select begun.log, begun.agent, begun.call
|
|
59
|
+
from unnest(%(logs)s::text[], %(agents)s::text[], %(calls)s::text[]) as begun(log, agent, call)
|
|
60
|
+
order by begun.log
|
|
61
|
+
on conflict (log) do update set log = head.log
|
|
62
|
+
returning log, seq, sealed, written, written_seq
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# The heads moved and the durable rows written in one statement: two tables, nothing between them
|
|
66
|
+
# to read. The rows travel as one JSON document, read apart by Postgres: six arrays of N were six
|
|
67
|
+
# parameters adapted a value at a time in Python, a fifth of the gateway's core at 100 calls.
|
|
68
|
+
WRITTEN = """
|
|
69
|
+
with moved as (
|
|
70
|
+
update call_log_head as head
|
|
71
|
+
set seq = moved.seq,
|
|
72
|
+
written = moved.written,
|
|
73
|
+
written_seq = moved.written_seq,
|
|
74
|
+
agent = coalesce(head.agent, moved.agent),
|
|
75
|
+
call = coalesce(head.call, moved.call),
|
|
76
|
+
started_at = coalesce(head.started_at, moved.started_at)
|
|
77
|
+
from unnest(%(logs)s::text[], %(seqs)s::bigint[], %(written)s::bigint[],
|
|
78
|
+
%(written_seqs)s::bigint[], %(agents)s::text[], %(heads_calls)s::text[],
|
|
79
|
+
%(heads_stamps)s::float8[])
|
|
80
|
+
as moved(log, seq, written, written_seq, agent, call, started_at)
|
|
81
|
+
where head.log = moved.log
|
|
82
|
+
)
|
|
83
|
+
insert into call_log (call, seq, ts, agent, type, ephemeral, data)
|
|
84
|
+
select kept.call, kept.seq, kept.ts, kept.agent, kept.type, false, kept.data
|
|
85
|
+
from jsonb_to_recordset(%(rows)s::jsonb)
|
|
86
|
+
as kept(call text, seq bigint, ts float8, agent text, type text, data jsonb)
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
# The first-of-its-type requests whose log already holds an entry of that type: read under the
|
|
90
|
+
# heads' locks, so a second writer deciding the same end sees the first one's row.
|
|
91
|
+
HELD_TYPES = """
|
|
92
|
+
select distinct wanted.log
|
|
93
|
+
from unnest(%(logs)s::text[], %(types)s::text[]) as wanted(log, type)
|
|
94
|
+
where exists (select 1 from call_log where log = wanted.log and type = wanted.type)
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
# The answers of a tool call not needed: the log never asked that call id, or already holds its
|
|
98
|
+
# answer (the app's, or the timeout another gateway wrote). Read under the heads' locks, so of two
|
|
99
|
+
# gateways answering one call id the second sees the first's row.
|
|
100
|
+
UNANSWERABLE = """
|
|
101
|
+
select wanted.log
|
|
102
|
+
from unnest(%(logs)s::text[], %(ids)s::text[]) as wanted(log, call_id)
|
|
103
|
+
where not exists (select 1 from call_log where log = wanted.log and type = 'tool.call'
|
|
104
|
+
and data ->> 'call_id' = wanted.call_id)
|
|
105
|
+
or exists (select 1 from call_log where log = wanted.log and type = 'tool.result'
|
|
106
|
+
and data ->> 'call_id' = wanted.call_id)
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
# A request that writes a fed type goes in the fed lane, which alone takes FEED_ORDER; every other
|
|
110
|
+
# goes in the plain lane, which never waits on it.
|
|
111
|
+
type Lane = Literal["fed", "plain"]
|
|
112
|
+
|
|
113
|
+
# The gateway's own entry, a worker's batch, a verdict written again on a sealed log, or a durable
|
|
114
|
+
# entry written only when its log is open and holds none of its type.
|
|
115
|
+
type Kind = Literal["entry", "batch", "score", "first", "answer"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class Unnumbered:
|
|
120
|
+
"""An entry the log has not numbered yet: what it is, whether a store keeps it, and when."""
|
|
121
|
+
|
|
122
|
+
type: str
|
|
123
|
+
data: JsonObject
|
|
124
|
+
ephemeral: bool
|
|
125
|
+
ts: float
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class Batch:
|
|
130
|
+
"""A request's entries as the log numbered them, and whether the log had taken them before."""
|
|
131
|
+
|
|
132
|
+
entries: list[Entry]
|
|
133
|
+
replayed: bool
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class Append:
|
|
138
|
+
"""One request to the writer: its kind, its log, its entries stamped, and a batch's `after`."""
|
|
139
|
+
|
|
140
|
+
kind: Kind
|
|
141
|
+
log: str
|
|
142
|
+
call: str | None
|
|
143
|
+
agent: str
|
|
144
|
+
entries: Sequence[Unnumbered]
|
|
145
|
+
after: int = 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class _Queued:
|
|
150
|
+
"""A request waiting for its group, its lane, and where its answer goes."""
|
|
151
|
+
|
|
152
|
+
append: Append
|
|
153
|
+
lane: Lane
|
|
154
|
+
answer: asyncio.Future[Batch]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True, slots=True)
|
|
158
|
+
class _Moved:
|
|
159
|
+
"""A request the log takes: its head as it will be, and the first seq of its entries."""
|
|
160
|
+
|
|
161
|
+
first: int
|
|
162
|
+
seq: int
|
|
163
|
+
written: int
|
|
164
|
+
written_seq: int | None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Writer:
|
|
168
|
+
"""The appends not yet written, in the order they came, and a task per lane writing them."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, pool: Pool) -> None:
|
|
171
|
+
"""Keep the pool; a lane's task starts with its first request and ends when none is left."""
|
|
172
|
+
self.pool = pool
|
|
173
|
+
self._queue: deque[_Queued] = deque()
|
|
174
|
+
# The logs a lane's transaction writes now: the other lane leaves them until it commits.
|
|
175
|
+
self._writing: set[str] = set()
|
|
176
|
+
self._tasks: dict[Lane, asyncio.Task[None]] = {}
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def waiting(self) -> int:
|
|
180
|
+
"""How many requests wait for a transaction now: what /metrics says a writer is behind."""
|
|
181
|
+
return len(self._queue)
|
|
182
|
+
|
|
183
|
+
async def written(self, append: Append) -> Batch:
|
|
184
|
+
"""Queue the request; its entries numbered, once the transaction that wrote them commits."""
|
|
185
|
+
answer: asyncio.Future[Batch] = asyncio.get_running_loop().create_future()
|
|
186
|
+
self._queue.append(_Queued(append, "fed" if _fed(append) else "plain", answer))
|
|
187
|
+
self._started()
|
|
188
|
+
return await answer
|
|
189
|
+
|
|
190
|
+
async def drained(self) -> None:
|
|
191
|
+
"""Return once every request queued so far is written and answered."""
|
|
192
|
+
while running := [task for task in self._tasks.values() if not task.done()]:
|
|
193
|
+
await asyncio.shield(asyncio.gather(*running))
|
|
194
|
+
|
|
195
|
+
# A lane idle with a request of its own waiting starts: on a request, and whenever a group
|
|
196
|
+
# ends, since the other lane may have been waiting on its logs.
|
|
197
|
+
def _started(self) -> None:
|
|
198
|
+
for lane in LANES:
|
|
199
|
+
task = self._tasks.get(lane)
|
|
200
|
+
idle = task is None or task.done()
|
|
201
|
+
if idle and any(queued.lane == lane for queued in self._queue):
|
|
202
|
+
self._tasks[lane] = box_task(self._lane(lane))
|
|
203
|
+
|
|
204
|
+
# No timer: what arrives while a transaction is out is the next group. A lane ends when it has
|
|
205
|
+
# nothing it may write; the other lane starts it again when it lets a log go.
|
|
206
|
+
async def _lane(self, lane: Lane) -> None:
|
|
207
|
+
while group := self._taken(lane):
|
|
208
|
+
logs = {queued.append.log for queued in group}
|
|
209
|
+
self._writing |= logs
|
|
210
|
+
try:
|
|
211
|
+
await self._settled(group)
|
|
212
|
+
finally:
|
|
213
|
+
self._writing -= logs
|
|
214
|
+
self._started()
|
|
215
|
+
|
|
216
|
+
# A log is in a group once, and never while the other lane writes it or holds an earlier request
|
|
217
|
+
# of it: its later requests, and every one past the group's size, wait in the order they came.
|
|
218
|
+
# A request its caller gave up on is not written.
|
|
219
|
+
def _taken(self, lane: Lane) -> list[_Queued]:
|
|
220
|
+
group: list[_Queued] = []
|
|
221
|
+
later: deque[_Queued] = deque()
|
|
222
|
+
logs = set(self._writing)
|
|
223
|
+
size = 0
|
|
224
|
+
while self._queue:
|
|
225
|
+
queued = self._queue.popleft()
|
|
226
|
+
if queued.answer.done():
|
|
227
|
+
continue
|
|
228
|
+
entries = len(queued.append.entries)
|
|
229
|
+
fits = not group or size + entries <= MOST_IN_A_GROUP
|
|
230
|
+
if queued.lane == lane and queued.append.log not in logs and fits:
|
|
231
|
+
group.append(queued)
|
|
232
|
+
size += entries
|
|
233
|
+
else:
|
|
234
|
+
later.append(queued)
|
|
235
|
+
logs.add(queued.append.log)
|
|
236
|
+
self._queue = later
|
|
237
|
+
return group
|
|
238
|
+
|
|
239
|
+
# A member whose own entries break the transaction (a constraint, bytes Postgres refuses) would
|
|
240
|
+
# take the group down with it, so the group is written again a member at a time and only that
|
|
241
|
+
# one is refused. Anything else (the pool, the network) is every member's answer.
|
|
242
|
+
async def _settled(self, group: list[_Queued]) -> None:
|
|
243
|
+
try:
|
|
244
|
+
outcomes = await _transaction(self.pool, [queued.append for queued in group])
|
|
245
|
+
except (psycopg.errors.IntegrityError, psycopg.errors.DataError):
|
|
246
|
+
logger.warning("a group of %d broke; each is written alone", len(group), exc_info=True)
|
|
247
|
+
for queued in group:
|
|
248
|
+
await self._alone(queued)
|
|
249
|
+
return
|
|
250
|
+
except Exception as broke:
|
|
251
|
+
logger.warning("a group of %d was not written", len(group), exc_info=True)
|
|
252
|
+
for queued in group:
|
|
253
|
+
_answered(queued.answer, broke)
|
|
254
|
+
return
|
|
255
|
+
for queued, outcome in zip(group, outcomes, strict=True):
|
|
256
|
+
_answered(queued.answer, outcome)
|
|
257
|
+
|
|
258
|
+
async def _alone(self, queued: _Queued) -> None:
|
|
259
|
+
try:
|
|
260
|
+
[outcome] = await _transaction(self.pool, [queued.append])
|
|
261
|
+
except Exception as broke:
|
|
262
|
+
logger.warning("an append to %s was refused", queued.append.log, exc_info=True)
|
|
263
|
+
_answered(queued.answer, broke)
|
|
264
|
+
return
|
|
265
|
+
_answered(queued.answer, outcome)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
LANES: tuple[Lane, ...] = ("fed", "plain")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# What a first-of-its-type request is answered with when its log did not need it.
|
|
272
|
+
NOT_NEEDED = Batch(entries=[], replayed=False)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# Every statement covers the whole group: the heads begun, locked, read and moved at once, the
|
|
276
|
+
# rows written at once, the facts folded at once. The answers are known before the commit and
|
|
277
|
+
# handed out only after it.
|
|
278
|
+
async def _transaction(pool: Pool, group: Sequence[Append]) -> list[Batch | Conflict]:
|
|
279
|
+
begun = [
|
|
280
|
+
append
|
|
281
|
+
for append in group
|
|
282
|
+
if append.kind == "entry" or (append.kind == "batch" and append.after == 0)
|
|
283
|
+
]
|
|
284
|
+
async with pool.connection() as connection, connection.transaction():
|
|
285
|
+
if any(_fed(append) for append in group):
|
|
286
|
+
await connection.execute(FEED_ORDER, {"key": FEED_ORDER_KEY})
|
|
287
|
+
heads = await _heads(connection, HEADS_LOCKED, {"logs": [append.log for append in group]})
|
|
288
|
+
missing = [append for append in begun if append.log not in heads]
|
|
289
|
+
if missing:
|
|
290
|
+
heads |= await _heads(connection, HEADS_BEGUN, _begun(missing))
|
|
291
|
+
typed = await _held_types(
|
|
292
|
+
connection, [append for append in group if append.kind == "first"]
|
|
293
|
+
)
|
|
294
|
+
typed |= await _unanswerable(
|
|
295
|
+
connection, [append for append in group if append.kind == "answer"]
|
|
296
|
+
)
|
|
297
|
+
outcomes = [_outcome_of(append, heads.get(append.log), typed) for append in group]
|
|
298
|
+
moved = [
|
|
299
|
+
(append, outcome)
|
|
300
|
+
for append, outcome in zip(group, outcomes, strict=True)
|
|
301
|
+
if isinstance(outcome, _Moved)
|
|
302
|
+
]
|
|
303
|
+
numbered = [_numbered(append, first=outcome.first) for append, outcome in moved]
|
|
304
|
+
entries = [entry for batch in numbered for entry in batch]
|
|
305
|
+
if moved:
|
|
306
|
+
await connection.execute(WRITTEN, {**_heads_moved(moved), **_rows(entries)})
|
|
307
|
+
await record(connection, entries)
|
|
308
|
+
taken = iter(numbered)
|
|
309
|
+
return [
|
|
310
|
+
Batch(entries=next(taken), replayed=False) if isinstance(outcome, _Moved) else outcome
|
|
311
|
+
for outcome in outcomes
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
async def _held_types(connection: Connection, firsts: Sequence[Append]) -> set[str]:
|
|
316
|
+
if not firsts:
|
|
317
|
+
return set()
|
|
318
|
+
wanted = {
|
|
319
|
+
"logs": [append.log for append in firsts],
|
|
320
|
+
"types": [append.entries[0].type for append in firsts],
|
|
321
|
+
}
|
|
322
|
+
rows = await (await connection.execute(HELD_TYPES, wanted)).fetchall()
|
|
323
|
+
return {str(row["log"]) for row in rows}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def _unanswerable(connection: Connection, answers: Sequence[Append]) -> set[str]:
|
|
327
|
+
if not answers:
|
|
328
|
+
return set()
|
|
329
|
+
wanted = {
|
|
330
|
+
"logs": [append.log for append in answers],
|
|
331
|
+
"ids": [str(append.entries[0].data.get("call_id")) for append in answers],
|
|
332
|
+
}
|
|
333
|
+
rows = await (await connection.execute(UNANSWERABLE, wanted)).fetchall()
|
|
334
|
+
return {str(row["log"]) for row in rows}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _fed(append: Append) -> bool:
|
|
338
|
+
return any(not item.ephemeral and item.type in FED_TYPES for item in append.entries)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def _heads(
|
|
342
|
+
connection: Connection, statement: LiteralString, params: Mapping[str, object]
|
|
343
|
+
) -> dict[str, DictRow]:
|
|
344
|
+
rows = await (await connection.execute(statement, params)).fetchall()
|
|
345
|
+
return {str(row["log"]): row for row in rows}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _begun(missing: Sequence[Append]) -> dict[str, object]:
|
|
349
|
+
return {
|
|
350
|
+
"logs": [append.log for append in missing],
|
|
351
|
+
"agents": [append.agent for append in missing],
|
|
352
|
+
"calls": [append.call for append in missing],
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# A first-of-its-type request is not needed on a log never written, sealed, or holding its type;
|
|
357
|
+
# an answer, on one that never asked its call id or holds its answer already.
|
|
358
|
+
def _outcome_of(append: Append, head: DictRow | None, typed: set[str]) -> _Moved | Batch | Conflict:
|
|
359
|
+
guarded = append.kind in {"first", "answer"}
|
|
360
|
+
if guarded and (append.log in typed or head is None or head["sealed"]):
|
|
361
|
+
return NOT_NEEDED
|
|
362
|
+
return _outcome(append, head)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _outcome(append: Append, head: DictRow | None) -> _Moved | Batch | Conflict:
|
|
366
|
+
if head is None and append.kind == "score":
|
|
367
|
+
return Conflict(f"call {append.call} has no log to judge")
|
|
368
|
+
if append.kind == "entry" and (head is None or head["sealed"]):
|
|
369
|
+
kind = append.entries[0].type
|
|
370
|
+
return Conflict(f"call {append.call} has ended: {kind} cannot be appended")
|
|
371
|
+
if head is not None and append.kind != "batch":
|
|
372
|
+
seq = int(head["seq"]) + 1
|
|
373
|
+
return _Moved(
|
|
374
|
+
first=seq, seq=seq, written=int(head["written"]), written_seq=head["written_seq"]
|
|
375
|
+
)
|
|
376
|
+
n = len(append.entries)
|
|
377
|
+
if head is not None and not head["sealed"] and int(head["written"]) == append.after:
|
|
378
|
+
seq = int(head["seq"]) + n
|
|
379
|
+
return _Moved(first=seq - n + 1, seq=seq, written=append.after + n, written_seq=seq)
|
|
380
|
+
first = _replayed_from(append, head)
|
|
381
|
+
if isinstance(first, Conflict):
|
|
382
|
+
return first
|
|
383
|
+
return Batch(entries=_numbered(append, first=first), replayed=True)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# A retry of a batch the log took is answered even after the seal, so the replay is asked first.
|
|
387
|
+
def _replayed_from(append: Append, head: DictRow | None) -> int | Conflict:
|
|
388
|
+
n, after = len(append.entries), append.after
|
|
389
|
+
written = 0 if head is None else int(head["written"])
|
|
390
|
+
last = None if head is None else head["written_seq"]
|
|
391
|
+
if after + n == written and last is not None:
|
|
392
|
+
return int(last) - n + 1
|
|
393
|
+
if head is not None and head["sealed"]:
|
|
394
|
+
return Conflict(f"call {append.call} has ended: its batch cannot be appended")
|
|
395
|
+
return Conflict(
|
|
396
|
+
f"call {append.call}: the log took {written} entries from its worker, "
|
|
397
|
+
f"and the batch says {after} before its {n}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _numbered(append: Append, *, first: int) -> list[Entry]:
|
|
402
|
+
return [
|
|
403
|
+
Entry(
|
|
404
|
+
seq=seq,
|
|
405
|
+
ts=item.ts,
|
|
406
|
+
call=append.call,
|
|
407
|
+
agent=append.agent,
|
|
408
|
+
type=item.type,
|
|
409
|
+
ephemeral=item.ephemeral,
|
|
410
|
+
data=item.data,
|
|
411
|
+
)
|
|
412
|
+
for seq, item in enumerate(append.entries, first)
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# A verdict on a sealed log names nothing of the log: it only takes the next seq.
|
|
417
|
+
def _heads_moved(moved: Sequence[tuple[Append, _Moved]]) -> dict[str, object]:
|
|
418
|
+
return {
|
|
419
|
+
"logs": [append.log for append, _ in moved],
|
|
420
|
+
"seqs": [outcome.seq for _, outcome in moved],
|
|
421
|
+
"written": [outcome.written for _, outcome in moved],
|
|
422
|
+
"written_seqs": [outcome.written_seq for _, outcome in moved],
|
|
423
|
+
"agents": [None if append.kind == "score" else append.agent for append, _ in moved],
|
|
424
|
+
"heads_calls": [None if append.kind == "score" else append.call for append, _ in moved],
|
|
425
|
+
"heads_stamps": [
|
|
426
|
+
None if append.kind == "score" else append.entries[0].ts for append, _ in moved
|
|
427
|
+
],
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _rows(entries: Sequence[Entry]) -> dict[str, object]:
|
|
432
|
+
rows = [
|
|
433
|
+
{
|
|
434
|
+
"call": entry.call,
|
|
435
|
+
"seq": entry.seq,
|
|
436
|
+
"ts": entry.ts,
|
|
437
|
+
"agent": entry.agent,
|
|
438
|
+
"type": entry.type,
|
|
439
|
+
"data": entry.data,
|
|
440
|
+
}
|
|
441
|
+
for entry in entries
|
|
442
|
+
if not entry.ephemeral
|
|
443
|
+
]
|
|
444
|
+
return {"rows": Jsonb(rows)}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# A caller that gave up has a cancelled future: its entries were written all the same.
|
|
448
|
+
def _answered(answer: asyncio.Future[Batch], outcome: Batch | Exception) -> None:
|
|
449
|
+
if answer.done():
|
|
450
|
+
return
|
|
451
|
+
if isinstance(outcome, Exception):
|
|
452
|
+
answer.set_exception(outcome)
|
|
453
|
+
return
|
|
454
|
+
answer.set_result(outcome)
|
pinecall/log/days.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The log's days: a partition of call_log per UTC day, made ahead and dropped once emptied."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
from psycopg import sql
|
|
8
|
+
|
|
9
|
+
from pinecall.postgres.pool import Pool
|
|
10
|
+
|
|
11
|
+
A_DAY_S = 86400
|
|
12
|
+
|
|
13
|
+
# Made a week ahead each night: a box whose nightly run stops has a week before the default fills.
|
|
14
|
+
AHEAD = 7
|
|
15
|
+
|
|
16
|
+
# Doctor speaks up while there are fewer days ahead than this.
|
|
17
|
+
FEWEST_AHEAD = 2
|
|
18
|
+
|
|
19
|
+
# A day is call_log_<YYYYMMDD>; the table as it was before the days is one partition of its own.
|
|
20
|
+
A_DAY = re.compile(r"^call_log_(\d{8})$")
|
|
21
|
+
|
|
22
|
+
BEFORE = "call_log_before_the_days"
|
|
23
|
+
|
|
24
|
+
DEFAULT = "call_log_default"
|
|
25
|
+
|
|
26
|
+
PARTITIONS = """
|
|
27
|
+
SELECT child.relname AS name, pg_get_expr(child.relpartbound, child.oid) AS bound
|
|
28
|
+
FROM pg_inherits link JOIN pg_class child ON child.oid = link.inhrelid
|
|
29
|
+
WHERE link.inhparent = 'call_log'::regclass
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# The upper bound a partition was made with, as pg_get_expr prints it.
|
|
33
|
+
UPPER = re.compile(r"TO \('?(-?[0-9.e+]+)'?\)")
|
|
34
|
+
|
|
35
|
+
# A partition made or dropped waits a second at most for its lock, and tries again the next night.
|
|
36
|
+
BRIEF = "SET LOCAL lock_timeout = '1s'"
|
|
37
|
+
|
|
38
|
+
MADE = "CREATE TABLE {day} PARTITION OF call_log FOR VALUES FROM ({start}) TO ({end})"
|
|
39
|
+
|
|
40
|
+
DEFAULT_HOLDS = "SELECT EXISTS (SELECT 1 FROM {default} WHERE ts >= %(start)s AND ts < %(end)s)"
|
|
41
|
+
|
|
42
|
+
HOLDS = "SELECT EXISTS (SELECT 1 FROM {day}) AS holds"
|
|
43
|
+
|
|
44
|
+
DROPPED = "DROP TABLE {day}"
|
|
45
|
+
|
|
46
|
+
IN_THE_DEFAULT = "SELECT count(*) AS rows FROM {default}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Partition:
|
|
51
|
+
"""A partition of the log: its name, and the time it holds rows up to (None: the default)."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
upto: float | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Kept:
|
|
59
|
+
"""What a night did to the days: made, dropped, refused for rows the default holds of them."""
|
|
60
|
+
|
|
61
|
+
made: list[str]
|
|
62
|
+
dropped: list[str]
|
|
63
|
+
refused: list[str]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def kept(pool: Pool, now: float) -> Kept:
|
|
67
|
+
"""Make the days ahead that are missing and drop the past days nothing is left in."""
|
|
68
|
+
found = await _partitions(pool)
|
|
69
|
+
made, refused = await _made_ahead(pool, found, now)
|
|
70
|
+
return Kept(made=made, dropped=await _dropped(pool, found, now), refused=refused)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def examined(pool: Pool, now: float) -> str | None:
|
|
74
|
+
"""What is wrong with the days: rows the default caught, or too few days made ahead."""
|
|
75
|
+
troubles: list[str] = []
|
|
76
|
+
async with pool.connection() as connection:
|
|
77
|
+
query = sql.SQL(IN_THE_DEFAULT).format(default=sql.Identifier(DEFAULT))
|
|
78
|
+
row = await (await connection.execute(query)).fetchone()
|
|
79
|
+
caught = 0 if row is None else int(row["rows"])
|
|
80
|
+
if caught:
|
|
81
|
+
troubles.append(f"{caught} rows in {DEFAULT}: a day was missing when they were written")
|
|
82
|
+
ahead = sum(
|
|
83
|
+
1 for part in await _partitions(pool) if part.upto is not None and part.upto > _day_of(now)
|
|
84
|
+
)
|
|
85
|
+
if ahead < FEWEST_AHEAD:
|
|
86
|
+
troubles.append(f"{ahead} days of the log made ahead: `retention run` makes {AHEAD}")
|
|
87
|
+
return "; ".join(troubles) or None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _partitions(pool: Pool) -> list[Partition]:
|
|
91
|
+
"""Every partition of the log, the default's end unknown."""
|
|
92
|
+
async with pool.connection() as connection:
|
|
93
|
+
rows = await (await connection.execute(PARTITIONS)).fetchall()
|
|
94
|
+
found: list[Partition] = []
|
|
95
|
+
for row in rows:
|
|
96
|
+
upper = UPPER.search(str(row["bound"]))
|
|
97
|
+
found.append(Partition(str(row["name"]), None if upper is None else float(upper.group(1))))
|
|
98
|
+
return sorted(found, key=lambda partition: (partition.upto is None, partition.upto or 0.0))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _day_named(start: float) -> str:
|
|
102
|
+
"""The name of the day that starts at that time."""
|
|
103
|
+
return f"call_log_{datetime.fromtimestamp(start, UTC):%Y%m%d}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# A day before the old table's bound is its; a day the default holds rows of cannot be made, since
|
|
107
|
+
# making it would move them, and is named instead.
|
|
108
|
+
async def _made_ahead(
|
|
109
|
+
pool: Pool, found: list[Partition], now: float
|
|
110
|
+
) -> tuple[list[str], list[str]]:
|
|
111
|
+
names = {part.name for part in found}
|
|
112
|
+
before = next((part.upto for part in found if part.name == BEFORE), None) or 0.0
|
|
113
|
+
made: list[str] = []
|
|
114
|
+
refused: list[str] = []
|
|
115
|
+
for start in (_day_of(now) + n * A_DAY_S for n in range(AHEAD)):
|
|
116
|
+
name = _day_named(start)
|
|
117
|
+
if name in names or start < before:
|
|
118
|
+
continue
|
|
119
|
+
bounds = {"start": start, "end": start + A_DAY_S}
|
|
120
|
+
async with pool.connection() as connection, connection.transaction():
|
|
121
|
+
await connection.execute(BRIEF)
|
|
122
|
+
holds = sql.SQL(DEFAULT_HOLDS).format(default=sql.Identifier(DEFAULT))
|
|
123
|
+
row = await (await connection.execute(holds, bounds)).fetchone()
|
|
124
|
+
if row is not None and row["exists"]:
|
|
125
|
+
refused.append(name)
|
|
126
|
+
continue
|
|
127
|
+
statement = sql.SQL(MADE).format(
|
|
128
|
+
day=sql.Identifier(name), start=sql.Literal(start), end=sql.Literal(start + A_DAY_S)
|
|
129
|
+
)
|
|
130
|
+
await connection.execute(statement)
|
|
131
|
+
made.append(name)
|
|
132
|
+
return made, refused
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Only a day wholly past and empty goes: its rows went with the calls erasure took, one by one,
|
|
136
|
+
# as each org's days said; a row of a call kept, or of an agent's own log, keeps it.
|
|
137
|
+
async def _dropped(pool: Pool, found: list[Partition], now: float) -> list[str]:
|
|
138
|
+
dropped: list[str] = []
|
|
139
|
+
for part in found:
|
|
140
|
+
if part.upto is None or part.upto > _day_of(now):
|
|
141
|
+
continue
|
|
142
|
+
async with pool.connection() as connection, connection.transaction():
|
|
143
|
+
await connection.execute(BRIEF)
|
|
144
|
+
holds = sql.SQL(HOLDS).format(day=sql.Identifier(part.name))
|
|
145
|
+
row = await (await connection.execute(holds)).fetchone()
|
|
146
|
+
if row is None or row["holds"]:
|
|
147
|
+
continue
|
|
148
|
+
await connection.execute(sql.SQL(DROPPED).format(day=sql.Identifier(part.name)))
|
|
149
|
+
dropped.append(part.name)
|
|
150
|
+
return dropped
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _day_of(now: float) -> float:
|
|
154
|
+
return float(int(now) - int(now) % A_DAY_S)
|