Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6a0c7f6b0 |
No files matched your search
@@ -299,6 +299,43 @@ Explicit cleanup uses the locked move and retries exact-key
|
|||||||
`.cleanup.*` tombstones left by an interrupted removal; a later build performs
|
`.cleanup.*` tombstones left by an interrupted removal; a later build performs
|
||||||
the same tombstone reconciliation before producing replacement content.
|
the same tombstone reconciliation before producing replacement content.
|
||||||
|
|
||||||
|
Failure-quarantined entries remain until explicitly selected for removal;
|
||||||
|
ordinary `cleanup(identity)` does not remove that diagnostic history.
|
||||||
|
`FilesystemHostWeightStore.cleanup_quarantined(storage_name)` accepts one
|
||||||
|
quarantined inventory basename and preserves the current artifact, deny marker,
|
||||||
|
and other failure-quarantined entries. It takes the same nonblocking build and
|
||||||
|
exclusive artifact locks, so an active builder or lease returns a typed refusal.
|
||||||
|
Malformed names are rejected before mutation, and symlinks are not followed.
|
||||||
|
|
||||||
|
Selected entries move through the existing `.cleanup.*` tombstone protocol.
|
||||||
|
Retrying the original name reconciles pending cleanup tombstones for that key,
|
||||||
|
even if the original entry disappeared during an earlier attempt. A missing
|
||||||
|
selection is otherwise an idempotent success. Existing cleanup intents for the
|
||||||
|
key may be completed along with the selection; other quarantine history is
|
||||||
|
retained. Parent synchronization is retried even when an earlier deletion
|
||||||
|
removed the last tombstone before its sync failed.
|
||||||
|
|
||||||
|
For a configured store, operators can inspect and deliberately select an entry:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from vllm_omni.host_weight_runtime import ArtifactInventoryState, HostWeightError
|
||||||
|
|
||||||
|
for entry in store.inspect_domain().inventory:
|
||||||
|
if entry.state is ArtifactInventoryState.QUARANTINED:
|
||||||
|
print(entry.storage_name, entry.size_bytes)
|
||||||
|
|
||||||
|
selected_storage_name = input("Quarantined entry to remove: ")
|
||||||
|
failure = store.cleanup_quarantined(selected_storage_name)
|
||||||
|
if failure is not None:
|
||||||
|
raise HostWeightError(failure)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same authoritative domain and capacity policy as the service. Review
|
||||||
|
inventory sizes and inspect again after cleanup before explicitly retrying a
|
||||||
|
build or startup. These are logical-byte, point-in-time observations; concurrent
|
||||||
|
activity can affect the difference and physical disk/RAM reclamation is not
|
||||||
|
implied. This operation adds no automatic retention policy or service retry.
|
||||||
|
|
||||||
An operational failure while inspecting a noncooperative competing publication
|
An operational failure while inspecting a noncooperative competing publication
|
||||||
is a retryable storage failure. The competing entry remains authoritative and
|
is a retryable storage failure. The competing entry remains authoritative and
|
||||||
is not mislabeled as corrupt or quarantined merely because its manifest could
|
is not mislabeled as corrupt or quarantined merely because its manifest could
|
||||||
|
|||||||
@@ -440,6 +440,22 @@ def _corrupt_test_payload(artifact: Path) -> None:
|
|||||||
os.chmod(artifact, 0o555)
|
os.chmod(artifact, 0o555)
|
||||||
|
|
||||||
|
|
||||||
|
def _quarantine_and_rebuild(store: FilesystemHostWeightStore, identity: WeightArtifactIdentity) -> Path:
|
||||||
|
before = set(store.quarantine_dir.iterdir())
|
||||||
|
_corrupt_test_payload(store.artifacts_dir / identity.key)
|
||||||
|
result = store.get_or_build(
|
||||||
|
BuildRequest(identity),
|
||||||
|
FakeProducer(identity),
|
||||||
|
validation=ValidationLevel.FULL_CHECKSUM,
|
||||||
|
deadline=time.monotonic() + 10,
|
||||||
|
)
|
||||||
|
assert result.status is StoreStatus.BUILT
|
||||||
|
assert result.lease is not None
|
||||||
|
result.lease.close()
|
||||||
|
(quarantined,) = set(store.quarantine_dir.iterdir()) - before
|
||||||
|
return quarantined
|
||||||
|
|
||||||
|
|
||||||
def _build_process(root: str, counter: str, initial_lookup_barrier: object, result_queue: object) -> None:
|
def _build_process(root: str, counter: str, initial_lookup_barrier: object, result_queue: object) -> None:
|
||||||
try:
|
try:
|
||||||
identity = _identity()
|
identity = _identity()
|
||||||
@@ -2496,6 +2512,199 @@ def test_store_and_free_space_capacity_limits_fail_before_publication(tmp_path:
|
|||||||
assert not list(free_limited.artifacts_dir.iterdir())
|
assert not list(free_limited.artifacts_dir.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_quarantined_preserves_current_artifact_other_entries_and_deny(tmp_path: Path) -> None:
|
||||||
|
store = _make_store(tmp_path / "store")
|
||||||
|
identity, artifact = _publish_test_artifact(store)
|
||||||
|
first = _quarantine_and_rebuild(store, identity)
|
||||||
|
second = _quarantine_and_rebuild(store, identity)
|
||||||
|
ready_inode = artifact.stat().st_ino
|
||||||
|
lock_inodes = {path.name: path.stat().st_ino for path in store.locks_dir.iterdir()}
|
||||||
|
|
||||||
|
assert store.cleanup_quarantined(first.name) is None
|
||||||
|
assert set(store.quarantine_dir.iterdir()) == {second}
|
||||||
|
assert artifact.stat().st_ino == ready_inode
|
||||||
|
hit = store.lookup(identity, validation=ValidationLevel.FULL_CHECKSUM)
|
||||||
|
assert hit.lease is not None
|
||||||
|
assert torch.equal(hit.lease.tensors["layer.weight"], _weight())
|
||||||
|
hit.lease.close()
|
||||||
|
|
||||||
|
_corrupt_test_payload(artifact)
|
||||||
|
assert store.lookup(identity, validation=ValidationLevel.FULL_CHECKSUM).status is StoreStatus.INVALID
|
||||||
|
deny = store.deny_dir / f"{identity.key}.json"
|
||||||
|
deny_bytes = deny.read_bytes()
|
||||||
|
assert store.cleanup_quarantined(second.name) is None
|
||||||
|
assert deny.read_bytes() == deny_bytes
|
||||||
|
assert artifact.stat().st_ino == ready_inode
|
||||||
|
assert {path.name: path.stat().st_ino for path in store.locks_dir.iterdir()} == lock_inodes
|
||||||
|
assert store.cleanup_quarantined(first.name) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("active", ["build", "lease"])
|
||||||
|
def test_cleanup_quarantined_refuses_active_key(tmp_path: Path, active: str) -> None:
|
||||||
|
store = _make_store(tmp_path / "store")
|
||||||
|
identity, _ = _publish_test_artifact(store)
|
||||||
|
quarantined = _quarantine_and_rebuild(store, identity)
|
||||||
|
owner: FileLock | HostWeightLease
|
||||||
|
if active == "build":
|
||||||
|
owner = FileLock(store._build_lock_path(identity.key), exclusive=True, deadline=None)
|
||||||
|
expected = FailureCode.ACTIVE_BUILD_TIMEOUT
|
||||||
|
else:
|
||||||
|
lease = store.lookup(identity, validation=ValidationLevel.FULL_CHECKSUM).lease
|
||||||
|
assert lease is not None
|
||||||
|
owner = lease
|
||||||
|
expected = FailureCode.ACTIVE_LEASE
|
||||||
|
try:
|
||||||
|
failure = store.cleanup_quarantined(quarantined.name)
|
||||||
|
assert failure is not None and failure.code is expected and failure.retryable
|
||||||
|
assert quarantined.is_dir()
|
||||||
|
finally:
|
||||||
|
owner.close()
|
||||||
|
assert store.cleanup_quarantined(quarantined.name) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("recovery", ["cleanup", "build"])
|
||||||
|
def test_cleanup_quarantined_retries_post_move_removal_failure(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, recovery: str
|
||||||
|
) -> None:
|
||||||
|
store = _make_store(tmp_path / "store")
|
||||||
|
identity, artifact = _publish_test_artifact(store)
|
||||||
|
quarantined = _quarantine_and_rebuild(store, identity)
|
||||||
|
original_rmtree = shutil.rmtree
|
||||||
|
|
||||||
|
def fail_removal(path: str | os.PathLike[str]) -> None:
|
||||||
|
if Path(path).parent == store.quarantine_dir:
|
||||||
|
raise OSError(errno.EIO, "injected removal failure")
|
||||||
|
original_rmtree(path)
|
||||||
|
|
||||||
|
with monkeypatch.context() as fault:
|
||||||
|
fault.setattr(shutil, "rmtree", fail_removal)
|
||||||
|
failure = store.cleanup_quarantined(quarantined.name)
|
||||||
|
assert failure is not None and failure.code is FailureCode.QUARANTINE_FAILED and failure.retryable
|
||||||
|
assert not quarantined.exists()
|
||||||
|
assert len(list(store.quarantine_dir.glob(f"{identity.key}.cleanup.*"))) == 1
|
||||||
|
|
||||||
|
if recovery == "cleanup":
|
||||||
|
assert store.cleanup_quarantined(quarantined.name) is None
|
||||||
|
else:
|
||||||
|
# A real build (not a warm hit) reconciles the same tombstone protocol.
|
||||||
|
_corrupt_test_payload(artifact)
|
||||||
|
result = store.get_or_build(
|
||||||
|
BuildRequest(identity),
|
||||||
|
FakeProducer(identity),
|
||||||
|
validation=ValidationLevel.FULL_CHECKSUM,
|
||||||
|
deadline=time.monotonic() + 10,
|
||||||
|
)
|
||||||
|
assert result.status is StoreStatus.BUILT
|
||||||
|
assert result.lease is not None
|
||||||
|
assert torch.equal(result.lease.tensors["layer.weight"], _weight())
|
||||||
|
result.lease.close()
|
||||||
|
assert not list(store.quarantine_dir.glob(f"{identity.key}.cleanup.*"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("after_removal", [False, True])
|
||||||
|
def test_cleanup_quarantined_retries_parent_sync_failure(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, after_removal: bool
|
||||||
|
) -> None:
|
||||||
|
store = _make_store(tmp_path / "store")
|
||||||
|
identity, _ = _publish_test_artifact(store)
|
||||||
|
quarantined = _quarantine_and_rebuild(store, identity)
|
||||||
|
original_sync = filesystem_store_module._fsync_directory
|
||||||
|
|
||||||
|
def fail_sync(path: Path) -> None:
|
||||||
|
if path == store.quarantine_dir and (
|
||||||
|
not after_removal or not list(store.quarantine_dir.glob(f"{identity.key}.cleanup.*"))
|
||||||
|
):
|
||||||
|
raise OSError(errno.EIO, "injected quarantine sync failure")
|
||||||
|
original_sync(path)
|
||||||
|
|
||||||
|
with monkeypatch.context() as fault:
|
||||||
|
fault.setattr(filesystem_store_module, "_fsync_directory", fail_sync)
|
||||||
|
failure = store.cleanup_quarantined(quarantined.name)
|
||||||
|
assert failure is not None and failure.code is FailureCode.QUARANTINE_FAILED and failure.retryable
|
||||||
|
assert not quarantined.exists()
|
||||||
|
assert bool(list(store.quarantine_dir.glob(f"{identity.key}.cleanup.*"))) is not after_removal
|
||||||
|
|
||||||
|
# An already absent selection must still retry the failed durability step.
|
||||||
|
with monkeypatch.context() as fault:
|
||||||
|
fault.setattr(filesystem_store_module, "_fsync_directory", fail_sync)
|
||||||
|
assert store.cleanup_quarantined(quarantined.name) is not None
|
||||||
|
assert store.cleanup_quarantined(quarantined.name) is None
|
||||||
|
assert not list(store.quarantine_dir.glob(f"{identity.key}.cleanup.*"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("entry_kind", ["symlink", "file"])
|
||||||
|
def test_cleanup_quarantined_rejects_non_directory_entry(tmp_path: Path, entry_kind: str) -> None:
|
||||||
|
store = _make_store(tmp_path / "store")
|
||||||
|
identity, artifact = _publish_test_artifact(store)
|
||||||
|
entry = store.quarantine_dir / f"{identity.key}.validation_failure.test"
|
||||||
|
if entry_kind == "symlink":
|
||||||
|
entry.symlink_to(artifact, target_is_directory=True)
|
||||||
|
else:
|
||||||
|
entry.write_text("not an artifact directory")
|
||||||
|
failure = store.cleanup_quarantined(entry.name)
|
||||||
|
assert failure is not None and failure.code is FailureCode.QUARANTINE_FAILED
|
||||||
|
assert not failure.retryable
|
||||||
|
assert entry.exists()
|
||||||
|
hit = store.lookup(identity, validation=ValidationLevel.FULL_CHECKSUM)
|
||||||
|
assert hit.lease is not None
|
||||||
|
assert torch.equal(hit.lease.tensors["layer.weight"], _weight())
|
||||||
|
hit.lease.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
["../escape", "/tmp/escape", "f" * 64, "g" * 64 + ".x", "f" * 64 + ".", "f" * 64 + ".x/../y", "f" * 64 + ".x\0"],
|
||||||
|
)
|
||||||
|
def test_cleanup_quarantined_rejects_invalid_selection_before_mutation(tmp_path: Path, name: str) -> None:
|
||||||
|
store = _make_store(tmp_path / "store")
|
||||||
|
locks = set(store.locks_dir.iterdir())
|
||||||
|
with pytest.raises(ValueError, match="inventory basename"):
|
||||||
|
store.cleanup_quarantined(name)
|
||||||
|
assert set(store.locks_dir.iterdir()) == locks
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_quarantined_restores_bounded_store_recovery_headroom(tmp_path: Path) -> None:
|
||||||
|
store = _make_store(tmp_path / "store", capacity=CapacityPolicy(max_store_bytes=16384))
|
||||||
|
identity = _identity()
|
||||||
|
for _ in range(32):
|
||||||
|
result = store.get_or_build(
|
||||||
|
BuildRequest(identity),
|
||||||
|
FakeProducer(identity),
|
||||||
|
validation=ValidationLevel.FULL_CHECKSUM,
|
||||||
|
deadline=time.monotonic() + 10,
|
||||||
|
)
|
||||||
|
if result.status is StoreStatus.FAILED:
|
||||||
|
break
|
||||||
|
assert result.lease is not None
|
||||||
|
result.lease.close()
|
||||||
|
_corrupt_test_payload(store.artifacts_dir / identity.key)
|
||||||
|
assert result.failure is not None and result.failure.code is FailureCode.STORE_LIMIT_EXCEEDED
|
||||||
|
quarantined = [
|
||||||
|
entry for entry in store.inspect_domain().inventory if entry.state is ArtifactInventoryState.QUARANTINED
|
||||||
|
]
|
||||||
|
assert len(quarantined) > 1
|
||||||
|
assert store.cleanup(identity) is None
|
||||||
|
still_full = store.get_or_build(
|
||||||
|
BuildRequest(identity),
|
||||||
|
FakeProducer(identity),
|
||||||
|
validation=ValidationLevel.FULL_CHECKSUM,
|
||||||
|
deadline=time.monotonic() + 10,
|
||||||
|
)
|
||||||
|
assert still_full.failure is not None and still_full.failure.code is FailureCode.STORE_LIMIT_EXCEEDED
|
||||||
|
|
||||||
|
assert store.cleanup_quarantined(quarantined[0].storage_name) is None
|
||||||
|
recovered = store.get_or_build(
|
||||||
|
BuildRequest(identity),
|
||||||
|
FakeProducer(identity),
|
||||||
|
validation=ValidationLevel.FULL_CHECKSUM,
|
||||||
|
deadline=time.monotonic() + 10,
|
||||||
|
)
|
||||||
|
assert recovered.status is StoreStatus.BUILT
|
||||||
|
assert recovered.lease is not None
|
||||||
|
assert torch.equal(recovered.lease.tensors["layer.weight"], _weight())
|
||||||
|
recovered.lease.close()
|
||||||
|
|
||||||
|
|
||||||
def test_domain_capacity_policy_is_authoritative(tmp_path: Path) -> None:
|
def test_domain_capacity_policy_is_authoritative(tmp_path: Path) -> None:
|
||||||
root = tmp_path / "store"
|
root = tmp_path / "store"
|
||||||
_make_store(root, capacity=CapacityPolicy(max_store_bytes=4096))
|
_make_store(root, capacity=CapacityPolicy(max_store_bytes=4096))
|
||||||
|
|||||||
@@ -1466,43 +1466,45 @@ class FilesystemHostWeightStore:
|
|||||||
total += file_stat.st_size
|
total += file_stat.st_size
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _cleanup_locks(self, key: str) -> Iterator[None]:
|
||||||
|
"""Exclude cooperative builders and leases without waiting for them."""
|
||||||
|
try:
|
||||||
|
build_lock = FileLock(self._build_lock_path(key), exclusive=True, deadline=None, nonblocking=True)
|
||||||
|
except FileLockTimeoutError as exc:
|
||||||
|
raise HostWeightError(
|
||||||
|
_failure(
|
||||||
|
ResolutionStage.LIFECYCLE,
|
||||||
|
FailureCode.ACTIVE_BUILD_TIMEOUT,
|
||||||
|
f"artifact {key} has an active builder",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
) from exc
|
||||||
|
with build_lock:
|
||||||
|
try:
|
||||||
|
artifact_lock = FileLock(self._artifact_lock_path(key), exclusive=True, deadline=None, nonblocking=True)
|
||||||
|
except FileLockTimeoutError as exc:
|
||||||
|
raise HostWeightError(
|
||||||
|
_failure(
|
||||||
|
ResolutionStage.LIFECYCLE,
|
||||||
|
FailureCode.ACTIVE_LEASE,
|
||||||
|
f"artifact {key} has an active lease",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
) from exc
|
||||||
|
with artifact_lock:
|
||||||
|
yield
|
||||||
|
|
||||||
def cleanup(self, identity: WeightArtifactIdentity) -> HostWeightFailure | None:
|
def cleanup(self, identity: WeightArtifactIdentity) -> HostWeightFailure | None:
|
||||||
"""Explicitly remove an inactive artifact without waiting for leases."""
|
"""Explicitly remove an inactive artifact without waiting for leases."""
|
||||||
try:
|
try:
|
||||||
try:
|
with self._cleanup_locks(identity.key):
|
||||||
build_lock = FileLock(
|
self._repair_quarantine_transitions_locked(identity.key)
|
||||||
self._build_lock_path(identity.key),
|
self._quarantine_locked(identity.key, reason="cleanup")
|
||||||
exclusive=True,
|
self._remove_cleanup_tombstones_locked(identity.key)
|
||||||
deadline=None,
|
self._remove_deny_locked(identity.key)
|
||||||
nonblocking=True,
|
except HostWeightError as exc:
|
||||||
)
|
return exc.failure
|
||||||
except FileLockTimeoutError:
|
|
||||||
return _failure(
|
|
||||||
ResolutionStage.LIFECYCLE,
|
|
||||||
FailureCode.ACTIVE_BUILD_TIMEOUT,
|
|
||||||
f"artifact {identity.key} has an active builder",
|
|
||||||
retryable=True,
|
|
||||||
)
|
|
||||||
with build_lock:
|
|
||||||
try:
|
|
||||||
artifact_lock = FileLock(
|
|
||||||
self._artifact_lock_path(identity.key),
|
|
||||||
exclusive=True,
|
|
||||||
deadline=None,
|
|
||||||
nonblocking=True,
|
|
||||||
)
|
|
||||||
except FileLockTimeoutError:
|
|
||||||
return _failure(
|
|
||||||
ResolutionStage.LIFECYCLE,
|
|
||||||
FailureCode.ACTIVE_LEASE,
|
|
||||||
f"artifact {identity.key} has an active lease",
|
|
||||||
retryable=True,
|
|
||||||
)
|
|
||||||
with artifact_lock:
|
|
||||||
self._repair_quarantine_transitions_locked(identity.key)
|
|
||||||
self._quarantine_locked(identity.key, reason="cleanup")
|
|
||||||
self._remove_cleanup_tombstones_locked(identity.key)
|
|
||||||
self._remove_deny_locked(identity.key)
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return _failure(
|
return _failure(
|
||||||
ResolutionStage.LIFECYCLE,
|
ResolutionStage.LIFECYCLE,
|
||||||
@@ -1512,6 +1514,50 @@ class FilesystemHostWeightStore:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def cleanup_quarantined(self, storage_name: str) -> HostWeightFailure | None:
|
||||||
|
"""Remove one quarantine inventory entry, preserving the current artifact.
|
||||||
|
|
||||||
|
A retry also finishes earlier cleanup tombstones for the same key.
|
||||||
|
Active builders or leases are reported rather than interrupted.
|
||||||
|
"""
|
||||||
|
key, separator, suffix = storage_name.partition(".")
|
||||||
|
if (
|
||||||
|
not separator
|
||||||
|
or not suffix
|
||||||
|
or "\0" in storage_name
|
||||||
|
or _ARTIFACT_KEY_RE.fullmatch(key) is None
|
||||||
|
or Path(storage_name).name != storage_name
|
||||||
|
):
|
||||||
|
raise ValueError("quarantined storage_name must be a single inventory basename with an artifact key")
|
||||||
|
try:
|
||||||
|
with self._cleanup_locks(key):
|
||||||
|
entry = self.quarantine_dir / storage_name
|
||||||
|
exists = _path_exists_without_following(entry)
|
||||||
|
if exists and not stat.S_ISDIR(entry.lstat().st_mode):
|
||||||
|
return _failure(
|
||||||
|
ResolutionStage.LIFECYCLE,
|
||||||
|
FailureCode.QUARANTINE_FAILED,
|
||||||
|
f"quarantine entry is not a directory: {storage_name}",
|
||||||
|
)
|
||||||
|
self._repair_quarantine_transitions_locked(key)
|
||||||
|
if exists and not storage_name.startswith(f"{key}.cleanup."):
|
||||||
|
tombstone = self.quarantine_dir / f"{key}.cleanup.{uuid.uuid4().hex}"
|
||||||
|
_move_artifact_locked(entry, tombstone)
|
||||||
|
self._remove_cleanup_tombstones_locked(key)
|
||||||
|
# A previous attempt may have removed the tombstone but failed
|
||||||
|
# its parent sync. Retry that sync even when no entry remains.
|
||||||
|
_fsync_directory(self.quarantine_dir)
|
||||||
|
except HostWeightError as exc:
|
||||||
|
return exc.failure
|
||||||
|
except OSError as exc:
|
||||||
|
return _failure(
|
||||||
|
ResolutionStage.LIFECYCLE,
|
||||||
|
FailureCode.QUARANTINE_FAILED,
|
||||||
|
f"failed to remove quarantined entry {storage_name}: {exc}",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
def inspect_domain(self) -> DomainInspection:
|
def inspect_domain(self) -> DomainInspection:
|
||||||
usage = shutil.disk_usage(self.root)
|
usage = shutil.disk_usage(self.root)
|
||||||
inventory: list[ArtifactInventoryEntry] = []
|
inventory: list[ArtifactInventoryEntry] = []
|
||||||
|
|||||||
Reference in new issue
Block a user