leasehold 0.2.0__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.
- leasehold/__init__.py +18 -0
- leasehold/__main__.py +8 -0
- leasehold/access.py +43 -0
- leasehold/admission.py +373 -0
- leasehold/agent.py +570 -0
- leasehold/api.py +129 -0
- leasehold/busy.py +62 -0
- leasehold/claude_code.py +218 -0
- leasehold/cli/__init__.py +151 -0
- leasehold/cli/admin.py +55 -0
- leasehold/cli/agents.py +45 -0
- leasehold/cli/daemon.py +169 -0
- leasehold/cli/demo.py +64 -0
- leasehold/cli/hooks.py +190 -0
- leasehold/cli/leases.py +170 -0
- leasehold/cli/push.py +42 -0
- leasehold/cli/run.py +57 -0
- leasehold/cli/setup.py +30 -0
- leasehold/cli/views.py +214 -0
- leasehold/cli/watch.py +49 -0
- leasehold/client.py +86 -0
- leasehold/commands.py +99 -0
- leasehold/contain.py +279 -0
- leasehold/demo.py +228 -0
- leasehold/discovery.py +125 -0
- leasehold/doctor.py +314 -0
- leasehold/events.py +139 -0
- leasehold/exits.py +16 -0
- leasehold/facts.py +139 -0
- leasehold/guard.py +88 -0
- leasehold/handoff.py +190 -0
- leasehold/hold.py +451 -0
- leasehold/hooknotes.py +111 -0
- leasehold/jobs.py +117 -0
- leasehold/kinds.py +17 -0
- leasehold/ledger.py +833 -0
- leasehold/lend.py +101 -0
- leasehold/load.py +131 -0
- leasehold/machine.py +453 -0
- leasehold/mcp_server.py +748 -0
- leasehold/metrics.py +91 -0
- leasehold/model.py +79 -0
- leasehold/observe.py +174 -0
- leasehold/paths.py +120 -0
- leasehold/process.py +64 -0
- leasehold/proctable.py +84 -0
- leasehold/programs.py +55 -0
- leasehold/py.typed +0 -0
- leasehold/quota.py +44 -0
- leasehold/report.py +190 -0
- leasehold/server.py +190 -0
- leasehold/session.py +131 -0
- leasehold/setup.py +196 -0
- leasehold/shell.py +312 -0
- leasehold/shims.py +197 -0
- leasehold/static/ui.html +1135 -0
- leasehold/status.py +152 -0
- leasehold/template.py +98 -0
- leasehold/templates/machine.toml +88 -0
- leasehold/templates/user.toml +60 -0
- leasehold/terminal.py +87 -0
- leasehold/top.py +1374 -0
- leasehold/units.py +33 -0
- leasehold/webui.py +159 -0
- leasehold-0.2.0.dist-info/METADATA +163 -0
- leasehold-0.2.0.dist-info/RECORD +69 -0
- leasehold-0.2.0.dist-info/WHEEL +4 -0
- leasehold-0.2.0.dist-info/entry_points.txt +2 -0
- leasehold-0.2.0.dist-info/licenses/LICENSE +202 -0
leasehold/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""leasehold: fair, load-aware admission for one shared Linux machine.
|
|
2
|
+
|
|
3
|
+
The library:
|
|
4
|
+
|
|
5
|
+
* `model`: pools, the machine's slots and how they are split (`PoolSet`);
|
|
6
|
+
* `admission`: the pure decision of what fits now (reservations, priority, turns between users
|
|
7
|
+
and between each user's sessions, FIFO per session, no backfill);
|
|
8
|
+
* `ledger`: the machine-wide SQLite ledger of leases, the queue and recent submits that applies
|
|
9
|
+
it, its holders fenced by `process` identity or by a TTL;
|
|
10
|
+
* `quota`: the submit-time per-user queued limit and create rate;
|
|
11
|
+
* `client`: a client for the machine agent's socket API.
|
|
12
|
+
|
|
13
|
+
The machine agent (`leasehold serve`) is built from `machine`, `agent`, `api` and `server`.
|
|
14
|
+
Only the standard library and pydantic.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
#: The one place the version is written; the build reads it from here (`[tool.hatch.version]`).
|
|
18
|
+
__version__ = "0.2.0"
|
leasehold/__main__.py
ADDED
leasehold/access.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Who may do what on the machine agent's socket.
|
|
2
|
+
|
|
3
|
+
Anyone who can open the socket may take, renew and give back their own leases. Switching lending
|
|
4
|
+
and changing pool limits change what everyone else gets, so only an administrator may: root, the
|
|
5
|
+
user the daemon runs as, or a member of the config's `admin_group`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import grp
|
|
11
|
+
import os
|
|
12
|
+
import pwd
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Forbidden(PermissionError):
|
|
17
|
+
"""A request the asking user may not make."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def in_group(user: str, group: str) -> bool:
|
|
21
|
+
"""Whether `user` is in `group`, as the system's name service says (files, SSSD, LDAP):
|
|
22
|
+
its primary group or a supplementary one. An unknown user or group is never."""
|
|
23
|
+
try:
|
|
24
|
+
gid = grp.getgrnam(group).gr_gid
|
|
25
|
+
return gid in os.getgrouplist(user, pwd.getpwnam(user).pw_gid)
|
|
26
|
+
except (KeyError, OSError):
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def may_administer(
|
|
31
|
+
uid: int | None,
|
|
32
|
+
user: str | None,
|
|
33
|
+
*,
|
|
34
|
+
admin_group: str | None,
|
|
35
|
+
daemon_uid: int | None = None,
|
|
36
|
+
member: Callable[[str, str], bool] = in_group,
|
|
37
|
+
) -> bool:
|
|
38
|
+
"""Whether the peer (`uid`, `user`) is an administrator. An unknown peer never is."""
|
|
39
|
+
if uid is None:
|
|
40
|
+
return False
|
|
41
|
+
if uid == 0 or uid == (os.getuid() if daemon_uid is None else daemon_uid):
|
|
42
|
+
return True
|
|
43
|
+
return admin_group is not None and user is not None and member(user, admin_group)
|
leasehold/admission.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Which waiting items the pools can admit now: a pure function of what is held, what waits and
|
|
2
|
+
the policy (`decide`). The ledger (`leasehold.ledger`) calls it inside its one transaction and
|
|
3
|
+
turns the answer into leases.
|
|
4
|
+
|
|
5
|
+
The rules, per host of `C` slots (a machine's pools all share one), with `U_q` the weight pool
|
|
6
|
+
`q` holds and `R_q` its reservation:
|
|
7
|
+
|
|
8
|
+
* an item of weight `w` enters pool `p` only if `U_p + w <= max_p` and
|
|
9
|
+
`C - sum(U) - w >= sum over the host's other pools q of max(0, R_q - U_q)`: a reservation is
|
|
10
|
+
never lent to another pool, and nothing is ever preempted to make room;
|
|
11
|
+
* pools are served in priority order (lower first, then by name);
|
|
12
|
+
* inside a pool, users take turns: the next item is the head of the user with the fewest items
|
|
13
|
+
running in the pool, then the one served longest ago (`State.served`, which the ledger keeps,
|
|
14
|
+
so the rotation survives across calls and one-slot pools), then the oldest head. A user with
|
|
15
|
+
one queued item is admitted within one rotation however many another has queued;
|
|
16
|
+
* inside a user's turn, that user's **sessions** take turns by the same three keys
|
|
17
|
+
(`State.session_served`). A session is whatever the client names one (an agent session, a
|
|
18
|
+
terminal); items without one share one session per user. Splitting work into more sessions
|
|
19
|
+
only divides that user's own turns, never takes another user's. One session's own items stay
|
|
20
|
+
FIFO;
|
|
21
|
+
* a user at the pool's `per_user_max_running` is skipped until one of theirs ends;
|
|
22
|
+
* a `closed` pool admits nothing, and its waiting items keep their places; a `capped` pool
|
|
23
|
+
admits only up to its reservation, as if `max_p` were `R_p`;
|
|
24
|
+
* the item whose turn it is blocks its pool when it does not fit: nothing behind it -- its own
|
|
25
|
+
user's or another's -- is admitted around it (no backfill), so a heavy item is never starved
|
|
26
|
+
by lighter ones. Another pool is not blocked by it: the reservations are what protect a pool.
|
|
27
|
+
|
|
28
|
+
`AdmissionPolicy.standing` is weight that long-lived work outside the ledger already occupies
|
|
29
|
+
(a machine's `[[standing]]` entries). `decide` counts it as used before the held leases, and
|
|
30
|
+
never as running, so per-user limits and whose turn it is stay a matter of real leases only.
|
|
31
|
+
|
|
32
|
+
Weight held beyond a pool's `max` or a host's slots is allowed (`leasehold.ledger.Ledger.impose`
|
|
33
|
+
records work that was never asked for, such as an observed process): `fits` then fails for that
|
|
34
|
+
pool and host until enough of it ends.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections.abc import Iterable, Mapping
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
|
|
42
|
+
from leasehold.model import MACHINE, PoolSet
|
|
43
|
+
|
|
44
|
+
#: An item on the machine: (scope, item id), the scope being whatever keeps two clients' item
|
|
45
|
+
#: ids apart (a repository path, say, or `leasehold` for the daemon's own leases).
|
|
46
|
+
ItemKey = tuple[str, str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PoolPolicy:
|
|
51
|
+
host: str
|
|
52
|
+
reserved: int
|
|
53
|
+
max: int
|
|
54
|
+
priority: int = 0
|
|
55
|
+
per_user_max_running: int | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class AdmissionPolicy:
|
|
60
|
+
"""What `decide` reads: each host's slots and each pool's shape."""
|
|
61
|
+
|
|
62
|
+
slots: Mapping[str, int]
|
|
63
|
+
pools: Mapping[str, PoolPolicy]
|
|
64
|
+
#: Pools that admit nothing now (their items keep their place in line): how a machine agent
|
|
65
|
+
#: holds its lend pool shut while the machine is not idle. Empty for a plain client.
|
|
66
|
+
closed: frozenset[str] = frozenset()
|
|
67
|
+
#: Pool name to weight that work outside the ledger already holds. Counted as used before
|
|
68
|
+
#: any lease; never a running count.
|
|
69
|
+
standing: Mapping[str, int] = field(default_factory=dict, hash=False)
|
|
70
|
+
#: Pools that admit only up to their `reserved` now, whatever their `max` (items past it
|
|
71
|
+
#: keep their place in line): how a machine agent holds back new work while the machine is
|
|
72
|
+
#: under memory pressure. Empty for a plain client.
|
|
73
|
+
capped: frozenset[str] = frozenset()
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_pools(
|
|
77
|
+
cls,
|
|
78
|
+
pool_set: PoolSet,
|
|
79
|
+
*,
|
|
80
|
+
closed: frozenset[str] = frozenset(),
|
|
81
|
+
standing: Mapping[str, int] | None = None,
|
|
82
|
+
capped: frozenset[str] = frozenset(),
|
|
83
|
+
) -> AdmissionPolicy:
|
|
84
|
+
"""The policy for one machine's `PoolSet`, every pool on host `MACHINE`."""
|
|
85
|
+
return cls(
|
|
86
|
+
slots={MACHINE: pool_set.slots},
|
|
87
|
+
pools={
|
|
88
|
+
name: PoolPolicy(
|
|
89
|
+
host=MACHINE,
|
|
90
|
+
reserved=p.reserved,
|
|
91
|
+
max=p.max,
|
|
92
|
+
priority=p.priority,
|
|
93
|
+
per_user_max_running=p.per_user_max_running,
|
|
94
|
+
)
|
|
95
|
+
for name, p in pool_set.pools.items()
|
|
96
|
+
},
|
|
97
|
+
closed=closed,
|
|
98
|
+
standing=dict(standing or {}),
|
|
99
|
+
capped=capped,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def refusal(self, pool: str, weight: int) -> str | None:
|
|
103
|
+
"""Why an item of `weight` could never be admitted to `pool`, or None if it could."""
|
|
104
|
+
policy = self.pools.get(pool)
|
|
105
|
+
if policy is None:
|
|
106
|
+
return f"there is no pool {pool!r}"
|
|
107
|
+
slots = self.slots.get(policy.host, 0)
|
|
108
|
+
if weight > policy.max:
|
|
109
|
+
return f"weight {weight} is more than pool {pool!r}'s max {policy.max}"
|
|
110
|
+
if weight > slots:
|
|
111
|
+
return f"weight {weight} is more than the {slots} slots there are"
|
|
112
|
+
if self.standing and not fits(_Tally.of(State(), self), self, pool, weight):
|
|
113
|
+
held = sum(self.standing.values())
|
|
114
|
+
return (
|
|
115
|
+
f"weight {weight} cannot fit pool {pool!r} even on an idle machine: "
|
|
116
|
+
f"{held} slots are held by standing work"
|
|
117
|
+
)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Held:
|
|
123
|
+
pool: str
|
|
124
|
+
host: str
|
|
125
|
+
user: str | None
|
|
126
|
+
weight: int
|
|
127
|
+
session: str | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class Waiting:
|
|
132
|
+
key: ItemKey
|
|
133
|
+
pool: str
|
|
134
|
+
user: str | None
|
|
135
|
+
weight: int
|
|
136
|
+
#: The item's place in its session's FIFO: when it was queued, then the ledger's sequence.
|
|
137
|
+
order: tuple[float, int]
|
|
138
|
+
session: str | None = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
#: (pool, user): who has had a turn in which pool.
|
|
142
|
+
Turn = tuple[str, str | None]
|
|
143
|
+
#: (pool, user, session): which of a user's sessions has had a turn in which pool.
|
|
144
|
+
SessionTurn = tuple[str, str | None, str | None]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class State:
|
|
149
|
+
held: tuple[Held, ...] = ()
|
|
150
|
+
waiting: tuple[Waiting, ...] = ()
|
|
151
|
+
#: When each user last had an item admitted in each pool, as an increasing sequence number;
|
|
152
|
+
#: a user never served there is absent (served first).
|
|
153
|
+
served: Mapping[Turn, int] = field(default_factory=dict)
|
|
154
|
+
#: The same, per session of a user.
|
|
155
|
+
session_served: Mapping[SessionTurn, int] = field(default_factory=dict)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class _Tally:
|
|
160
|
+
used: dict[str, int] = field(default_factory=dict) # pool -> weight held
|
|
161
|
+
host_used: dict[str, int] = field(default_factory=dict) # host -> weight held
|
|
162
|
+
running: dict[Turn, int] = field(default_factory=dict)
|
|
163
|
+
session_running: dict[SessionTurn, int] = field(default_factory=dict)
|
|
164
|
+
served: dict[Turn, int] = field(default_factory=dict)
|
|
165
|
+
session_served: dict[SessionTurn, int] = field(default_factory=dict)
|
|
166
|
+
last_turn: int = 0
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def of(cls, state: State, policy: AdmissionPolicy | None = None) -> _Tally:
|
|
170
|
+
"""`state`'s leases, after any standing weight (which has no user and runs nothing)."""
|
|
171
|
+
tally = cls(
|
|
172
|
+
served=dict(state.served),
|
|
173
|
+
session_served=dict(state.session_served),
|
|
174
|
+
last_turn=max([*state.served.values(), *state.session_served.values()], default=0),
|
|
175
|
+
)
|
|
176
|
+
if policy is not None:
|
|
177
|
+
for pool, weight in policy.standing.items():
|
|
178
|
+
shaped = policy.pools.get(pool)
|
|
179
|
+
if shaped is None or weight < 1:
|
|
180
|
+
continue
|
|
181
|
+
tally.used[pool] = tally.used.get(pool, 0) + weight
|
|
182
|
+
tally.host_used[shaped.host] = tally.host_used.get(shaped.host, 0) + weight
|
|
183
|
+
for h in state.held:
|
|
184
|
+
tally.add(h.pool, h.host, h.user, h.session, h.weight)
|
|
185
|
+
return tally
|
|
186
|
+
|
|
187
|
+
def add(self, pool: str, host: str, user: str | None, session: str | None, weight: int):
|
|
188
|
+
self.used[pool] = self.used.get(pool, 0) + weight
|
|
189
|
+
self.host_used[host] = self.host_used.get(host, 0) + weight
|
|
190
|
+
self.running[(pool, user)] = self.running.get((pool, user), 0) + 1
|
|
191
|
+
key = (pool, user, session)
|
|
192
|
+
self.session_running[key] = self.session_running.get(key, 0) + 1
|
|
193
|
+
|
|
194
|
+
def serve(self, pool: str, user: str | None, session: str | None) -> None:
|
|
195
|
+
self.last_turn += 1
|
|
196
|
+
self.served[(pool, user)] = self.last_turn
|
|
197
|
+
self.session_served[(pool, user, session)] = self.last_turn
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def top(policy: AdmissionPolicy, pool: str) -> int:
|
|
201
|
+
"""The most `pool` may hold now: its `max`, or its `reserved` while it is capped."""
|
|
202
|
+
p = policy.pools[pool]
|
|
203
|
+
return p.reserved if pool in policy.capped else p.max
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def fits(tally: _Tally, policy: AdmissionPolicy, pool: str, weight: int) -> bool:
|
|
207
|
+
"""The two capacity rules, for `weight` more in `pool`."""
|
|
208
|
+
p = policy.pools[pool]
|
|
209
|
+
if tally.used.get(pool, 0) + weight > top(policy, pool):
|
|
210
|
+
return False
|
|
211
|
+
owed = sum(
|
|
212
|
+
max(0, q.reserved - tally.used.get(name, 0))
|
|
213
|
+
for name, q in policy.pools.items()
|
|
214
|
+
if name != pool and q.host == p.host
|
|
215
|
+
)
|
|
216
|
+
free = policy.slots.get(p.host, 0) - tally.host_used.get(p.host, 0) - weight
|
|
217
|
+
return free >= owed
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
#: A pool's line: user -> session -> that session's items, oldest first.
|
|
221
|
+
_Line = dict[str | None, dict[str | None, list[Waiting]]]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _line(waiting: Iterable[Waiting], pool: str) -> _Line:
|
|
225
|
+
line: _Line = {}
|
|
226
|
+
for item in sorted((w for w in waiting if w.pool == pool), key=lambda w: w.order):
|
|
227
|
+
line.setdefault(item.user, {}).setdefault(item.session, []).append(item)
|
|
228
|
+
return line
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_DONE = object() # nobody has anything left to admit (a user may be None, so not None)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _next_user(line: _Line, tally: _Tally, pool: str, skip: set[str | None]) -> object:
|
|
235
|
+
"""Whose turn it is in `pool`: fewest running, then served longest ago, then oldest head;
|
|
236
|
+
`_DONE` when nobody's."""
|
|
237
|
+
candidates = [u for u, sessions in line.items() if _any(sessions) and u not in skip]
|
|
238
|
+
if not candidates:
|
|
239
|
+
return _DONE
|
|
240
|
+
return min(
|
|
241
|
+
candidates,
|
|
242
|
+
key=lambda u: (
|
|
243
|
+
tally.running.get((pool, u), 0),
|
|
244
|
+
tally.served.get((pool, u), 0),
|
|
245
|
+
min(q[0].order for q in line[u].values() if q),
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _next_session(
|
|
251
|
+
sessions: dict[str | None, list[Waiting]], tally: _Tally, pool: str, user: str | None
|
|
252
|
+
) -> str | None:
|
|
253
|
+
"""Which of `user`'s sessions goes next, by the same three keys."""
|
|
254
|
+
return min(
|
|
255
|
+
(s for s, q in sessions.items() if q),
|
|
256
|
+
key=lambda s: (
|
|
257
|
+
tally.session_running.get((pool, user, s), 0),
|
|
258
|
+
tally.session_served.get((pool, user, s), 0),
|
|
259
|
+
sessions[s][0].order,
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _any(sessions: dict[str | None, list[Waiting]]) -> bool:
|
|
265
|
+
return any(sessions.values())
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _pools_in_order(policy: AdmissionPolicy) -> list[str]:
|
|
269
|
+
return sorted(policy.pools, key=lambda name: (policy.pools[name].priority, name))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def decide(state: State, policy: AdmissionPolicy) -> list[ItemKey]:
|
|
273
|
+
"""The waiting items to admit now, in admission order. Waiting items in a pool the policy
|
|
274
|
+
does not have are left waiting; weight held in such a pool still counts against its host.
|
|
275
|
+
|
|
276
|
+
`policy.standing` is already occupied when this starts: it reduces what fits, and it does
|
|
277
|
+
not change whose turn it is."""
|
|
278
|
+
tally = _Tally.of(state, policy)
|
|
279
|
+
admits: list[ItemKey] = []
|
|
280
|
+
for pool in _pools_in_order(policy):
|
|
281
|
+
if pool in policy.closed:
|
|
282
|
+
continue
|
|
283
|
+
limit = policy.pools[pool].per_user_max_running
|
|
284
|
+
line = _line(state.waiting, pool)
|
|
285
|
+
at_limit: set[str | None] = set()
|
|
286
|
+
while True:
|
|
287
|
+
user = _next_user(line, tally, pool, at_limit)
|
|
288
|
+
if user is _DONE:
|
|
289
|
+
break
|
|
290
|
+
if limit is not None and tally.running.get((pool, user), 0) >= limit:
|
|
291
|
+
at_limit.add(user)
|
|
292
|
+
continue
|
|
293
|
+
session = _next_session(line[user], tally, pool, user)
|
|
294
|
+
head = line[user][session][0]
|
|
295
|
+
if not fits(tally, policy, pool, head.weight):
|
|
296
|
+
break # no backfill around the item whose turn it is
|
|
297
|
+
line[user][session].pop(0)
|
|
298
|
+
tally.add(pool, policy.pools[pool].host, user, session, head.weight)
|
|
299
|
+
tally.serve(pool, user, session)
|
|
300
|
+
admits.append(head.key)
|
|
301
|
+
return admits
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def positions(state: State, policy: AdmissionPolicy) -> dict[ItemKey, int]:
|
|
305
|
+
"""Each waiting item's 1-based place in its pool's line: the order `decide` would take
|
|
306
|
+
them in if capacity freed one at a time (per-user limits aside)."""
|
|
307
|
+
tally = _Tally.of(state)
|
|
308
|
+
out: dict[ItemKey, int] = {}
|
|
309
|
+
for pool in sorted({w.pool for w in state.waiting}):
|
|
310
|
+
line = _line(state.waiting, pool)
|
|
311
|
+
place = 0
|
|
312
|
+
while (user := _next_user(line, tally, pool, set())) is not _DONE:
|
|
313
|
+
session = _next_session(line[user], tally, pool, user)
|
|
314
|
+
head = line[user][session].pop(0)
|
|
315
|
+
place += 1
|
|
316
|
+
out[head.key] = place
|
|
317
|
+
tally.running[(pool, user)] = tally.running.get((pool, user), 0) + 1
|
|
318
|
+
key = (pool, user, session)
|
|
319
|
+
tally.session_running[key] = tally.session_running.get(key, 0) + 1
|
|
320
|
+
tally.serve(pool, user, session)
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def blocker(
|
|
325
|
+
state: State,
|
|
326
|
+
policy: AdmissionPolicy,
|
|
327
|
+
key: ItemKey,
|
|
328
|
+
places: Mapping[ItemKey, int] | None = None,
|
|
329
|
+
) -> str | None:
|
|
330
|
+
"""Why the waiting item `key` is not admitted now, in words, or None when it is not waiting
|
|
331
|
+
(or would be admitted). The first reason that applies, in this order: its pool is closed;
|
|
332
|
+
another item is ahead of it; its user is at the pool's per-user limit; its pool is capped
|
|
333
|
+
to its reservation; its pool is full;
|
|
334
|
+
the machine's free slots are owed to other pools' reservations or are all in use.
|
|
335
|
+
`places` is `positions(state, policy)`, for a caller asking about many items."""
|
|
336
|
+
item = next((w for w in state.waiting if w.key == key), None)
|
|
337
|
+
if item is None or item.pool not in policy.pools:
|
|
338
|
+
return None
|
|
339
|
+
pool = policy.pools[item.pool]
|
|
340
|
+
if item.pool in policy.closed:
|
|
341
|
+
return f"pool {item.pool} is closed"
|
|
342
|
+
place = (places if places is not None else positions(state, policy)).get(key, 1)
|
|
343
|
+
if place > 1:
|
|
344
|
+
return f"{place - 1} ahead of it in pool {item.pool}"
|
|
345
|
+
tally = _Tally.of(state, policy)
|
|
346
|
+
if (
|
|
347
|
+
pool.per_user_max_running is not None
|
|
348
|
+
and tally.running.get((item.pool, item.user), 0) >= pool.per_user_max_running
|
|
349
|
+
):
|
|
350
|
+
return (
|
|
351
|
+
f"its user already runs {pool.per_user_max_running} in pool {item.pool}, "
|
|
352
|
+
"the per-user limit"
|
|
353
|
+
)
|
|
354
|
+
used = tally.used.get(item.pool, 0)
|
|
355
|
+
if item.pool in policy.capped and pool.reserved < used + item.weight <= pool.max:
|
|
356
|
+
return (
|
|
357
|
+
f"the machine is under pressure: pool {item.pool} admits only its "
|
|
358
|
+
f"{pool.reserved} reserved slots and holds {used}; this needs {item.weight}"
|
|
359
|
+
)
|
|
360
|
+
if used + item.weight > pool.max:
|
|
361
|
+
return f"pool {item.pool} holds {used} of {pool.max}; this needs {item.weight}"
|
|
362
|
+
if fits(tally, policy, item.pool, item.weight):
|
|
363
|
+
return None
|
|
364
|
+
slots = policy.slots.get(pool.host, 0)
|
|
365
|
+
free = slots - tally.host_used.get(pool.host, 0)
|
|
366
|
+
owed = sum(
|
|
367
|
+
max(0, q.reserved - tally.used.get(name, 0))
|
|
368
|
+
for name, q in policy.pools.items()
|
|
369
|
+
if name != item.pool and q.host == pool.host
|
|
370
|
+
)
|
|
371
|
+
if free >= item.weight and owed:
|
|
372
|
+
return f"{free} of {slots} slots are free, but {owed} are reserved for other pools"
|
|
373
|
+
return f"{max(free, 0)} of {slots} slots are free; this needs {item.weight}"
|