arkhai-core-storefront 0.1.0__tar.gz
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.
- arkhai_core_storefront-0.1.0/.gitignore +223 -0
- arkhai_core_storefront-0.1.0/PKG-INFO +12 -0
- arkhai_core_storefront-0.1.0/pyproject.toml +39 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/__init__.py +1 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/aggregation.py +338 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/auth.py +188 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/capacity.py +170 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/capacity_remote.py +280 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/escrow_verification.py +466 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/heartbeats.py +103 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/models/__init__.py +1 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/models/deal_models.py +43 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/models/listing_models.py +209 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/models/negotiation_models.py +129 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/models/settle_models.py +111 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/models/system_models.py +26 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/multi_registry_client.py +412 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/negotiation_sync.py +262 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/openapi.py +58 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/refund.py +195 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/registry_publication.py +189 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/services/__init__.py +1 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/services/negotiation_service.py +265 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/settlement_lifecycle.py +298 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/sqlite_client.py +2643 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/sqlite_migrations.py +461 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/stage_log.py +57 -0
- arkhai_core_storefront-0.1.0/src/core_storefront/token_transfer.py +116 -0
- arkhai_core_storefront-0.1.0/tests/unit/test_aggregation.py +215 -0
- arkhai_core_storefront-0.1.0/tests/unit/test_heartbeats.py +65 -0
- arkhai_core_storefront-0.1.0/tests/unit/test_negotiation_sync.py +68 -0
- arkhai_core_storefront-0.1.0/tests/unit/test_registry_publication.py +165 -0
- arkhai_core_storefront-0.1.0/tests/unit/test_settlement_lifecycle.py +205 -0
- arkhai_core_storefront-0.1.0/uv.lock +1663 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
.vscode/
|
|
2
|
+
|
|
3
|
+
# Project-local scratch files, private notes, and generated diagnostics.
|
|
4
|
+
.scm-local/
|
|
5
|
+
|
|
6
|
+
# Visible transfer staging for snap-confined tools such as Multipass.
|
|
7
|
+
scm-clean-room-transfer/
|
|
8
|
+
|
|
9
|
+
# Legacy local rehearsal params (private keys, RPC URLs, etc.).
|
|
10
|
+
.rehearsal-params.local.md
|
|
11
|
+
.rehearsal-params.local.*
|
|
12
|
+
|
|
13
|
+
### Python ###
|
|
14
|
+
# Byte-compiled / optimized / DLL files
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
*$py.class
|
|
18
|
+
|
|
19
|
+
# C extensions
|
|
20
|
+
*.so
|
|
21
|
+
|
|
22
|
+
# Distribution / packaging
|
|
23
|
+
.Python
|
|
24
|
+
build/
|
|
25
|
+
develop-eggs/
|
|
26
|
+
dist/
|
|
27
|
+
downloads/
|
|
28
|
+
eggs/
|
|
29
|
+
.eggs/
|
|
30
|
+
lib/
|
|
31
|
+
lib64/
|
|
32
|
+
parts/
|
|
33
|
+
sdist/
|
|
34
|
+
var/
|
|
35
|
+
wheels/
|
|
36
|
+
share/python-wheels/
|
|
37
|
+
*.egg-info/
|
|
38
|
+
.installed.cfg
|
|
39
|
+
*.egg
|
|
40
|
+
MANIFEST
|
|
41
|
+
|
|
42
|
+
# PyInstaller
|
|
43
|
+
# Usually these files are written by a python script from a template
|
|
44
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
45
|
+
*.manifest
|
|
46
|
+
*.spec
|
|
47
|
+
|
|
48
|
+
# Installer logs
|
|
49
|
+
pip-log.txt
|
|
50
|
+
pip-delete-this-directory.txt
|
|
51
|
+
|
|
52
|
+
# Unit test / coverage reports
|
|
53
|
+
htmlcov/
|
|
54
|
+
.tox/
|
|
55
|
+
.nox/
|
|
56
|
+
.coverage
|
|
57
|
+
.coverage.*
|
|
58
|
+
.cache
|
|
59
|
+
nosetests.xml
|
|
60
|
+
coverage.xml
|
|
61
|
+
*.cover
|
|
62
|
+
*.py,cover
|
|
63
|
+
.hypothesis/
|
|
64
|
+
.pytest_cache/
|
|
65
|
+
cover/
|
|
66
|
+
|
|
67
|
+
# Translations
|
|
68
|
+
*.mo
|
|
69
|
+
*.pot
|
|
70
|
+
|
|
71
|
+
# Django stuff:
|
|
72
|
+
*.log
|
|
73
|
+
*.log.[0-9]*
|
|
74
|
+
local_settings.py
|
|
75
|
+
db.sqlite3
|
|
76
|
+
db.sqlite3-journal
|
|
77
|
+
|
|
78
|
+
# Flask stuff:
|
|
79
|
+
instance/
|
|
80
|
+
.webassets-cache
|
|
81
|
+
|
|
82
|
+
# Scrapy stuff:
|
|
83
|
+
.scrapy
|
|
84
|
+
|
|
85
|
+
# Sphinx documentation
|
|
86
|
+
docs/_build/
|
|
87
|
+
|
|
88
|
+
# PyBuilder
|
|
89
|
+
.pybuilder/
|
|
90
|
+
target/
|
|
91
|
+
|
|
92
|
+
# Jupyter Notebook
|
|
93
|
+
.ipynb_checkpoints
|
|
94
|
+
|
|
95
|
+
# IPython
|
|
96
|
+
profile_default/
|
|
97
|
+
ipython_config.py
|
|
98
|
+
|
|
99
|
+
# pyenv
|
|
100
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
101
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
102
|
+
# .python-version
|
|
103
|
+
|
|
104
|
+
# pipenv
|
|
105
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
106
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
107
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
108
|
+
# install all needed dependencies.
|
|
109
|
+
#Pipfile.lock
|
|
110
|
+
|
|
111
|
+
# poetry
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
113
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
114
|
+
# commonly ignored for libraries.
|
|
115
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
116
|
+
#poetry.lock
|
|
117
|
+
|
|
118
|
+
# pdm
|
|
119
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
120
|
+
#pdm.lock
|
|
121
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
122
|
+
# in version control.
|
|
123
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
124
|
+
.pdm.toml
|
|
125
|
+
|
|
126
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
127
|
+
__pypackages__/
|
|
128
|
+
|
|
129
|
+
# Celery stuff
|
|
130
|
+
celerybeat-schedule
|
|
131
|
+
celerybeat.pid
|
|
132
|
+
|
|
133
|
+
# SageMath parsed files
|
|
134
|
+
*.sage.py
|
|
135
|
+
|
|
136
|
+
# Environments
|
|
137
|
+
.env
|
|
138
|
+
.env.*
|
|
139
|
+
.venv
|
|
140
|
+
env/
|
|
141
|
+
venv/
|
|
142
|
+
ENV/
|
|
143
|
+
env.bak/
|
|
144
|
+
venv.bak/
|
|
145
|
+
|
|
146
|
+
# Spyder project settings
|
|
147
|
+
.spyderproject
|
|
148
|
+
.spyproject
|
|
149
|
+
|
|
150
|
+
# Rope project settings
|
|
151
|
+
.ropeproject
|
|
152
|
+
|
|
153
|
+
# mkdocs documentation
|
|
154
|
+
/site
|
|
155
|
+
|
|
156
|
+
# mypy
|
|
157
|
+
.mypy_cache/
|
|
158
|
+
.dmypy.json
|
|
159
|
+
dmypy.json
|
|
160
|
+
|
|
161
|
+
# Pyre type checker
|
|
162
|
+
.pyre/
|
|
163
|
+
|
|
164
|
+
# pytype static type analyzer
|
|
165
|
+
.pytype/
|
|
166
|
+
|
|
167
|
+
# Cython debug symbols
|
|
168
|
+
cython_debug/
|
|
169
|
+
|
|
170
|
+
# PyCharm
|
|
171
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
172
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
173
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
174
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
175
|
+
#.idea/
|
|
176
|
+
|
|
177
|
+
### Python Patch ###
|
|
178
|
+
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
|
179
|
+
poetry.toml
|
|
180
|
+
|
|
181
|
+
# ruff
|
|
182
|
+
.ruff_cache/
|
|
183
|
+
|
|
184
|
+
# LSP config files
|
|
185
|
+
pyrightconfig.json
|
|
186
|
+
|
|
187
|
+
# Infra
|
|
188
|
+
infra/*/*.tfvars
|
|
189
|
+
infra/*/*.tfstate
|
|
190
|
+
infra/*/*.tfstate.backup
|
|
191
|
+
infra/*/.terraform*
|
|
192
|
+
infra/*/*.plan
|
|
193
|
+
*.db
|
|
194
|
+
# PufferRL Experiments
|
|
195
|
+
experiments/
|
|
196
|
+
wandb/
|
|
197
|
+
!.env.sample
|
|
198
|
+
!.env.docker-compose
|
|
199
|
+
!.env.*.docker-compose
|
|
200
|
+
!.env.*.docker-compose
|
|
201
|
+
!.env.docker-compose
|
|
202
|
+
.cursor/
|
|
203
|
+
|
|
204
|
+
# Cloud Function
|
|
205
|
+
node_modules
|
|
206
|
+
|
|
207
|
+
# Puffer artifacts — ignore any resources/ dir or symlink at any depth
|
|
208
|
+
**/resources
|
|
209
|
+
pr-message-puffer.md
|
|
210
|
+
dev-env/state/state.json
|
|
211
|
+
shared-env/*
|
|
212
|
+
helm/Chart.lock
|
|
213
|
+
helm/charts/*.tgz
|
|
214
|
+
helm/.port-forward.pids
|
|
215
|
+
provisioning-service/settings.local.toml
|
|
216
|
+
provisioning-service/src/config/config-local.yml
|
|
217
|
+
.snapshot/*
|
|
218
|
+
|
|
219
|
+
# Per-env deployment scratch dirs (carry keys + per-env configs)
|
|
220
|
+
deploy-base-sepolia/
|
|
221
|
+
|
|
222
|
+
# Claude Code per-project state
|
|
223
|
+
.claude/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arkhai-core-storefront
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core storefront role shell and protocol helpers
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: arkhai-core
|
|
7
|
+
Requires-Dist: arkhai-core-registry-client
|
|
8
|
+
Requires-Dist: arkhai-kit-config
|
|
9
|
+
Requires-Dist: arkhai-kit-identity
|
|
10
|
+
Requires-Dist: arkhai-kit-policy
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: pydantic>=2
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "arkhai-core-storefront"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Core storefront role shell and protocol helpers"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"arkhai-core",
|
|
8
|
+
"arkhai-core-registry-client",
|
|
9
|
+
"arkhai-kit-config",
|
|
10
|
+
"arkhai-kit-identity",
|
|
11
|
+
"arkhai-kit-policy",
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
"pydantic>=2",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/core_storefront"]
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
pythonpath = ["src"]
|
|
25
|
+
testpaths = ["tests/unit"]
|
|
26
|
+
asyncio_mode = "auto"
|
|
27
|
+
|
|
28
|
+
[tool.uv.sources]
|
|
29
|
+
arkhai-core = { path = "..", editable = true }
|
|
30
|
+
arkhai-core-registry-client = { path = "../registry-client", editable = true }
|
|
31
|
+
arkhai-kit-config = { path = "../../kit/config", editable = true }
|
|
32
|
+
arkhai-kit-identity = { path = "../../kit/identity", editable = true }
|
|
33
|
+
arkhai-kit-policy = { path = "../../kit/policy", editable = true }
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.3.4,<9.0.0",
|
|
38
|
+
"pytest-asyncio>=0.25,<2.0",
|
|
39
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Storefront protocol helpers owned by market core."""
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Multi-site capacity aggregation.
|
|
2
|
+
|
|
3
|
+
The aggregator answers the design doc's "two machines in two
|
|
4
|
+
datacenters, one listing, depletes only when both are depleted"
|
|
5
|
+
(docs/development/ARCHITECTURE.md, "Capacity and the Site Authority"):
|
|
6
|
+
a soft-state view over N hard-state site ledgers, reached only through
|
|
7
|
+
their ``CapacityClient`` interfaces. It holds no capacity itself —
|
|
8
|
+
availability is a union over member sites, a reserve is routed to one
|
|
9
|
+
site and falls back to the next on refusal, and there are no
|
|
10
|
+
distributed transactions to invent: cross-site contention resolves at
|
|
11
|
+
whichever single site's local transaction the placement policy picked.
|
|
12
|
+
|
|
13
|
+
It lives in the storefront process, not in a site and not in a shared
|
|
14
|
+
service, because pooling/placement is a commercial judgment per seller
|
|
15
|
+
(the fungibility rule: resources may pool exactly when no advertised
|
|
16
|
+
attribute distinguishes them — only the seller's market schema knows
|
|
17
|
+
which attributes those are).
|
|
18
|
+
|
|
19
|
+
``AggregateCapacityClient`` implements the same ``CapacityClient``
|
|
20
|
+
protocol it consumes, so a storefront wired against one site and one
|
|
21
|
+
wired against five run identical code. Every payload and delta is
|
|
22
|
+
tagged with the site name it came from; pool members reference
|
|
23
|
+
``(site, resource_id)``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any, Callable, Iterable, Mapping, Protocol, runtime_checkable
|
|
30
|
+
|
|
31
|
+
from core_storefront.capacity import (
|
|
32
|
+
CapacityClient,
|
|
33
|
+
CapacityDelta,
|
|
34
|
+
CapacityEventBus,
|
|
35
|
+
CapacitySubscriber,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Placement policies
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class PlacementPolicy(Protocol):
|
|
47
|
+
"""Orders candidate sites for a reserve/probe.
|
|
48
|
+
|
|
49
|
+
Takes the configured site names (in configuration order) and the
|
|
50
|
+
latest per-site snapshots the aggregator could obtain (absent for a
|
|
51
|
+
site whose snapshot failed); returns the site names to try, in
|
|
52
|
+
order. Sites omitted from the result are not tried.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __call__(
|
|
56
|
+
self,
|
|
57
|
+
site_names: list[str],
|
|
58
|
+
snapshots: Mapping[str, list[dict[str, Any]]],
|
|
59
|
+
*,
|
|
60
|
+
claim: Mapping[str, Any] | None = None,
|
|
61
|
+
) -> list[str]:
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fill_first(
|
|
66
|
+
site_names: list[str],
|
|
67
|
+
snapshots: Mapping[str, list[dict[str, Any]]],
|
|
68
|
+
*,
|
|
69
|
+
claim: Mapping[str, Any] | None = None,
|
|
70
|
+
) -> list[str]:
|
|
71
|
+
"""Configuration order: pack the first site before touching the next."""
|
|
72
|
+
return list(site_names)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _site_available_units(snapshot: list[dict[str, Any]]) -> int:
|
|
76
|
+
total = 0
|
|
77
|
+
for row in snapshot:
|
|
78
|
+
available = row.get("available_units")
|
|
79
|
+
if available is None:
|
|
80
|
+
continue
|
|
81
|
+
total += max(int(available), 0)
|
|
82
|
+
return total
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def most_available(
|
|
86
|
+
site_names: list[str],
|
|
87
|
+
snapshots: Mapping[str, list[dict[str, Any]]],
|
|
88
|
+
*,
|
|
89
|
+
claim: Mapping[str, Any] | None = None,
|
|
90
|
+
) -> list[str]:
|
|
91
|
+
"""Spread: prefer the site with the most free units (ties keep
|
|
92
|
+
configuration order; sites without a snapshot go last)."""
|
|
93
|
+
def _key(idx_name: tuple[int, str]) -> tuple[int, int]:
|
|
94
|
+
idx, name = idx_name
|
|
95
|
+
snapshot = snapshots.get(name)
|
|
96
|
+
if snapshot is None:
|
|
97
|
+
return (1, idx) # unknown availability — try after known sites
|
|
98
|
+
return (0, -_site_available_units(snapshot) * len(site_names) + idx)
|
|
99
|
+
|
|
100
|
+
# Sort by (known first, descending availability), stable on config order.
|
|
101
|
+
ordered = sorted(enumerate(site_names), key=_key)
|
|
102
|
+
return [name for _, name in ordered]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
PLACEMENT_POLICIES: dict[str, PlacementPolicy] = {
|
|
106
|
+
"fill_first": fill_first,
|
|
107
|
+
"most_available": most_available,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# The aggregate client
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _tagged(site: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
116
|
+
out = dict(payload)
|
|
117
|
+
out["site"] = site
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AggregateCapacityClient:
|
|
122
|
+
"""``CapacityClient`` over N named site clients.
|
|
123
|
+
|
|
124
|
+
Reads union, writes route. A site that errors is skipped (logged):
|
|
125
|
+
the aggregator is a soft-state view and one site's outage must not
|
|
126
|
+
take down the seller's whole market — the listing closes only when
|
|
127
|
+
every member refuses.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
sites: Mapping[str, CapacityClient],
|
|
133
|
+
*,
|
|
134
|
+
placement: PlacementPolicy | None = None,
|
|
135
|
+
bus: CapacityEventBus | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
if not sites:
|
|
138
|
+
raise ValueError("AggregateCapacityClient needs at least one site")
|
|
139
|
+
self._sites = dict(sites)
|
|
140
|
+
self._placement = placement or fill_first
|
|
141
|
+
self._bus = bus or CapacityEventBus()
|
|
142
|
+
# allocation_id → site name, learned at reserve time. A cache,
|
|
143
|
+
# not a ledger: misses (process restart) fall back to asking
|
|
144
|
+
# every site, and the answer is re-learned.
|
|
145
|
+
self._allocation_sites: dict[str, str] = {}
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def site_names(self) -> list[str]:
|
|
149
|
+
return list(self._sites)
|
|
150
|
+
|
|
151
|
+
def site(self, name: str) -> CapacityClient:
|
|
152
|
+
return self._sites[name]
|
|
153
|
+
|
|
154
|
+
# -- reads ----------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
async def snapshot(self) -> list[dict[str, Any]]:
|
|
157
|
+
"""Union of per-site snapshots, each row tagged with its site."""
|
|
158
|
+
rows: list[dict[str, Any]] = []
|
|
159
|
+
for name, snapshot in (await self._snapshots()).items():
|
|
160
|
+
rows.extend(_tagged(name, row) for row in snapshot)
|
|
161
|
+
return rows
|
|
162
|
+
|
|
163
|
+
async def probe(
|
|
164
|
+
self, *, claim: Mapping[str, Any] | None = None,
|
|
165
|
+
) -> dict[str, Any] | None:
|
|
166
|
+
snapshots = await self._snapshots()
|
|
167
|
+
for name in self._placement(self.site_names, snapshots, claim=claim):
|
|
168
|
+
try:
|
|
169
|
+
match = await self._sites[name].probe(claim=claim)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
logger.warning("[AGGREGATOR] probe at site %r failed: %s", name, exc)
|
|
172
|
+
continue
|
|
173
|
+
if match is not None:
|
|
174
|
+
return _tagged(name, match)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
# -- writes ----------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async def reserve(
|
|
180
|
+
self,
|
|
181
|
+
*,
|
|
182
|
+
claim: Mapping[str, Any] | None = None,
|
|
183
|
+
deal_ref: Mapping[str, Any] | None = None,
|
|
184
|
+
ttl_seconds: float | None = None,
|
|
185
|
+
) -> dict[str, Any] | None:
|
|
186
|
+
"""Route to one site in placement order; fall back on refusal.
|
|
187
|
+
|
|
188
|
+
A refusal is a None (no capacity) or an error; either way the
|
|
189
|
+
next site gets the claim. Returns None only when every member
|
|
190
|
+
refused.
|
|
191
|
+
"""
|
|
192
|
+
snapshots = await self._snapshots()
|
|
193
|
+
for name in self._placement(self.site_names, snapshots, claim=claim):
|
|
194
|
+
try:
|
|
195
|
+
reserved = await self._sites[name].reserve(
|
|
196
|
+
claim=claim, deal_ref=deal_ref, ttl_seconds=ttl_seconds,
|
|
197
|
+
)
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
logger.warning(
|
|
200
|
+
"[AGGREGATOR] reserve at site %r failed, trying next: %s",
|
|
201
|
+
name, exc,
|
|
202
|
+
)
|
|
203
|
+
continue
|
|
204
|
+
if reserved is None:
|
|
205
|
+
continue
|
|
206
|
+
allocation_id = reserved.get("allocation_id")
|
|
207
|
+
if allocation_id:
|
|
208
|
+
self._allocation_sites[str(allocation_id)] = name
|
|
209
|
+
return _tagged(name, reserved)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
async def commit(
|
|
213
|
+
self,
|
|
214
|
+
*,
|
|
215
|
+
resource_id: str,
|
|
216
|
+
allocation_id: str | None = None,
|
|
217
|
+
lease_end_utc: str,
|
|
218
|
+
idempotency_ref: str | None = None,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Commit at the owning site (cache-first, then the rest).
|
|
221
|
+
|
|
222
|
+
A site that doesn't know the allocation raises/refuses and the
|
|
223
|
+
next is tried; if every site refuses, the last error propagates
|
|
224
|
+
— a commit that lands nowhere must not look like success.
|
|
225
|
+
"""
|
|
226
|
+
last_error: Exception | None = None
|
|
227
|
+
for name in self._route_order(allocation_id):
|
|
228
|
+
try:
|
|
229
|
+
await self._sites[name].commit(
|
|
230
|
+
resource_id=resource_id,
|
|
231
|
+
allocation_id=allocation_id,
|
|
232
|
+
lease_end_utc=lease_end_utc,
|
|
233
|
+
idempotency_ref=idempotency_ref,
|
|
234
|
+
)
|
|
235
|
+
return
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
logger.warning(
|
|
238
|
+
"[AGGREGATOR] commit at site %r failed: %s", name, exc,
|
|
239
|
+
)
|
|
240
|
+
last_error = exc
|
|
241
|
+
if last_error is not None:
|
|
242
|
+
raise last_error
|
|
243
|
+
|
|
244
|
+
async def release(
|
|
245
|
+
self,
|
|
246
|
+
*,
|
|
247
|
+
allocation_id: str | None = None,
|
|
248
|
+
deal_ref: Mapping[str, Any] | None = None,
|
|
249
|
+
**extra: Any,
|
|
250
|
+
) -> dict[str, Any] | None:
|
|
251
|
+
"""Release wherever the allocation lives; None if no site holds it.
|
|
252
|
+
|
|
253
|
+
``extra`` passes through implementation-specific keywords (e.g.
|
|
254
|
+
failure metadata) to sites that accept them.
|
|
255
|
+
"""
|
|
256
|
+
for name in self._route_order(allocation_id):
|
|
257
|
+
try:
|
|
258
|
+
released = await self._sites[name].release(
|
|
259
|
+
allocation_id=allocation_id, deal_ref=deal_ref, **extra,
|
|
260
|
+
)
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
logger.warning(
|
|
263
|
+
"[AGGREGATOR] release at site %r failed: %s", name, exc,
|
|
264
|
+
)
|
|
265
|
+
continue
|
|
266
|
+
if released is not None:
|
|
267
|
+
if allocation_id:
|
|
268
|
+
self._allocation_sites.pop(str(allocation_id), None)
|
|
269
|
+
return _tagged(name, released)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
async def truncate_lease(
|
|
273
|
+
self,
|
|
274
|
+
*,
|
|
275
|
+
allocation_id: str,
|
|
276
|
+
lease_end_utc: str,
|
|
277
|
+
) -> dict[str, Any] | None:
|
|
278
|
+
for name in self._route_order(allocation_id):
|
|
279
|
+
try:
|
|
280
|
+
truncated = await self._sites[name].truncate_lease(
|
|
281
|
+
allocation_id=allocation_id, lease_end_utc=lease_end_utc,
|
|
282
|
+
)
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
logger.warning(
|
|
285
|
+
"[AGGREGATOR] truncate at site %r failed: %s", name, exc,
|
|
286
|
+
)
|
|
287
|
+
continue
|
|
288
|
+
if truncated is not None:
|
|
289
|
+
return _tagged(name, truncated)
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
# -- events ----------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def subscribe(self, subscriber: CapacitySubscriber) -> Callable[[], None]:
|
|
295
|
+
return self._bus.subscribe(subscriber)
|
|
296
|
+
|
|
297
|
+
async def emit_site_delta(self, site: str, delta: CapacityDelta) -> None:
|
|
298
|
+
"""Feed a site's delta (from its poller/bus) to aggregate subscribers."""
|
|
299
|
+
await self._bus.emit(CapacityDelta(
|
|
300
|
+
kind=delta.kind,
|
|
301
|
+
version=delta.version,
|
|
302
|
+
resource_id=delta.resource_id,
|
|
303
|
+
pool_id=delta.pool_id,
|
|
304
|
+
site=site,
|
|
305
|
+
))
|
|
306
|
+
|
|
307
|
+
# -- internals ---------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
async def _snapshots(self) -> dict[str, list[dict[str, Any]]]:
|
|
310
|
+
"""Best-effort per-site snapshots; a failed site is omitted."""
|
|
311
|
+
snapshots: dict[str, list[dict[str, Any]]] = {}
|
|
312
|
+
for name, client in self._sites.items():
|
|
313
|
+
try:
|
|
314
|
+
snapshots[name] = await client.snapshot() or []
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
logger.warning(
|
|
317
|
+
"[AGGREGATOR] snapshot at site %r failed: %s", name, exc,
|
|
318
|
+
)
|
|
319
|
+
return snapshots
|
|
320
|
+
|
|
321
|
+
def _route_order(self, allocation_id: str | None) -> Iterable[str]:
|
|
322
|
+
"""Owning site first when known, then everyone else.
|
|
323
|
+
|
|
324
|
+
The cache is populated at reserve time; after a restart the
|
|
325
|
+
cache is cold and the write fans out — sites that don't hold
|
|
326
|
+
the allocation refuse, the one that does answers.
|
|
327
|
+
"""
|
|
328
|
+
cached = (
|
|
329
|
+
self._allocation_sites.get(str(allocation_id))
|
|
330
|
+
if allocation_id else None
|
|
331
|
+
)
|
|
332
|
+
if cached and cached in self._sites:
|
|
333
|
+
yield cached
|
|
334
|
+
for name in self._sites:
|
|
335
|
+
if name != cached:
|
|
336
|
+
yield name
|
|
337
|
+
else:
|
|
338
|
+
yield from self._sites
|