bazaar-contrib-server-computer 0.3.0rc1__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.
- bazaar_contrib_server_computer/__init__.py +187 -0
- bazaar_contrib_server_computer/calls.py +37 -0
- bazaar_contrib_server_computer/contracts.py +98 -0
- bazaar_contrib_server_computer/hosts.py +96 -0
- bazaar_contrib_server_computer/memory.py +122 -0
- bazaar_contrib_server_computer/pages.py +393 -0
- bazaar_contrib_server_computer/py.typed +0 -0
- bazaar_contrib_server_computer/resources/locales/en.toml +65 -0
- bazaar_contrib_server_computer/resources/locales/zh-CN.toml +65 -0
- bazaar_contrib_server_computer/resources/static/computers.css +187 -0
- bazaar_contrib_server_computer/resources/templates/computers/detail.html +45 -0
- bazaar_contrib_server_computer/resources/templates/computers/enrol.html +54 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/check.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/chevron-right.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/copy.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/cpu.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/ellipsis.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/hard-drive.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/key-round.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/memory-stick.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/monitor.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/os-apple.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/os-linux.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/os-windows.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/pencil.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/plus.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/share-2.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/terminal.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/trash-2.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/icons/x.svg +1 -0
- bazaar_contrib_server_computer/resources/templates/computers/list.html +12 -0
- bazaar_contrib_server_computer/resources/templates/computers/macros.html +32 -0
- bazaar_contrib_server_computer/resources/templates/computers/remove.html +13 -0
- bazaar_contrib_server_computer/resources/templates/computers/rows.html +37 -0
- bazaar_contrib_server_computer/resources/templates/computers/share.html +8 -0
- bazaar_contrib_server_computer/resources/templates/computers/stall.html +24 -0
- bazaar_contrib_server_computer/resources/templates/computers/table.html +14 -0
- bazaar_contrib_server_computer/schema.py +27 -0
- bazaar_contrib_server_computer/starlink.py +169 -0
- bazaar_contrib_server_computer/store.py +209 -0
- bazaar_contrib_server_computer-0.3.0rc1.dist-info/METADATA +40 -0
- bazaar_contrib_server_computer-0.3.0rc1.dist-info/RECORD +45 -0
- bazaar_contrib_server_computer-0.3.0rc1.dist-info/WHEEL +4 -0
- bazaar_contrib_server_computer-0.3.0rc1.dist-info/entry_points.txt +4 -0
- bazaar_contrib_server_computer-0.3.0rc1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""The computers of the Bazaar Compute server: enrolling them, where their
|
|
2
|
+
starlink connects, calling what they expose, and their pages; and the relay
|
|
3
|
+
between the server's replicas, kept in memory for a server with one."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
lazy import asyncio
|
|
9
|
+
lazy from collections.abc import AsyncIterator
|
|
10
|
+
lazy from importlib.resources import files
|
|
11
|
+
lazy from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from bazaar_contrib_http import HTTP
|
|
14
|
+
from bazaar_contrib_server import SERVER, PermissionItem, SidebarItem, Topic
|
|
15
|
+
from bazaar_contrib_server_access import ACCESS, Shared
|
|
16
|
+
from bazaar_contrib_server_database import SERVER_DB, SERVER_SCHEMA
|
|
17
|
+
from bazaar_contrib_server_migrations import SERVER_MIGRATIONS
|
|
18
|
+
from bazaar_contrib_server_overview import OVERVIEW, OverviewItem
|
|
19
|
+
from bazaar_contrib_timer import TIMER
|
|
20
|
+
from bazaar_core import Context, Contrib
|
|
21
|
+
|
|
22
|
+
from .contracts import (
|
|
23
|
+
ANSWER_MS,
|
|
24
|
+
CALLS_AT_MOST,
|
|
25
|
+
COMPUTERS,
|
|
26
|
+
HOLD_MS,
|
|
27
|
+
RELAY,
|
|
28
|
+
REPORTED,
|
|
29
|
+
Call,
|
|
30
|
+
Computers,
|
|
31
|
+
Relay,
|
|
32
|
+
Reported,
|
|
33
|
+
)
|
|
34
|
+
lazy from .calls import Calls
|
|
35
|
+
lazy from .hosts import Hosts
|
|
36
|
+
lazy from .memory import MemoryRelay
|
|
37
|
+
lazy from .pages import CREATE, DELETE, SHARE, UPDATE, VIEW, Views
|
|
38
|
+
lazy from .schema import MODULE
|
|
39
|
+
lazy from .starlink import Starlink
|
|
40
|
+
lazy from .store import Store
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"ANSWER_MS",
|
|
44
|
+
"CALLS_AT_MOST",
|
|
45
|
+
"COMPUTERS",
|
|
46
|
+
"HOLD_MS",
|
|
47
|
+
"RELAY",
|
|
48
|
+
"REPORTED",
|
|
49
|
+
"Call",
|
|
50
|
+
"Computers",
|
|
51
|
+
"Relay",
|
|
52
|
+
"Reported",
|
|
53
|
+
"computer",
|
|
54
|
+
"relay",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@asynccontextmanager
|
|
59
|
+
async def _computer(ctx: Context) -> AsyncIterator[None]:
|
|
60
|
+
server = ctx.accept(SERVER)
|
|
61
|
+
access = ctx.accept(ACCESS)
|
|
62
|
+
http = ctx.accept(HTTP)
|
|
63
|
+
relay = ctx.accept(RELAY)
|
|
64
|
+
resources = Path(str(files(__name__))) / "resources"
|
|
65
|
+
monitor = await asyncio.to_thread(
|
|
66
|
+
(resources / "templates" / "computers" / "icons" / "monitor.svg").read_text,
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
)
|
|
69
|
+
async with (
|
|
70
|
+
ctx.accept(SERVER_SCHEMA).register(MODULE),
|
|
71
|
+
asyncio.TaskGroup() as tasks,
|
|
72
|
+
):
|
|
73
|
+
calls = Calls(relay)
|
|
74
|
+
hosts = Hosts(calls, tasks)
|
|
75
|
+
store = Store(ctx.accept(SERVER_DB), access)
|
|
76
|
+
starlink = Starlink(
|
|
77
|
+
store, relay, hosts, lambda reported: ctx.announce(REPORTED, reported)
|
|
78
|
+
)
|
|
79
|
+
pages = await server.pages("computers", __name__)
|
|
80
|
+
views = Views(server, pages, access, store, relay, hosts)
|
|
81
|
+
async with (
|
|
82
|
+
server.module(
|
|
83
|
+
"computers",
|
|
84
|
+
ui=pages.application(views.routes),
|
|
85
|
+
static=resources / "static",
|
|
86
|
+
locales=resources / "locales",
|
|
87
|
+
),
|
|
88
|
+
server.permission(
|
|
89
|
+
PermissionItem(point=VIEW, description="computers.permission.view")
|
|
90
|
+
),
|
|
91
|
+
server.permission(
|
|
92
|
+
PermissionItem(point=CREATE, description="computers.permission.create")
|
|
93
|
+
),
|
|
94
|
+
server.permission(
|
|
95
|
+
PermissionItem(point=UPDATE, description="computers.permission.update")
|
|
96
|
+
),
|
|
97
|
+
server.permission(
|
|
98
|
+
PermissionItem(point=DELETE, description="computers.permission.delete")
|
|
99
|
+
),
|
|
100
|
+
server.permission(
|
|
101
|
+
PermissionItem(point=SHARE, description="computers.permission.share")
|
|
102
|
+
),
|
|
103
|
+
server.sidebar(
|
|
104
|
+
SidebarItem(
|
|
105
|
+
text="computers.title",
|
|
106
|
+
icon=monitor,
|
|
107
|
+
path="/computers",
|
|
108
|
+
group="server.sidebar.workbench",
|
|
109
|
+
permission=VIEW,
|
|
110
|
+
order=30,
|
|
111
|
+
badge="computers.count",
|
|
112
|
+
)
|
|
113
|
+
),
|
|
114
|
+
server.topic(
|
|
115
|
+
Topic(name="computers.count", fetch=views.counted, permission=VIEW)
|
|
116
|
+
),
|
|
117
|
+
server.topic(
|
|
118
|
+
Topic(name="computers.list", fetch=views.listed, permission=VIEW)
|
|
119
|
+
),
|
|
120
|
+
server.topic(
|
|
121
|
+
Topic(
|
|
122
|
+
name="computers.computer",
|
|
123
|
+
fetch=views.computer,
|
|
124
|
+
fields=("computer",),
|
|
125
|
+
permission=VIEW,
|
|
126
|
+
)
|
|
127
|
+
),
|
|
128
|
+
access.shared(
|
|
129
|
+
Shared(
|
|
130
|
+
kind="computer",
|
|
131
|
+
permission=SHARE,
|
|
132
|
+
points=(VIEW, UPDATE, DELETE),
|
|
133
|
+
name=views.name,
|
|
134
|
+
)
|
|
135
|
+
),
|
|
136
|
+
ctx.accept(OVERVIEW).register(
|
|
137
|
+
OverviewItem(
|
|
138
|
+
key="computers",
|
|
139
|
+
title="computers.stalls",
|
|
140
|
+
src="/ui/computers/stall",
|
|
141
|
+
permission=VIEW,
|
|
142
|
+
columns=2,
|
|
143
|
+
)
|
|
144
|
+
),
|
|
145
|
+
http.route("/starlink/v1/listen", starlink.listen, methods=["GET"]),
|
|
146
|
+
http.route(
|
|
147
|
+
"/starlink/v1/announce/{number:int}",
|
|
148
|
+
starlink.announce,
|
|
149
|
+
methods=["PUT"],
|
|
150
|
+
),
|
|
151
|
+
http.route("/starlink/v1/report", starlink.report, methods=["POST"]),
|
|
152
|
+
):
|
|
153
|
+
ctx.offer(COMPUTERS, calls)
|
|
154
|
+
try:
|
|
155
|
+
yield
|
|
156
|
+
finally:
|
|
157
|
+
# held listens and questions out end before the routes wait
|
|
158
|
+
# for their requests
|
|
159
|
+
starlink.stop()
|
|
160
|
+
hosts.close()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@asynccontextmanager
|
|
164
|
+
async def _relay(ctx: Context) -> AsyncIterator[None]:
|
|
165
|
+
relay = MemoryRelay(ctx.accept(TIMER))
|
|
166
|
+
ctx.offer(RELAY, relay)
|
|
167
|
+
try:
|
|
168
|
+
yield
|
|
169
|
+
finally:
|
|
170
|
+
await relay.close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
computer = Contrib(
|
|
174
|
+
accepts=[
|
|
175
|
+
SERVER,
|
|
176
|
+
ACCESS,
|
|
177
|
+
HTTP,
|
|
178
|
+
RELAY,
|
|
179
|
+
OVERVIEW,
|
|
180
|
+
SERVER_DB,
|
|
181
|
+
SERVER_SCHEMA,
|
|
182
|
+
SERVER_MIGRATIONS,
|
|
183
|
+
],
|
|
184
|
+
offers=[COMPUTERS],
|
|
185
|
+
lifespan=_computer,
|
|
186
|
+
)
|
|
187
|
+
relay = Contrib(offers=[RELAY], accepts=[TIMER], lifespan=_relay)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""COMPUTERS: calling what a computer exposes, through the relay to wherever
|
|
2
|
+
it listens."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
lazy from bazaar_contrib_sidecar import RemoteError
|
|
7
|
+
lazy from bazaar_contrib_sidecar.wire import Failure, Request
|
|
8
|
+
lazy from pydantic import JsonValue
|
|
9
|
+
lazy from pydantic_core import to_jsonable_python
|
|
10
|
+
|
|
11
|
+
lazy from .contracts import Relay
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Calls:
|
|
15
|
+
def __init__(self, relay: Relay) -> None:
|
|
16
|
+
self._relay = relay
|
|
17
|
+
|
|
18
|
+
async def call(
|
|
19
|
+
self,
|
|
20
|
+
computer_id: str,
|
|
21
|
+
service: str,
|
|
22
|
+
method: str,
|
|
23
|
+
*args: object,
|
|
24
|
+
**kwargs: object,
|
|
25
|
+
) -> JsonValue:
|
|
26
|
+
response = await self._relay.ask(
|
|
27
|
+
computer_id,
|
|
28
|
+
Request(
|
|
29
|
+
service=service,
|
|
30
|
+
method=method,
|
|
31
|
+
args=to_jsonable_python(args),
|
|
32
|
+
kwargs=to_jsonable_python(kwargs),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
if isinstance(response, Failure):
|
|
36
|
+
raise RemoteError(response.type, response.message)
|
|
37
|
+
return response.value
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""What other modules hold server-computer to, and what a relay between the
|
|
2
|
+
server's replicas holds to."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
lazy from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from bazaar_core import EventKey, ServiceKey
|
|
11
|
+
lazy from bazaar_contrib_audit import AuditEvent
|
|
12
|
+
lazy from bazaar_contrib_sidecar.wire import Failure, Request, Result
|
|
13
|
+
lazy from pydantic import JsonValue
|
|
14
|
+
|
|
15
|
+
# how long a computer's listen is held when nothing is waiting for it
|
|
16
|
+
HOLD_MS = 25_000
|
|
17
|
+
# how many calls one listen hands over at most
|
|
18
|
+
CALLS_AT_MOST = 20
|
|
19
|
+
# how long a call waits for its answer before the computer counts as silent
|
|
20
|
+
ANSWER_MS = 60_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Call:
|
|
25
|
+
"""A call waiting for its computer: its number there, and what it asks."""
|
|
26
|
+
|
|
27
|
+
number: int
|
|
28
|
+
request: Request
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Relay(Protocol):
|
|
32
|
+
"""What the server's replicas share about computers: the calls waiting
|
|
33
|
+
for them, the answers coming back, and when each was last here. A
|
|
34
|
+
computer listens on any replica and a page calls it from any other; the
|
|
35
|
+
relay keeps only what lives while it does."""
|
|
36
|
+
|
|
37
|
+
async def ask(self, computer_id: str, request: Request) -> Result | Failure:
|
|
38
|
+
"""Hand the call to the computer and wait for its answer, for
|
|
39
|
+
ANSWER_MS at most; the same call still waiting is joined rather than
|
|
40
|
+
handed over again. TimeoutError when no answer comes."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
async def calls(self, computer_id: str, after: int) -> Sequence[Call]:
|
|
44
|
+
"""The calls for the computer numbered after the given one, oldest
|
|
45
|
+
first and CALLS_AT_MOST of them at most; when there are none, the
|
|
46
|
+
first to come within HOLD_MS, or nothing."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
async def answer(
|
|
50
|
+
self, computer_id: str, number: int, response: Result | Failure
|
|
51
|
+
) -> None:
|
|
52
|
+
"""The computer's answer to its call; one given again counts once."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
async def seen(self, computer_id: str) -> int | None:
|
|
56
|
+
"""Note that the computer is here now; when it was here before,
|
|
57
|
+
nothing when not since the relay started."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
async def last_seen(self, computer_ids: Sequence[str]) -> dict[str, int]:
|
|
61
|
+
"""When each of the computers was last here, those seen since the
|
|
62
|
+
relay started."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
RELAY = ServiceKey[Relay]("server.relay")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Computers(Protocol):
|
|
70
|
+
"""What other modules do with the computers."""
|
|
71
|
+
|
|
72
|
+
async def call(
|
|
73
|
+
self,
|
|
74
|
+
computer_id: str,
|
|
75
|
+
service: str,
|
|
76
|
+
method: str,
|
|
77
|
+
*args: object,
|
|
78
|
+
**kwargs: object,
|
|
79
|
+
) -> JsonValue:
|
|
80
|
+
"""Call a member the computer exposes, as sidecar's Client does:
|
|
81
|
+
RemoteError when it fails there, TimeoutError when the computer does
|
|
82
|
+
not answer in time."""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
COMPUTERS = ServiceKey[Computers]("server.computers")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class Reported:
|
|
91
|
+
"""An audit event a computer reported, announced once however often the
|
|
92
|
+
computer sends it again."""
|
|
93
|
+
|
|
94
|
+
computer_id: str
|
|
95
|
+
event: AuditEvent
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
REPORTED = EventKey[Reported]("server.computer.reported")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""What each computer's node says about the machine it runs on: asked when
|
|
2
|
+
the computer comes online, and again once a minute old while a page shows
|
|
3
|
+
it."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
lazy import asyncio
|
|
8
|
+
lazy import logging
|
|
9
|
+
lazy from time import time_ns
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ValidationError
|
|
12
|
+
lazy from bazaar_contrib_sidecar import RemoteError
|
|
13
|
+
|
|
14
|
+
from .contracts import Computers
|
|
15
|
+
|
|
16
|
+
# what was said is shown for a minute before the node is asked again
|
|
17
|
+
FRESH_MS = 60_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Usage(BaseModel):
|
|
21
|
+
total: int
|
|
22
|
+
used: int
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def percent(self) -> int:
|
|
26
|
+
return round(self.used * 100 / self.total) if self.total else 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Disk(Usage):
|
|
30
|
+
mount: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Processor(BaseModel):
|
|
34
|
+
cores: int | None
|
|
35
|
+
percent: float
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Host(BaseModel):
|
|
39
|
+
"""node.info as a node answers it."""
|
|
40
|
+
|
|
41
|
+
version: str
|
|
42
|
+
system: str
|
|
43
|
+
name: str
|
|
44
|
+
release: str
|
|
45
|
+
machine: str
|
|
46
|
+
started_ms: int
|
|
47
|
+
cpu: Processor
|
|
48
|
+
memory: Usage
|
|
49
|
+
disks: list[Disk]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def fullest(self) -> Disk | None:
|
|
53
|
+
return max(self.disks, key=lambda disk: disk.percent, default=None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Hosts:
|
|
57
|
+
def __init__(self, computers: Computers, tasks: asyncio.TaskGroup) -> None:
|
|
58
|
+
self._computers = computers
|
|
59
|
+
self._tasks = tasks
|
|
60
|
+
self._known: dict[str, tuple[int, Host]] = {}
|
|
61
|
+
self._asking: dict[str, asyncio.Task[None]] = {}
|
|
62
|
+
|
|
63
|
+
def ask(self, computer_id: str) -> None:
|
|
64
|
+
"""Ask the computer about its machine again, unless that is under way."""
|
|
65
|
+
if computer_id not in self._asking:
|
|
66
|
+
self._asking[computer_id] = self._tasks.create_task(self._ask(computer_id))
|
|
67
|
+
|
|
68
|
+
async def _ask(self, computer_id: str) -> None:
|
|
69
|
+
try:
|
|
70
|
+
value = await self._computers.call(computer_id, "node", "info")
|
|
71
|
+
self._known[computer_id] = (
|
|
72
|
+
time_ns() // 1_000_000,
|
|
73
|
+
Host.model_validate(value),
|
|
74
|
+
)
|
|
75
|
+
except RemoteError, TimeoutError, ValidationError:
|
|
76
|
+
# a node that does not answer, or answers otherwise, keeps what
|
|
77
|
+
# was said before
|
|
78
|
+
logging.getLogger(__name__).info("computer.info.unanswered", exc_info=True)
|
|
79
|
+
finally:
|
|
80
|
+
del self._asking[computer_id]
|
|
81
|
+
|
|
82
|
+
def of(self, computer_id: str, online: bool) -> tuple[int, Host] | None:
|
|
83
|
+
"""When the computer last said what about its machine; one online
|
|
84
|
+
is asked again once that is a minute old."""
|
|
85
|
+
known = self._known.get(computer_id)
|
|
86
|
+
if online and (known is None or time_ns() // 1_000_000 - known[0] >= FRESH_MS):
|
|
87
|
+
self.ask(computer_id)
|
|
88
|
+
return known
|
|
89
|
+
|
|
90
|
+
def forget(self, computer_id: str) -> None:
|
|
91
|
+
self._known.pop(computer_id, None)
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
"""Stop asking: the server is going down."""
|
|
95
|
+
for task in self._asking.values():
|
|
96
|
+
task.cancel()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""server-relay-memory: the relay of a server with one replica, kept in its
|
|
2
|
+
own memory, as 0.2's controls were."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
lazy import asyncio
|
|
9
|
+
lazy import json
|
|
10
|
+
lazy from collections.abc import Sequence
|
|
11
|
+
lazy from time import time_ns
|
|
12
|
+
|
|
13
|
+
lazy from bazaar_contrib_sidecar.wire import Failure, Request, Result
|
|
14
|
+
lazy from bazaar_contrib_timer import Timer, timeout
|
|
15
|
+
|
|
16
|
+
from .contracts import ANSWER_MS, CALLS_AT_MOST, HOLD_MS, Call
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class _Asked:
|
|
21
|
+
computer_id: str
|
|
22
|
+
call: Call
|
|
23
|
+
answer: asyncio.Future[Result | Failure]
|
|
24
|
+
# how many askers wait on the answer; none, and the call is forgotten
|
|
25
|
+
waiters: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MemoryRelay:
|
|
29
|
+
def __init__(self, timer: Timer) -> None:
|
|
30
|
+
self._timer = timer
|
|
31
|
+
# what each computer has been asked and not yet answered, by number
|
|
32
|
+
self._asked: defaultdict[str, dict[int, _Asked]] = defaultdict(dict)
|
|
33
|
+
# the same call to the same computer, while one is out, is asked once
|
|
34
|
+
self._out: dict[tuple[str, str], _Asked] = {}
|
|
35
|
+
# a listen waits here until its computer has something
|
|
36
|
+
self._arrivals: defaultdict[str, asyncio.Condition] = defaultdict(
|
|
37
|
+
asyncio.Condition
|
|
38
|
+
)
|
|
39
|
+
self._seen: dict[str, int] = {}
|
|
40
|
+
# numbers climb with the clock, so where a computer got to before the
|
|
41
|
+
# server restarted is behind what comes after
|
|
42
|
+
self._last = 0
|
|
43
|
+
self._closing = False
|
|
44
|
+
|
|
45
|
+
async def ask(self, computer_id: str, request: Request) -> Result | Failure:
|
|
46
|
+
key = (
|
|
47
|
+
computer_id,
|
|
48
|
+
json.dumps(request.model_dump(mode="json"), sort_keys=True),
|
|
49
|
+
)
|
|
50
|
+
asked = self._out.get(key)
|
|
51
|
+
fresh = asked is None
|
|
52
|
+
if asked is None:
|
|
53
|
+
number = self._last = max(self._last + 1, time_ns() // 1_000_000)
|
|
54
|
+
asked = _Asked(
|
|
55
|
+
computer_id,
|
|
56
|
+
Call(number, request),
|
|
57
|
+
asyncio.get_running_loop().create_future(),
|
|
58
|
+
)
|
|
59
|
+
self._out[key] = asked
|
|
60
|
+
self._asked[computer_id][number] = asked
|
|
61
|
+
asked.answer.add_done_callback(lambda _: self._forget(key, asked))
|
|
62
|
+
asked.waiters += 1
|
|
63
|
+
try:
|
|
64
|
+
if fresh:
|
|
65
|
+
arrivals = self._arrivals[computer_id]
|
|
66
|
+
async with arrivals:
|
|
67
|
+
arrivals.notify_all()
|
|
68
|
+
async with timeout(self._timer, ANSWER_MS):
|
|
69
|
+
# one asker giving up does not take the answer from the others
|
|
70
|
+
return await asyncio.shield(asked.answer)
|
|
71
|
+
finally:
|
|
72
|
+
asked.waiters -= 1
|
|
73
|
+
if asked.waiters == 0 and not asked.answer.done():
|
|
74
|
+
self._forget(key, asked)
|
|
75
|
+
|
|
76
|
+
def _forget(self, key: tuple[str, str], asked: _Asked) -> None:
|
|
77
|
+
if self._out.get(key) is asked:
|
|
78
|
+
del self._out[key]
|
|
79
|
+
self._asked[asked.computer_id].pop(asked.call.number, None)
|
|
80
|
+
|
|
81
|
+
async def calls(self, computer_id: str, after: int) -> Sequence[Call]:
|
|
82
|
+
arrivals = self._arrivals[computer_id]
|
|
83
|
+
async with arrivals:
|
|
84
|
+
if not self._closing and not self._pending(computer_id, after):
|
|
85
|
+
try:
|
|
86
|
+
async with timeout(self._timer, HOLD_MS):
|
|
87
|
+
await arrivals.wait()
|
|
88
|
+
except TimeoutError:
|
|
89
|
+
return []
|
|
90
|
+
return [] if self._closing else self._pending(computer_id, after)
|
|
91
|
+
|
|
92
|
+
def _pending(self, computer_id: str, after: int) -> list[Call]:
|
|
93
|
+
asked = self._asked[computer_id]
|
|
94
|
+
return [asked[number].call for number in sorted(asked) if number > after][
|
|
95
|
+
:CALLS_AT_MOST
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
async def answer(
|
|
99
|
+
self, computer_id: str, number: int, response: Result | Failure
|
|
100
|
+
) -> None:
|
|
101
|
+
asked = self._asked[computer_id].get(number)
|
|
102
|
+
if asked is not None and not asked.answer.done():
|
|
103
|
+
asked.answer.set_result(response)
|
|
104
|
+
|
|
105
|
+
async def seen(self, computer_id: str) -> int | None:
|
|
106
|
+
before = self._seen.get(computer_id)
|
|
107
|
+
self._seen[computer_id] = time_ns() // 1_000_000
|
|
108
|
+
return before
|
|
109
|
+
|
|
110
|
+
async def last_seen(self, computer_ids: Sequence[str]) -> dict[str, int]:
|
|
111
|
+
return {
|
|
112
|
+
computer_id: self._seen[computer_id]
|
|
113
|
+
for computer_id in computer_ids
|
|
114
|
+
if computer_id in self._seen
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async def close(self) -> None:
|
|
118
|
+
"""Let every held listen go: the relay is going away."""
|
|
119
|
+
self._closing = True
|
|
120
|
+
for arrivals in list(self._arrivals.values()):
|
|
121
|
+
async with arrivals:
|
|
122
|
+
arrivals.notify_all()
|