hoja-cli 0.21.4__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.
- VERSION +1 -0
- google_workspace/__init__.py +19 -0
- google_workspace/client.py +253 -0
- google_workspace/config.py +39 -0
- google_workspace/errors.py +19 -0
- google_workspace/mcp_server.py +100 -0
- google_workspace/oauth.py +131 -0
- google_workspace/principal.py +83 -0
- google_workspace/py.typed +0 -0
- google_workspace/tools.py +64 -0
- google_workspace/writes.py +44 -0
- hoja/__init__.py +29 -0
- hoja/__main__.py +6 -0
- hoja/auth.py +42 -0
- hoja/cli.py +161 -0
- hoja/cli_state.py +55 -0
- hoja/client.py +268 -0
- hoja/commands/__init__.py +4 -0
- hoja/commands/auth_cmd.py +271 -0
- hoja/commands/comment.py +149 -0
- hoja/commands/cycle.py +75 -0
- hoja/commands/init.py +108 -0
- hoja/commands/issue.py +398 -0
- hoja/commands/label.py +163 -0
- hoja/commands/memory.py +240 -0
- hoja/commands/project.py +56 -0
- hoja/commands/raw.py +50 -0
- hoja/commands/team.py +102 -0
- hoja/commands/update.py +162 -0
- hoja/config.py +362 -0
- hoja/errors.py +107 -0
- hoja/filters.py +150 -0
- hoja/graphql/__init__.py +5 -0
- hoja/graphql/fragments.py +138 -0
- hoja/graphql/mutations.py +160 -0
- hoja/graphql/queries.py +245 -0
- hoja/mcp/__init__.py +27 -0
- hoja/mcp/content.py +151 -0
- hoja/mcp/prompts.py +298 -0
- hoja/mcp/resources.py +213 -0
- hoja/mcp/server.py +189 -0
- hoja/memory_board.py +370 -0
- hoja/models/__init__.py +37 -0
- hoja/models/base.py +64 -0
- hoja/models/cycle.py +32 -0
- hoja/models/enums.py +62 -0
- hoja/models/issue.py +99 -0
- hoja/models/label.py +17 -0
- hoja/models/project.py +60 -0
- hoja/models/team.py +31 -0
- hoja/models/user.py +32 -0
- hoja/models/workflow.py +65 -0
- hoja/output.py +410 -0
- hoja/skill_content/commands.md +222 -0
- hoja/skill_content/examples.md +193 -0
- hoja/skill_content/filters.md +101 -0
- hoja/skill_content/output-formats.md +92 -0
- hoja/skill_content/overview.md +123 -0
- hoja/skill_content/workflows.md +174 -0
- hoja_cli-0.21.4.dist-info/METADATA +279 -0
- hoja_cli-0.21.4.dist-info/RECORD +107 -0
- hoja_cli-0.21.4.dist-info/WHEEL +4 -0
- hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
- hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
- hoja_server/README.md +135 -0
- hoja_server/__init__.py +11 -0
- hoja_server/abac.py +116 -0
- hoja_server/agent_chat.py +280 -0
- hoja_server/agent_conversations.py +91 -0
- hoja_server/agent_durable.py +332 -0
- hoja_server/agent_prompt.py +117 -0
- hoja_server/agent_threads.py +96 -0
- hoja_server/agent_uploads.py +60 -0
- hoja_server/app.py +2189 -0
- hoja_server/cadence.py +231 -0
- hoja_server/cli.py +191 -0
- hoja_server/cloudflare_realtime.py +110 -0
- hoja_server/comment_policy.py +70 -0
- hoja_server/connector_actions.py +119 -0
- hoja_server/connectors_api.py +375 -0
- hoja_server/db.py +1816 -0
- hoja_server/denials.py +18 -0
- hoja_server/deployment.py +28 -0
- hoja_server/email_adapter.py +94 -0
- hoja_server/google_login.py +217 -0
- hoja_server/google_oidc.py +342 -0
- hoja_server/identity_transfer.py +210 -0
- hoja_server/mcp_oauth.py +623 -0
- hoja_server/mcp_tools.py +579 -0
- hoja_server/meeting_policy.py +179 -0
- hoja_server/meetings.py +1332 -0
- hoja_server/meetings_api.py +909 -0
- hoja_server/model_providers.py +205 -0
- hoja_server/realtime.py +690 -0
- hoja_server/resolvers.py +1281 -0
- hoja_server/schema.graphql +477 -0
- hoja_server/schema_sdl.py +8 -0
- hoja_server/secrets_box.py +39 -0
- hoja_server/signup_config.py +292 -0
- hoja_server/signup_gate.py +393 -0
- hoja_server/signup_policy.py +97 -0
- hoja_server/signup_store.py +577 -0
- hoja_server/store.py +2179 -0
- hoja_server/sync.py +249 -0
- hoja_server/tenant_context.py +131 -0
- hoja_server/turnstile.py +81 -0
- hoja_server/writer.py +1262 -0
hoja_server/realtime.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""Realtime change feed shared by every WebSocket client of one server process.
|
|
2
|
+
|
|
3
|
+
Sources (the ``change_event`` table stays the source of truth for catch-up):
|
|
4
|
+
|
|
5
|
+
- PostgreSQL: the change triggers ``pg_notify('hoja_change', ...)`` at commit, so
|
|
6
|
+
every server process/node LISTENing on the channel wakes on any node's write. A
|
|
7
|
+
slow per-org catch-up poll is the safety net (and the primary path while the
|
|
8
|
+
LISTEN connection is down). Change triggers run deferred at commit under a
|
|
9
|
+
per-org advisory lock, so per-org seqs are commit-ordered and "everything after
|
|
10
|
+
seq N for this org" is gap-free.
|
|
11
|
+
- SQLite: a 100 ms global poll, kicked immediately after a local mutation.
|
|
12
|
+
|
|
13
|
+
Each org has a ring of published batches. A batch carries its events and the
|
|
14
|
+
issue rows they touched (fetched once, unscoped); frames are encoded once per
|
|
15
|
+
(visibility set, protocol) and shared by every socket in that group, so fan-out
|
|
16
|
+
costs one ``send`` per client and no per-client database work.
|
|
17
|
+
|
|
18
|
+
Wire protocol (``/ws``):
|
|
19
|
+
|
|
20
|
+
- v1 (default, legacy): each frame is a JSON array of ChangeEvent objects
|
|
21
|
+
``{seq, entity, entityId, action, teamId}`` sorted by seq (unchanged format).
|
|
22
|
+
- v2 (``/ws?v=2``): each frame is an object::
|
|
23
|
+
|
|
24
|
+
{"v": 2, "prev": P, "seq": S, "events": [ChangeEvent...],
|
|
25
|
+
"columns": [...], "issues": [[row]...], "removed": [id...], "full": bool}
|
|
26
|
+
|
|
27
|
+
Events are coalesced per (entity, entityId), keeping the newest (its action
|
|
28
|
+
is the latest action). Contract: every change visible to this viewer with ``P < seq <= S`` is in
|
|
29
|
+
``events``. When ``full`` is true, ``issues``/``removed`` cover every issue
|
|
30
|
+
event (rows use /sync's columnar format and are visibility-filtered), so a
|
|
31
|
+
store at seq ``s`` with ``P <= s < S`` can apply them and move to ``S`` without
|
|
32
|
+
a /sync/delta round trip; if ``s < P`` it must pull a delta. The first frame
|
|
33
|
+
after connect always arrives (possibly with no events) and carries the head
|
|
34
|
+
seq. ``{"v": 2, "reset": true, "seq": S}`` means "too far behind, pull".
|
|
35
|
+
- Visibility: events of teams the viewer cannot see are never sent; per-user
|
|
36
|
+
events (notifications) carry ``recipientId`` and reach only that recipient.
|
|
37
|
+
- Cursor: ``after=SEQ`` replays visible events after SEQ (bounded); omitting it
|
|
38
|
+
starts at the live head with no replay.
|
|
39
|
+
- Close codes: 4400 bad cursor, 4401 not (or no longer) authenticated, 4408 the
|
|
40
|
+
client could not keep up (reconnect and catch up).
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import asyncio
|
|
45
|
+
import itertools
|
|
46
|
+
import json
|
|
47
|
+
import logging
|
|
48
|
+
import time
|
|
49
|
+
from collections import deque
|
|
50
|
+
from contextlib import suppress
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
|
|
53
|
+
from starlette.websockets import WebSocketDisconnect
|
|
54
|
+
from hoja_server import tenant_context
|
|
55
|
+
|
|
56
|
+
from hoja_server import sync
|
|
57
|
+
|
|
58
|
+
log = logging.getLogger("hoja_server.realtime")
|
|
59
|
+
|
|
60
|
+
NOTIFY_CHANNEL = "hoja_change"
|
|
61
|
+
POLL_INTERVAL = 0.1 # SQLite / PostgreSQL-without-LISTEN
|
|
62
|
+
PG_SAFETY_POLL_INTERVAL = 2.0 # PostgreSQL while LISTEN is healthy
|
|
63
|
+
NOTIFY_COALESCE = 0.002 # gather a burst of notifications into one batch
|
|
64
|
+
RING_BATCHES = 4096
|
|
65
|
+
ROW_PUSH_LIMIT = 200 # issue rows pushed inline per batch; beyond, clients pull
|
|
66
|
+
MAX_REPLAY = 500
|
|
67
|
+
VISIBILITY_TTL = 30.0
|
|
68
|
+
REVALIDATE_SECONDS = 5.0
|
|
69
|
+
SEND_TIMEOUT = 10.0
|
|
70
|
+
FEED_IDLE_SECONDS = 600.0
|
|
71
|
+
# Entities that can change who sees which team.
|
|
72
|
+
VISIBILITY_ENTITIES = {"team", "teamMember", "user", "member"}
|
|
73
|
+
_dumps = json.JSONEncoder(separators=(",", ":")).encode
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def coalesce(events: list[dict]) -> list[dict]:
|
|
77
|
+
"""Keep the newest event per (entity, entityId), in seq order."""
|
|
78
|
+
if len(events) < 2:
|
|
79
|
+
return events
|
|
80
|
+
latest: dict[tuple, dict] = {}
|
|
81
|
+
for e in events:
|
|
82
|
+
latest[(e["entity"], e["entityId"])] = e
|
|
83
|
+
if len(latest) == len(events):
|
|
84
|
+
return events
|
|
85
|
+
return sorted(latest.values(), key=lambda e: e["seq"])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _visible_events(events: list[dict], vis: frozenset | None, uid: str) -> list[dict]:
|
|
89
|
+
"""Team-visible events; per-user events (recipientId) only for their recipient."""
|
|
90
|
+
return [e for e in events
|
|
91
|
+
if (vis is None or e["teamId"] is None or e["teamId"] in vis)
|
|
92
|
+
and e.get("recipientId", uid) == uid]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def issue_payload(events: list[dict], rows: dict | None, vis: frozenset | None) -> tuple[list, list]:
|
|
96
|
+
"""(visible rows, removed ids) for the issue events, given unscoped rows by id."""
|
|
97
|
+
issues, removed = [], []
|
|
98
|
+
for iid in dict.fromkeys(e["entityId"] for e in events if e["entity"] == "issue"):
|
|
99
|
+
row = rows.get(iid) if rows is not None else None
|
|
100
|
+
if row is not None and sync.row_visible(row, vis):
|
|
101
|
+
issues.append(row)
|
|
102
|
+
else:
|
|
103
|
+
removed.append(iid) # deleted, archived, or moved to a team this viewer cannot see
|
|
104
|
+
return issues, removed
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Batch:
|
|
108
|
+
__slots__ = ("entities", "events", "first_seq", "frames", "idx", "issue_ids", "recipients", "rows", "seq")
|
|
109
|
+
|
|
110
|
+
def __init__(self, idx: int, events: list[dict], rows: dict | None) -> None:
|
|
111
|
+
self.idx = idx
|
|
112
|
+
self.events = events
|
|
113
|
+
self.first_seq = events[0]["seq"]
|
|
114
|
+
self.seq = events[-1]["seq"]
|
|
115
|
+
self.rows = rows # issue id -> unscoped row or None (gone); None when not fetched
|
|
116
|
+
self.entities = {e["entity"] for e in events}
|
|
117
|
+
self.issue_ids = {e["entityId"] for e in events if e["entity"] == "issue"}
|
|
118
|
+
self.recipients = {e["recipientId"] for e in events if "recipientId" in e}
|
|
119
|
+
self.frames: dict = {}
|
|
120
|
+
|
|
121
|
+
def body(self, vis: frozenset | None, proto: int, uid: str) -> str | None:
|
|
122
|
+
"""Encoded frame for a visibility group (v2 bodies lack the per-socket prev).
|
|
123
|
+
|
|
124
|
+
Viewers share a frame unless the batch holds a per-user event for them.
|
|
125
|
+
"""
|
|
126
|
+
who = uid if uid in self.recipients else None
|
|
127
|
+
key = (vis, proto, who)
|
|
128
|
+
if key in self.frames:
|
|
129
|
+
return self.frames[key]
|
|
130
|
+
evs = _visible_events(self.events, vis, uid if who else "")
|
|
131
|
+
if not evs:
|
|
132
|
+
out = None
|
|
133
|
+
elif proto == 1:
|
|
134
|
+
out = _dumps(evs) # legacy frames keep every event, exactly as before
|
|
135
|
+
else:
|
|
136
|
+
evs = coalesce(evs)
|
|
137
|
+
issues, removed = issue_payload(evs, self.rows, vis)
|
|
138
|
+
out = _dumps({"seq": self.seq, "events": evs, "columns": sync.ISSUE_COLUMNS,
|
|
139
|
+
"issues": issues, "removed": removed, "full": self.rows is not None})
|
|
140
|
+
self.frames[key] = out
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def v2_frame(prev: int, body: str) -> str:
|
|
145
|
+
return '{"v":2,"prev":' + str(int(prev)) + "," + body[1:]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(eq=False)
|
|
149
|
+
class Conn:
|
|
150
|
+
uid: str
|
|
151
|
+
org: str
|
|
152
|
+
cred: tuple | None
|
|
153
|
+
websocket: object
|
|
154
|
+
closing: int | None = None
|
|
155
|
+
ended: bool = False
|
|
156
|
+
task: asyncio.Task | None = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(eq=False)
|
|
160
|
+
class Feed:
|
|
161
|
+
org: str
|
|
162
|
+
head: int # newest published seq for this org
|
|
163
|
+
covered_from: int # every event with seq > covered_from is in the ring
|
|
164
|
+
ring: deque = field(default_factory=deque)
|
|
165
|
+
next_idx: int = 1
|
|
166
|
+
waiter: asyncio.Future | None = None
|
|
167
|
+
conns: set = field(default_factory=set)
|
|
168
|
+
vis: dict = field(default_factory=dict) # uid -> (monotonic time, frozenset | None)
|
|
169
|
+
vis_gen: int = 0
|
|
170
|
+
vis_task: asyncio.Future | None = None
|
|
171
|
+
meta_seq: int = 0
|
|
172
|
+
last_active: float = field(default_factory=time.monotonic)
|
|
173
|
+
|
|
174
|
+
def wake(self) -> None:
|
|
175
|
+
old, self.waiter = self.waiter, asyncio.get_running_loop().create_future()
|
|
176
|
+
if old is not None and not old.done():
|
|
177
|
+
old.set_result(None)
|
|
178
|
+
|
|
179
|
+
def publish(self, events: list[dict], rows: dict | None) -> Batch:
|
|
180
|
+
batch = Batch(self.next_idx, events, rows)
|
|
181
|
+
self.next_idx += 1
|
|
182
|
+
self.ring.append(batch)
|
|
183
|
+
if len(self.ring) > RING_BATCHES:
|
|
184
|
+
self.covered_from = self.ring.popleft().seq
|
|
185
|
+
self.head = max(self.head, batch.seq)
|
|
186
|
+
self.last_active = time.monotonic()
|
|
187
|
+
if batch.entities & VISIBILITY_ENTITIES:
|
|
188
|
+
self.vis.clear()
|
|
189
|
+
self.vis_gen += 1
|
|
190
|
+
if batch.entities & sync.META_ENTITIES:
|
|
191
|
+
self.meta_seq = batch.seq
|
|
192
|
+
self.wake()
|
|
193
|
+
return batch
|
|
194
|
+
|
|
195
|
+
def batches_after(self, idx: int) -> list[Batch] | None:
|
|
196
|
+
"""Batches newer than idx, or None when some were evicted from the ring."""
|
|
197
|
+
if not self.ring:
|
|
198
|
+
return [] if idx >= self.next_idx - 1 else None
|
|
199
|
+
start = idx + 1 - self.ring[0].idx
|
|
200
|
+
if start < 0:
|
|
201
|
+
return None
|
|
202
|
+
return list(itertools.islice(self.ring, start, None))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class RealtimeHub:
|
|
206
|
+
def __init__(self, store, *, poll_interval: float = POLL_INTERVAL) -> None:
|
|
207
|
+
self.store = store
|
|
208
|
+
self.dialect = store.engine.dialect.name
|
|
209
|
+
self.poll_interval = poll_interval
|
|
210
|
+
self.feeds: dict[str, Feed] = {}
|
|
211
|
+
self.conns: set[Conn] = set()
|
|
212
|
+
self.cursor = 0 # SQLite global poll cursor
|
|
213
|
+
self.listening = False
|
|
214
|
+
self.stats = {"batches": 0, "events": 0, "notifies": 0, "polls": 0, "frames": 0, "row_fetches": 0,
|
|
215
|
+
"snapshot_derived": 0, "snapshot_full": 0}
|
|
216
|
+
self._ingest_lock = asyncio.Lock()
|
|
217
|
+
self._kick = asyncio.Event()
|
|
218
|
+
self._tasks: list[asyncio.Task] = []
|
|
219
|
+
self._pending: list[dict] = []
|
|
220
|
+
self._flush_task: asyncio.Task | None = None
|
|
221
|
+
self._last_poll_error = 0.0
|
|
222
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
223
|
+
self.validate = None # set by the app: creds -> {cred: (uid, org)}
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------ lifecycle
|
|
226
|
+
async def ensure_started(self) -> None:
|
|
227
|
+
"""Start on the running loop (restarting from scratch if the loop changed, as in tests)."""
|
|
228
|
+
loop = asyncio.get_running_loop()
|
|
229
|
+
if self._loop is loop:
|
|
230
|
+
return
|
|
231
|
+
self._loop = loop
|
|
232
|
+
self.feeds, self.conns, self._tasks, self._pending = {}, set(), [], []
|
|
233
|
+
self._ingest_lock, self._kick, self._flush_task = asyncio.Lock(), asyncio.Event(), None
|
|
234
|
+
await self.start()
|
|
235
|
+
|
|
236
|
+
async def start(self) -> None:
|
|
237
|
+
try:
|
|
238
|
+
with tenant_context.system(): # the global head, before any tenant
|
|
239
|
+
self.cursor = await asyncio.to_thread(self.store.max_change_seq)
|
|
240
|
+
except Exception:
|
|
241
|
+
log.warning("realtime feed starting without change_event (schema not migrated?)")
|
|
242
|
+
# The hub serves every organization: its loops are system context, and
|
|
243
|
+
# frames are still filtered per viewer before they are sent.
|
|
244
|
+
sys_task = lambda coro: asyncio.create_task(tenant_context.as_system(coro))
|
|
245
|
+
self._tasks.append(sys_task(self._poll_loop()))
|
|
246
|
+
self._tasks.append(sys_task(self._revalidate_loop()))
|
|
247
|
+
self._tasks.append(sys_task(self._evict_loop()))
|
|
248
|
+
if self.dialect == "postgresql":
|
|
249
|
+
self._tasks.append(sys_task(self._listen_loop()))
|
|
250
|
+
|
|
251
|
+
async def stop(self) -> None:
|
|
252
|
+
for task in self._tasks:
|
|
253
|
+
task.cancel()
|
|
254
|
+
for task in self._tasks:
|
|
255
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
256
|
+
await task
|
|
257
|
+
self._tasks.clear()
|
|
258
|
+
self._loop = None
|
|
259
|
+
|
|
260
|
+
def kick(self) -> None:
|
|
261
|
+
"""A local write committed: poll now instead of at the next tick."""
|
|
262
|
+
self._kick.set()
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------ feeds
|
|
265
|
+
async def feed(self, org: str) -> Feed:
|
|
266
|
+
await self.ensure_started()
|
|
267
|
+
feed = self.feeds.get(org)
|
|
268
|
+
if feed is not None:
|
|
269
|
+
return feed
|
|
270
|
+
latest = await asyncio.to_thread(self.store.latest_change_seq, org)
|
|
271
|
+
feed = self.feeds.get(org)
|
|
272
|
+
if feed is None:
|
|
273
|
+
feed = self.feeds[org] = Feed(org=org, head=latest, covered_from=latest)
|
|
274
|
+
feed.wake()
|
|
275
|
+
return feed
|
|
276
|
+
|
|
277
|
+
async def ingest(self, rows: list[dict]) -> None:
|
|
278
|
+
"""Publish raw events (with organization_id) as one batch per org, in seq order."""
|
|
279
|
+
if not rows:
|
|
280
|
+
return
|
|
281
|
+
async with self._ingest_lock:
|
|
282
|
+
by_org: dict[str, list[dict]] = {}
|
|
283
|
+
for r in rows:
|
|
284
|
+
feed = self.feeds.get(r["organization_id"])
|
|
285
|
+
if feed is not None and r["seq"] <= feed.head:
|
|
286
|
+
continue # already published (per-org seqs are commit-ordered)
|
|
287
|
+
ev = {"seq": r["seq"], "entity": r["entity"], "entityId": r["entityId"],
|
|
288
|
+
"action": r["action"], "teamId": r["teamId"]}
|
|
289
|
+
if r.get("recipientId") is not None:
|
|
290
|
+
ev["recipientId"] = r["recipientId"]
|
|
291
|
+
by_org.setdefault(r["organization_id"], []).append(ev)
|
|
292
|
+
if not by_org:
|
|
293
|
+
return
|
|
294
|
+
for evs in by_org.values():
|
|
295
|
+
evs.sort(key=lambda e: e["seq"])
|
|
296
|
+
wanted = {}
|
|
297
|
+
for org, evs in by_org.items():
|
|
298
|
+
feed = self.feeds.get(org)
|
|
299
|
+
if feed is None or not feed.conns:
|
|
300
|
+
continue # nobody watching: deltas read rows from the database instead
|
|
301
|
+
ids = list(dict.fromkeys(e["entityId"] for e in evs if e["entity"] == "issue"))
|
|
302
|
+
if 0 < len(ids) <= ROW_PUSH_LIMIT:
|
|
303
|
+
wanted[org] = ids
|
|
304
|
+
fetched: dict[str, dict] = {}
|
|
305
|
+
if wanted:
|
|
306
|
+
try:
|
|
307
|
+
fetched = await asyncio.to_thread(self._fetch_rows, wanted)
|
|
308
|
+
self.stats["row_fetches"] += 1
|
|
309
|
+
except Exception:
|
|
310
|
+
log.exception("realtime row fetch failed; frames fall back to invalidation")
|
|
311
|
+
for org, evs in by_org.items():
|
|
312
|
+
feed = self.feeds.get(org)
|
|
313
|
+
if feed is None:
|
|
314
|
+
# First event seen for this org: everything before it is in the database.
|
|
315
|
+
feed = self.feeds[org] = Feed(org=org, head=evs[0]["seq"] - 1,
|
|
316
|
+
covered_from=evs[0]["seq"] - 1)
|
|
317
|
+
feed.wake()
|
|
318
|
+
has_issue = any(e["entity"] == "issue" for e in evs)
|
|
319
|
+
feed.publish(evs, fetched.get(org) if has_issue else {})
|
|
320
|
+
self.stats["batches"] += 1
|
|
321
|
+
self.stats["events"] += len(evs)
|
|
322
|
+
|
|
323
|
+
def _fetch_rows(self, wanted: dict[str, list[str]]) -> dict[str, dict]:
|
|
324
|
+
out = {}
|
|
325
|
+
for org, ids in wanted.items():
|
|
326
|
+
rows = {r[0]: r for r in sync.issues_by_id(self.store, org, None, ids)}
|
|
327
|
+
out[org] = {iid: rows.get(iid) for iid in ids}
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------ sources
|
|
331
|
+
async def _poll_loop(self) -> None:
|
|
332
|
+
while True:
|
|
333
|
+
self._kick.clear()
|
|
334
|
+
try:
|
|
335
|
+
if self.dialect == "postgresql":
|
|
336
|
+
await self._poll_orgs()
|
|
337
|
+
else:
|
|
338
|
+
await self._poll_global()
|
|
339
|
+
except asyncio.CancelledError:
|
|
340
|
+
raise
|
|
341
|
+
except Exception:
|
|
342
|
+
now = time.monotonic()
|
|
343
|
+
if now - self._last_poll_error > 60:
|
|
344
|
+
self._last_poll_error = now
|
|
345
|
+
log.exception("realtime poll failed")
|
|
346
|
+
interval = PG_SAFETY_POLL_INTERVAL if self.listening else self.poll_interval
|
|
347
|
+
with suppress(asyncio.TimeoutError):
|
|
348
|
+
await asyncio.wait_for(self._kick.wait(), timeout=interval)
|
|
349
|
+
|
|
350
|
+
async def _poll_global(self) -> None:
|
|
351
|
+
while True:
|
|
352
|
+
rows = await asyncio.to_thread(self.store.change_events_after_global, self.cursor, 5000)
|
|
353
|
+
self.stats["polls"] += 1
|
|
354
|
+
if not rows:
|
|
355
|
+
return
|
|
356
|
+
self.cursor = max(self.cursor, rows[-1]["seq"])
|
|
357
|
+
await self.ingest(rows)
|
|
358
|
+
if len(rows) < 5000:
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
async def _poll_orgs(self) -> None:
|
|
362
|
+
heads = {org: feed.head for org, feed in self.feeds.items()}
|
|
363
|
+
if not heads:
|
|
364
|
+
return
|
|
365
|
+
rows = await asyncio.to_thread(self.store.change_events_after_orgs, heads)
|
|
366
|
+
self.stats["polls"] += 1
|
|
367
|
+
await self.ingest(rows)
|
|
368
|
+
|
|
369
|
+
def _pg_dsn(self) -> str:
|
|
370
|
+
url = self.store.engine.url.set(drivername="postgresql")
|
|
371
|
+
return url.render_as_string(hide_password=False)
|
|
372
|
+
|
|
373
|
+
async def _listen_loop(self) -> None:
|
|
374
|
+
import psycopg
|
|
375
|
+
|
|
376
|
+
backoff = 0.5
|
|
377
|
+
while True:
|
|
378
|
+
try:
|
|
379
|
+
async with await psycopg.AsyncConnection.connect(self._pg_dsn(), autocommit=True) as conn:
|
|
380
|
+
await conn.execute(f"LISTEN {NOTIFY_CHANNEL}")
|
|
381
|
+
self.listening = True
|
|
382
|
+
backoff = 0.5
|
|
383
|
+
# Catch up anything committed while we were not listening.
|
|
384
|
+
self._kick.set()
|
|
385
|
+
async for note in conn.notifies():
|
|
386
|
+
self._on_notify(note.payload)
|
|
387
|
+
except asyncio.CancelledError:
|
|
388
|
+
self.listening = False
|
|
389
|
+
raise
|
|
390
|
+
except Exception:
|
|
391
|
+
log.warning("realtime LISTEN connection lost; polling until it recovers", exc_info=True)
|
|
392
|
+
self.listening = False
|
|
393
|
+
self._kick.set()
|
|
394
|
+
await asyncio.sleep(backoff)
|
|
395
|
+
backoff = min(10.0, backoff * 2)
|
|
396
|
+
|
|
397
|
+
def _on_notify(self, payload: str) -> None:
|
|
398
|
+
try:
|
|
399
|
+
seq, org, entity, entity_id, action, team_id, *rest = json.loads(payload)
|
|
400
|
+
except (ValueError, TypeError):
|
|
401
|
+
return
|
|
402
|
+
self.stats["notifies"] += 1
|
|
403
|
+
self._pending.append({"seq": int(seq), "organization_id": org, "entity": entity,
|
|
404
|
+
"entityId": entity_id, "action": action, "teamId": team_id,
|
|
405
|
+
"recipientId": rest[0] if rest else None})
|
|
406
|
+
if self._flush_task is None or self._flush_task.done():
|
|
407
|
+
self._flush_task = asyncio.create_task(tenant_context.as_system(self._flush_notifies()))
|
|
408
|
+
|
|
409
|
+
async def _flush_notifies(self) -> None:
|
|
410
|
+
await asyncio.sleep(NOTIFY_COALESCE)
|
|
411
|
+
while self._pending:
|
|
412
|
+
rows, self._pending = self._pending, []
|
|
413
|
+
try:
|
|
414
|
+
await self.ingest(rows)
|
|
415
|
+
except Exception:
|
|
416
|
+
log.exception("realtime ingest failed; the safety poll will retry")
|
|
417
|
+
self._kick.set()
|
|
418
|
+
|
|
419
|
+
# ------------------------------------------------------------------ visibility
|
|
420
|
+
async def visibility(self, feed: Feed, uid: str) -> frozenset | None:
|
|
421
|
+
"""Viewer's visible teams, refreshed for every subscriber of the org in one read."""
|
|
422
|
+
while True:
|
|
423
|
+
now = time.monotonic()
|
|
424
|
+
entry = feed.vis.get(uid)
|
|
425
|
+
if entry is not None and now - entry[0] < VISIBILITY_TTL:
|
|
426
|
+
return entry[1]
|
|
427
|
+
if feed.vis_task is not None:
|
|
428
|
+
with suppress(Exception):
|
|
429
|
+
await asyncio.shield(feed.vis_task)
|
|
430
|
+
continue
|
|
431
|
+
task = feed.vis_task = asyncio.get_running_loop().create_future()
|
|
432
|
+
gen = feed.vis_gen
|
|
433
|
+
uids = {uid} | {c.uid for c in feed.conns
|
|
434
|
+
if (e := feed.vis.get(c.uid)) is None or now - e[0] >= VISIBILITY_TTL / 2}
|
|
435
|
+
try:
|
|
436
|
+
result = await asyncio.to_thread(self.store.visibility_map, feed.org, uids)
|
|
437
|
+
except BaseException as exc:
|
|
438
|
+
feed.vis_task = None
|
|
439
|
+
task.set_exception(exc)
|
|
440
|
+
task.exception()
|
|
441
|
+
raise
|
|
442
|
+
feed.vis_task = None
|
|
443
|
+
task.set_result(None)
|
|
444
|
+
if gen != feed.vis_gen:
|
|
445
|
+
continue # membership changed while we read; read again
|
|
446
|
+
for u, v in result.items():
|
|
447
|
+
feed.vis[u] = (now, v)
|
|
448
|
+
return result[uid]
|
|
449
|
+
|
|
450
|
+
# ------------------------------------------------------------------ revalidation
|
|
451
|
+
async def _revalidate_loop(self) -> None:
|
|
452
|
+
while True:
|
|
453
|
+
await asyncio.sleep(REVALIDATE_SECONDS)
|
|
454
|
+
conns = [c for c in self.conns if c.cred is not None and c.closing is None]
|
|
455
|
+
if not conns or self.validate is None:
|
|
456
|
+
continue
|
|
457
|
+
try:
|
|
458
|
+
valid = await asyncio.to_thread(self.validate, {c.cred for c in conns})
|
|
459
|
+
except Exception:
|
|
460
|
+
log.exception("realtime credential revalidation failed")
|
|
461
|
+
continue
|
|
462
|
+
for c in conns:
|
|
463
|
+
if valid.get(c.cred) != (c.uid, c.org):
|
|
464
|
+
self.close(c, 4401)
|
|
465
|
+
|
|
466
|
+
def close(self, conn: Conn, code: int) -> None:
|
|
467
|
+
conn.closing = code
|
|
468
|
+
if conn.task is not None and not conn.task.done():
|
|
469
|
+
conn.task.cancel()
|
|
470
|
+
|
|
471
|
+
async def _evict_loop(self) -> None:
|
|
472
|
+
while True:
|
|
473
|
+
await asyncio.sleep(60)
|
|
474
|
+
now = time.monotonic()
|
|
475
|
+
for org, feed in list(self.feeds.items()):
|
|
476
|
+
if not feed.conns and now - feed.last_active > FEED_IDLE_SECONDS:
|
|
477
|
+
del self.feeds[org]
|
|
478
|
+
|
|
479
|
+
# ------------------------------------------------------------------ snapshot derivation
|
|
480
|
+
async def wait_head(self, org: str, seq: int, timeout: float = 0.25) -> bool:
|
|
481
|
+
"""Wait (briefly) until this org's feed has published everything up to seq."""
|
|
482
|
+
feed = self.feeds.get(org)
|
|
483
|
+
if feed is None:
|
|
484
|
+
return False
|
|
485
|
+
deadline = time.monotonic() + timeout
|
|
486
|
+
while feed.head < seq:
|
|
487
|
+
left = deadline - time.monotonic()
|
|
488
|
+
if left <= 0 or self.feeds.get(org) is not feed:
|
|
489
|
+
return False
|
|
490
|
+
self.kick()
|
|
491
|
+
with suppress(asyncio.TimeoutError):
|
|
492
|
+
await asyncio.wait_for(asyncio.shield(feed.waiter), timeout=left)
|
|
493
|
+
return True
|
|
494
|
+
|
|
495
|
+
def issue_changes(self, org: str, after: int, upto: int) -> dict | None:
|
|
496
|
+
"""Unscoped rows (None = gone) of issues changed in (after, upto], or None if unknown."""
|
|
497
|
+
feed = self.feeds.get(org)
|
|
498
|
+
if feed is None or after < feed.covered_from or feed.head < upto:
|
|
499
|
+
return None
|
|
500
|
+
changes: dict = {}
|
|
501
|
+
for batch in feed.ring:
|
|
502
|
+
if batch.seq <= after:
|
|
503
|
+
continue
|
|
504
|
+
if batch.first_seq > upto:
|
|
505
|
+
break
|
|
506
|
+
if "workflowState" in batch.entities:
|
|
507
|
+
return None # a state's type change can move many issues in or out of the window
|
|
508
|
+
ids = {e["entityId"] for e in batch.events
|
|
509
|
+
if e["entity"] == "issue" and after < e["seq"] <= upto}
|
|
510
|
+
if ids and batch.rows is None:
|
|
511
|
+
return None
|
|
512
|
+
for i in ids:
|
|
513
|
+
changes[i] = batch.rows.get(i)
|
|
514
|
+
return changes
|
|
515
|
+
|
|
516
|
+
# ------------------------------------------------------------------ delta from memory
|
|
517
|
+
async def delta(self, org: str, uid: str, after: int) -> dict | None:
|
|
518
|
+
"""/sync/delta served from the ring, or None when the database must answer."""
|
|
519
|
+
await self.ensure_started()
|
|
520
|
+
feed = self.feeds.get(org)
|
|
521
|
+
if feed is None or after < feed.covered_from or after > feed.head:
|
|
522
|
+
return None
|
|
523
|
+
# Read-your-writes: a client pulling right after its own mutation must see
|
|
524
|
+
# it even if the broadcast has not ingested it yet (one indexed MAX query).
|
|
525
|
+
if await asyncio.to_thread(self.store.latest_change_seq, org) > feed.head:
|
|
526
|
+
return None
|
|
527
|
+
events: list[dict] = []
|
|
528
|
+
batches: list[Batch] = []
|
|
529
|
+
for batch in reversed(feed.ring):
|
|
530
|
+
if batch.seq <= after:
|
|
531
|
+
break
|
|
532
|
+
batches.append(batch)
|
|
533
|
+
batches.reverse()
|
|
534
|
+
for batch in batches:
|
|
535
|
+
events.extend(e for e in batch.events if e["seq"] > after)
|
|
536
|
+
head = feed.head
|
|
537
|
+
if len(events) > sync.MAX_DELTA_EVENTS:
|
|
538
|
+
return {"v": sync.SYNC_VERSION, "reload": True, "seq": head}
|
|
539
|
+
vis = await self.visibility(feed, uid)
|
|
540
|
+
events = _visible_events(events, vis, uid)
|
|
541
|
+
entities = {e["entity"] for e in events}
|
|
542
|
+
if entities & sync.RELOAD_ENTITIES:
|
|
543
|
+
return {"v": sync.SYNC_VERSION, "reload": True, "seq": head}
|
|
544
|
+
rows: dict = {}
|
|
545
|
+
missing: list[str] = []
|
|
546
|
+
touched = list(dict.fromkeys(e["entityId"] for e in events if e["entity"] == "issue"))
|
|
547
|
+
if touched:
|
|
548
|
+
want = set(touched)
|
|
549
|
+
for batch in reversed(batches): # newest row wins
|
|
550
|
+
if not want:
|
|
551
|
+
break
|
|
552
|
+
for i in batch.issue_ids & want:
|
|
553
|
+
want.discard(i)
|
|
554
|
+
if batch.rows is None:
|
|
555
|
+
missing.append(i)
|
|
556
|
+
else:
|
|
557
|
+
rows[i] = batch.rows.get(i)
|
|
558
|
+
missing.extend(want)
|
|
559
|
+
issues, removed = issue_payload([e for e in events if e["entity"] == "issue"
|
|
560
|
+
and e["entityId"] not in missing], rows, vis)
|
|
561
|
+
if missing:
|
|
562
|
+
fresh = await asyncio.to_thread(sync.issues_by_id, self.store, org, uid, missing)
|
|
563
|
+
present = {r[0] for r in fresh}
|
|
564
|
+
issues.extend(fresh)
|
|
565
|
+
removed.extend(i for i in missing if i not in present)
|
|
566
|
+
return {
|
|
567
|
+
"v": sync.SYNC_VERSION, "reload": False, "seq": head,
|
|
568
|
+
"columns": sync.ISSUE_COLUMNS, "issues": issues, "removed": removed,
|
|
569
|
+
"meta": bool(entities & sync.META_ENTITIES),
|
|
570
|
+
"other": sorted(entities - sync.META_ENTITIES - {"issue"}),
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
# ------------------------------------------------------------------ sockets
|
|
574
|
+
async def serve(self, websocket, uid: str, org: str, cred: tuple | None,
|
|
575
|
+
cursor: int | None, proto: int) -> None:
|
|
576
|
+
feed = await self.feed(org)
|
|
577
|
+
conn = Conn(uid=uid, org=org, cred=cred, websocket=websocket)
|
|
578
|
+
conn.task = asyncio.current_task()
|
|
579
|
+
feed.conns.add(conn)
|
|
580
|
+
self.conns.add(conn)
|
|
581
|
+
reader = asyncio.create_task(self._read_until_closed(websocket, conn))
|
|
582
|
+
try:
|
|
583
|
+
await self._pump(feed, conn, cursor, proto)
|
|
584
|
+
except asyncio.CancelledError:
|
|
585
|
+
if conn.closing is None and not conn.ended:
|
|
586
|
+
raise
|
|
587
|
+
except asyncio.TimeoutError:
|
|
588
|
+
conn.closing = 4408 # could not keep up; it reconnects and catches up from its cursor
|
|
589
|
+
except (RuntimeError, OSError, ConnectionError, WebSocketDisconnect):
|
|
590
|
+
pass # client went away mid-send
|
|
591
|
+
finally:
|
|
592
|
+
reader.cancel()
|
|
593
|
+
feed.conns.discard(conn)
|
|
594
|
+
self.conns.discard(conn)
|
|
595
|
+
if conn.closing is not None and not conn.ended:
|
|
596
|
+
with suppress(Exception):
|
|
597
|
+
await asyncio.wait_for(websocket.close(code=conn.closing), timeout=1.0)
|
|
598
|
+
|
|
599
|
+
async def _read_until_closed(self, websocket, conn: Conn) -> None:
|
|
600
|
+
with suppress(asyncio.CancelledError):
|
|
601
|
+
try:
|
|
602
|
+
while True:
|
|
603
|
+
message = await websocket.receive()
|
|
604
|
+
if message["type"] == "websocket.disconnect":
|
|
605
|
+
break
|
|
606
|
+
except (WebSocketDisconnect, RuntimeError):
|
|
607
|
+
pass
|
|
608
|
+
conn.ended = True
|
|
609
|
+
if conn.task is not None and not conn.task.done():
|
|
610
|
+
conn.task.cancel()
|
|
611
|
+
|
|
612
|
+
async def _send(self, websocket, text: str) -> None:
|
|
613
|
+
await asyncio.wait_for(websocket.send_text(text), timeout=SEND_TIMEOUT)
|
|
614
|
+
self.stats["frames"] += 1
|
|
615
|
+
|
|
616
|
+
async def _backlog(self, feed: Feed, conn: Conn, cursor: int, proto: int) -> tuple[str | None, int]:
|
|
617
|
+
"""Replay frame for events after cursor, and the max seq it covers."""
|
|
618
|
+
latest = feed.head
|
|
619
|
+
start = cursor
|
|
620
|
+
if proto == 1 and latest - cursor > MAX_REPLAY:
|
|
621
|
+
start = max(0, latest - MAX_REPLAY) # legacy clients refetch on any event anyway
|
|
622
|
+
events = await asyncio.to_thread(self.store.change_events_after, conn.org, start,
|
|
623
|
+
MAX_REPLAY + 1, conn.uid)
|
|
624
|
+
if len(events) > MAX_REPLAY:
|
|
625
|
+
if proto == 1:
|
|
626
|
+
events = events[-MAX_REPLAY:]
|
|
627
|
+
else:
|
|
628
|
+
head = max(feed.head, events[-1]["seq"])
|
|
629
|
+
return _dumps({"v": 2, "reset": True, "seq": head}), head
|
|
630
|
+
top = max([feed.head] + [e["seq"] for e in events])
|
|
631
|
+
if proto == 1:
|
|
632
|
+
return (_dumps(events) if events else None), top
|
|
633
|
+
evs = coalesce(events)
|
|
634
|
+
issues, removed, full = [], [], True
|
|
635
|
+
ids = list(dict.fromkeys(e["entityId"] for e in evs if e["entity"] == "issue"))
|
|
636
|
+
if len(ids) > ROW_PUSH_LIMIT:
|
|
637
|
+
full = False
|
|
638
|
+
elif ids:
|
|
639
|
+
fresh = await asyncio.to_thread(sync.issues_by_id, self.store, conn.org, conn.uid, ids)
|
|
640
|
+
present = {r[0] for r in fresh}
|
|
641
|
+
issues, removed = fresh, [i for i in ids if i not in present]
|
|
642
|
+
body = _dumps({"seq": top, "events": evs, "columns": sync.ISSUE_COLUMNS,
|
|
643
|
+
"issues": issues, "removed": removed, "full": full})
|
|
644
|
+
return v2_frame(cursor, body), top
|
|
645
|
+
|
|
646
|
+
async def _pump(self, feed: Feed, conn: Conn, cursor: int | None, proto: int) -> None:
|
|
647
|
+
ws = conn.websocket
|
|
648
|
+
pos = feed.next_idx - 1
|
|
649
|
+
if cursor is None:
|
|
650
|
+
# Start at the database head, not the feed's: events committed but not
|
|
651
|
+
# yet broadcast are history this client's snapshot already covers.
|
|
652
|
+
sent = max(feed.head, await asyncio.to_thread(self.store.latest_change_seq, conn.org))
|
|
653
|
+
if proto == 2:
|
|
654
|
+
await self._send(ws, _dumps({"v": 2, "prev": sent, "seq": sent, "events": [],
|
|
655
|
+
"columns": sync.ISSUE_COLUMNS, "issues": [],
|
|
656
|
+
"removed": [], "full": True}))
|
|
657
|
+
else:
|
|
658
|
+
frame, sent = await self._backlog(feed, conn, cursor, proto)
|
|
659
|
+
if frame is not None:
|
|
660
|
+
await self._send(ws, frame)
|
|
661
|
+
prev = sent
|
|
662
|
+
while True:
|
|
663
|
+
waiter = feed.waiter
|
|
664
|
+
batches = feed.batches_after(pos)
|
|
665
|
+
if batches is None:
|
|
666
|
+
# Fell off the ring (very slow client): catch up from the database.
|
|
667
|
+
pos = feed.next_idx - 1
|
|
668
|
+
frame, top = await self._backlog(feed, conn, prev, proto)
|
|
669
|
+
if frame is not None:
|
|
670
|
+
await self._send(ws, frame)
|
|
671
|
+
prev = sent = max(sent, top)
|
|
672
|
+
continue
|
|
673
|
+
if batches:
|
|
674
|
+
vis = await self.visibility(feed, conn.uid)
|
|
675
|
+
for batch in batches:
|
|
676
|
+
pos = batch.idx
|
|
677
|
+
if batch.seq <= sent:
|
|
678
|
+
continue
|
|
679
|
+
if batch.first_seq <= sent:
|
|
680
|
+
# Overlaps the replayed backlog: build an unshared frame without the dupes.
|
|
681
|
+
part = Batch(batch.idx, [e for e in batch.events if e["seq"] > sent], batch.rows)
|
|
682
|
+
body = part.body(vis, proto, conn.uid)
|
|
683
|
+
else:
|
|
684
|
+
body = batch.body(vis, proto, conn.uid)
|
|
685
|
+
if body is not None:
|
|
686
|
+
await self._send(ws, body if proto == 1 else v2_frame(prev, body))
|
|
687
|
+
prev = batch.seq
|
|
688
|
+
sent = max(sent, batch.seq)
|
|
689
|
+
continue
|
|
690
|
+
await asyncio.shield(waiter)
|