Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16257e65a6 | ||
|
|
4b11e1eeb3 | ||
|
|
3854aabd0d | ||
|
|
4699de61cc | ||
|
|
3f94c19162 | ||
|
|
7af6bdcf93 | ||
|
|
ddf7d6710b | ||
|
|
dd2ae33ee1 | ||
|
|
2363d970c2 |
@@ -1,144 +0,0 @@
|
|||||||
name: Publish verified container image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
verify:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Reject non-version publication events
|
|
||||||
env:
|
|
||||||
PUBLICATION_EVENT: ${{ github.event_name }}
|
|
||||||
PUBLICATION_REF: ${{ github.ref }}
|
|
||||||
run: |
|
|
||||||
if [[ "${PUBLICATION_EVENT}" != "push" \
|
|
||||||
|| ! "${PUBLICATION_REF}" =~ ^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
|
||||||
echo "publication requires a strict vX.Y.Z tag push" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check out exact source commit
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Reverify exact checked out commit
|
|
||||||
run: python scripts/image_release.py verify-checkout --commit "${GITHUB_SHA}"
|
|
||||||
|
|
||||||
- name: Set up Python 3.12
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install repository checks
|
|
||||||
run: |
|
|
||||||
python -m pip install "uv==0.11.14"
|
|
||||||
uv sync --extra dev --locked
|
|
||||||
|
|
||||||
- name: Run repository lint
|
|
||||||
run: uv run ruff check .
|
|
||||||
|
|
||||||
- name: Run repository tests
|
|
||||||
run: uv run pytest
|
|
||||||
|
|
||||||
- name: Create excluded synthetic canary
|
|
||||||
run: |
|
|
||||||
python - <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
Path(".env.ci-canary").write_text("TREX_SECRET_CANARY_DO_NOT_SHIP\n", encoding="ascii")
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Build source image once
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
--provenance=false \
|
|
||||||
--build-arg "SOURCE_COMMIT=${GITHUB_SHA}" \
|
|
||||||
--tag "trex-core-ci:${GITHUB_SHA}" \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Smoke built image
|
|
||||||
run: python scripts/image_release.py smoke --image "trex-core-ci:${GITHUB_SHA}"
|
|
||||||
|
|
||||||
- name: Record tested image ID
|
|
||||||
run: |
|
|
||||||
python scripts/image_release.py record-image-id \
|
|
||||||
--image "trex-core-ci:${GITHUB_SHA}" \
|
|
||||||
--output trex-core-image.id
|
|
||||||
|
|
||||||
- name: Export tested image
|
|
||||||
run: docker save --output trex-core-image.tar "trex-core-ci:${GITHUB_SHA}"
|
|
||||||
|
|
||||||
- name: Scan all canary surfaces
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
python scripts/image_release.py scan \
|
|
||||||
--image "trex-core-ci:${GITHUB_SHA}" \
|
|
||||||
--archive trex-core-image.tar \
|
|
||||||
>canary-scan.stdout 2>canary-scan.stderr
|
|
||||||
scan_status=$?
|
|
||||||
set -e
|
|
||||||
python scripts/image_release.py assert-clean-output \
|
|
||||||
canary-scan.stdout canary-scan.stderr
|
|
||||||
exit "${scan_status}"
|
|
||||||
|
|
||||||
- name: Record tested archive checksum
|
|
||||||
run: |
|
|
||||||
python scripts/image_release.py checksum \
|
|
||||||
--archive trex-core-image.tar \
|
|
||||||
--output trex-core-image.tar.sha256
|
|
||||||
|
|
||||||
- name: Upload immutable tested image artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
if-no-files-found: error
|
|
||||||
overwrite: false
|
|
||||||
retention-days: 7
|
|
||||||
path: |
|
|
||||||
trex-core-image.tar
|
|
||||||
trex-core-image.tar.sha256
|
|
||||||
trex-core-image.id
|
|
||||||
scripts/image_release.py
|
|
||||||
|
|
||||||
publish:
|
|
||||||
needs: verify
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
outputs:
|
|
||||||
image: ${{ steps.publish.outputs.image }}
|
|
||||||
digest: ${{ steps.publish.outputs.digest }}
|
|
||||||
source_commit: ${{ steps.publish.outputs.source_commit }}
|
|
||||||
version_tag: ${{ steps.publish.outputs.version_tag }}
|
|
||||||
steps:
|
|
||||||
- name: Download tested image artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
path: tested-image
|
|
||||||
|
|
||||||
- name: Verify, load, and publish without rebuilding
|
|
||||||
id: publish
|
|
||||||
env:
|
|
||||||
GITHUB_ACTOR: ${{ github.actor }}
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
python tested-image/scripts/image_release.py publish \
|
|
||||||
--archive tested-image/trex-core-image.tar \
|
|
||||||
--checksum tested-image/trex-core-image.tar.sha256 \
|
|
||||||
--image-id tested-image/trex-core-image.id \
|
|
||||||
--local-image "trex-core-ci:${GITHUB_SHA}" \
|
|
||||||
--commit "${GITHUB_SHA}" \
|
|
||||||
--event-name "${GITHUB_EVENT_NAME}" \
|
|
||||||
--ref "${GITHUB_REF}"
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
name: Verify published trex-core image
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Strict release tag (for example v0.2.0)"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
commit:
|
|
||||||
description: "Exact 40-character lowercase source commit"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
verify:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- name: Validate immutable selectors and image contract
|
|
||||||
env:
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
VERSION: ${{ inputs.version }}
|
|
||||||
COMMIT: ${{ inputs.commit }}
|
|
||||||
IMAGE: ghcr.io/fourblocks2020/trex-core
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
|
||||||
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]]
|
|
||||||
short_sha="${COMMIT:0:12}"
|
|
||||||
|
|
||||||
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io --username "$ACTOR" --password-stdin >/dev/null
|
|
||||||
version_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$VERSION")"
|
|
||||||
source_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$short_sha")"
|
|
||||||
version_digest="sha256:$(printf '%s' "$version_raw" | sha256sum | cut -d' ' -f1)"
|
|
||||||
source_digest="sha256:$(printf '%s' "$source_raw" | sha256sum | cut -d' ' -f1)"
|
|
||||||
test "$version_digest" = "$source_digest"
|
|
||||||
|
|
||||||
docker pull "$IMAGE@$version_digest" >/dev/null
|
|
||||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Architecture}}')" = amd64
|
|
||||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Config.User}}')" = 10001:10001
|
|
||||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$COMMIT"
|
|
||||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.source"}}')" = https://github.com/fourblocks2020/trex-core
|
|
||||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{json .Config.Entrypoint}}')" = '["trex-core-daemon"]'
|
|
||||||
test "$(docker run --rm --entrypoint python "$IMAGE@$version_digest" -c 'from trex_core.adapters.persistence.migrate import migration_files; p=migration_files(); print(f"count={len(p)} last={p[-1].name}")')" = 'count=21 last=022_persist_order_timeout_anchor.sql'
|
|
||||||
|
|
||||||
if docker buildx imagetools inspect "$IMAGE:latest" >/dev/null 2>&1; then
|
|
||||||
echo "mutable latest selector exists" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "digest=$version_digest"
|
|
||||||
echo "version_selector=$IMAGE:$VERSION"
|
|
||||||
echo "source_selector=$IMAGE:$short_sha"
|
|
||||||
echo "immutable=$IMAGE@$version_digest"
|
|
||||||
{
|
|
||||||
echo "## Verified published trex-core image"
|
|
||||||
echo
|
|
||||||
echo "- digest: \`$version_digest\`"
|
|
||||||
echo "- version selector: \`$IMAGE:$VERSION\`"
|
|
||||||
echo "- source selector: \`$IMAGE:$short_sha\`"
|
|
||||||
echo "- source commit: \`$COMMIT\`"
|
|
||||||
echo "- architecture: \`linux/amd64\`"
|
|
||||||
echo "- runtime user: \`10001:10001\`"
|
|
||||||
echo "- entrypoint: \`trex-core-daemon\`"
|
|
||||||
echo "- migration inventory: \`21\`, last \`022_persist_order_timeout_anchor.sql\`"
|
|
||||||
echo "- latest: absent"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
id: "ac-0004"
|
id: "ac-0004"
|
||||||
status: active
|
status: retired
|
||||||
---
|
---
|
||||||
|
|
||||||
# strict version-tag 검증 성공 이미지에 한정된 GHCR 게시
|
# strict version-tag 검증 성공 이미지에 한정된 GHCR 게시
|
||||||
@@ -13,4 +13,4 @@ GitHub Actions publication workflow는 strict `vX.Y.Z` tag push만 후보로 받
|
|||||||
|
|
||||||
- 성공 조건: synthetic strict tag-push fixture에서 exact checkout 재검증, full repository verification, single build, smoke, tested-artifact 생성·전달·checksum·load 검증을 모두 통과해야 publish job의 login/push가 실행 가능하고, 두 push가 같은 유효 digest를 반환한 뒤에만 성공 evidence가 생성된다.
|
- 성공 조건: synthetic strict tag-push fixture에서 exact checkout 재검증, full repository verification, single build, smoke, tested-artifact 생성·전달·checksum·load 검증을 모두 통과해야 publish job의 login/push가 실행 가능하고, 두 push가 같은 유효 digest를 반환한 뒤에만 성공 evidence가 생성된다.
|
||||||
- 실패·경계 조건: non-push, branch, malformed/leading-zero/prerelease/build tag, invalid source commit, checkout `HEAD` mismatch, repository 검증·build·smoke·artifact·checksum·load 실패는 registry credential/write 전에 nonzero로 종료된다. post-push digest 오류·불일치는 nonzero와 성공 evidence 부재로 이어진다.
|
- 실패·경계 조건: non-push, branch, malformed/leading-zero/prerelease/build tag, invalid source commit, checkout `HEAD` mismatch, repository 검증·build·smoke·artifact·checksum·load 실패는 registry credential/write 전에 nonzero로 종료된다. post-push digest 오류·불일치는 nonzero와 성공 evidence 부재로 이어진다.
|
||||||
- 검사 방법: `tests/release/test_image_contracts.py`, `tests/release/test_image_release.py`의 workflow parser·recording-runner tests로 trigger, guard 순서, exact checkout, `publish.needs`, publish-job-only `packages: write`, registry write 전후 실패 경계를 검증하고 전체 Ruff·pytest를 실행한다. 실제 tag push·GHCR write는 별도 승인된 publication evidence로 확인한다.
|
- 검사 방법: `tests/release/test_image_contracts.py`, `tests/release/test_image_release.py`의 workflow parser·recording-runner tests로 trigger, guard 순서, exact checkout, `publish.needs`, publish-job-only `packages: write`, registry write 전후 실패 경계를 검증하고 전체 Ruff·pytest를 실행한다. 실제 tag push·GHCR write는 별도 승인된 publication evidence로 확인한다.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
id: "ac-0005"
|
id: "ac-0005"
|
||||||
status: active
|
status: retired
|
||||||
---
|
---
|
||||||
|
|
||||||
# 검증 이미지와 게시 이미지의 동일성
|
# 검증 이미지와 게시 이미지의 동일성
|
||||||
@@ -13,4 +13,4 @@ verify job은 한 번 빌드하여 built-container smoke를 통과한 `trex-core
|
|||||||
|
|
||||||
- 성공 조건: smoke가 성공한 local image를 export한 archive의 SHA-256과 publish job이 download한 archive의 SHA-256이 일치하고, checksum 확인 뒤 load된 image가 GHCR tag의 유일한 원본이 된다.
|
- 성공 조건: smoke가 성공한 local image를 export한 archive의 SHA-256과 publish job이 download한 archive의 SHA-256이 일치하고, checksum 확인 뒤 load된 image가 GHCR tag의 유일한 원본이 된다.
|
||||||
- 실패·경계 조건: archive 또는 checksum 누락, archive byte 변조, checksum 불일치, load 실패, publish job의 checkout·build 또는 별도 source-based image 생성은 registry login/push 전 nonzero로 종료된다.
|
- 실패·경계 조건: archive 또는 checksum 누락, archive byte 변조, checksum 불일치, load 실패, publish job의 checkout·build 또는 별도 source-based image 생성은 registry login/push 전 nonzero로 종료된다.
|
||||||
- 검사 방법: workflow 구조 테스트로 smoke→save→checksum→upload 및 download→checksum compare→load→tag→push 순서를 검사하고, recording-runner 단위 테스트에서 원본·변조 archive fixture를 사용해 mismatch 시 load/login/push가 호출되지 않음을 검증한다.
|
- 검사 방법: workflow 구조 테스트로 smoke→save→checksum→upload 및 download→checksum compare→load→tag→push 순서를 검사하고, recording-runner 단위 테스트에서 원본·변조 archive fixture를 사용해 mismatch 시 load/login/push가 호출되지 않음을 검증한다.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
id: "ac-0006"
|
id: "ac-0006"
|
||||||
status: active
|
status: retired
|
||||||
---
|
---
|
||||||
|
|
||||||
# requester version tag·source tag와 immutable digest 추적성
|
# requester version tag·source tag와 immutable digest 추적성
|
||||||
@@ -13,4 +13,4 @@ status: active
|
|||||||
|
|
||||||
- 성공 조건: strict `vX.Y.Z` tag event는 같은 loaded image에 `:<source-commit-first-12>`와 `:vX.Y.Z`를 만들고 두 push가 동일한 digest를 반환한다. OCI revision과 output·summary의 immutable image reference, digest, source commit, version tag가 event와 push 결과에 정확히 일치하며 package version 값은 tag 생성·검증에 사용되지 않는다. 운영 문서는 version authority·package version 독립성·별도 승인 tag push 경계를 모두 설명하고 기존 published identity를 보존한다.
|
- 성공 조건: strict `vX.Y.Z` tag event는 같은 loaded image에 `:<source-commit-first-12>`와 `:vX.Y.Z`를 만들고 두 push가 동일한 digest를 반환한다. OCI revision과 output·summary의 immutable image reference, digest, source commit, version tag가 event와 push 결과에 정확히 일치하며 package version 값은 tag 생성·검증에 사용되지 않는다. 운영 문서는 version authority·package version 독립성·별도 승인 tag push 경계를 모두 설명하고 기존 published identity를 보존한다.
|
||||||
- 실패·경계 조건: source commit 또는 version tag 형식 오류, SHA tag 파생 불일치, version/package version 일치 강제, tag별 별도 build, `latest` 생성, OCI revision 불일치, digest 누락·형식 오류·tag 간 불일치가 있으면 성공 evidence를 생성할 수 없다. 기존 published tag·digest의 삭제·이동·재지정 또는 operator documentation의 version authority·별도 승인 경계 누락도 FAIL이다. push 이후 오류는 job nonzero로 유지한다.
|
- 실패·경계 조건: source commit 또는 version tag 형식 오류, SHA tag 파생 불일치, version/package version 일치 강제, tag별 별도 build, `latest` 생성, OCI revision 불일치, digest 누락·형식 오류·tag 간 불일치가 있으면 성공 evidence를 생성할 수 없다. 기존 published tag·digest의 삭제·이동·재지정 또는 operator documentation의 version authority·별도 승인 경계 누락도 FAIL이다. push 이후 오류는 job nonzero로 유지한다.
|
||||||
- 검사 방법: `scripts/image_release.py`의 trigger/tag/publish helper 단위 테스트와 workflow contract test로 deterministic tag, same-loaded-image, no rebuild, same digest, no-`latest`와 output·summary exactness를 검증하고 Docker image label을 inspect한다. `docs/operations.md` contract test로 Git tag authority, package version 독립성과 requester-approved tag creation/push 경계를 검사하고, workflow·helper가 기존 published registry identity를 삭제·이동·재지정하는 command를 포함하지 않는지 정적 검사한다. 실제 registry의 두 tag·digest와 기존 identity 보존은 별도 승인된 publication run에서 immutable evidence로 확인한다.
|
- 검사 방법: `scripts/image_release.py`의 trigger/tag/publish helper 단위 테스트와 workflow contract test로 deterministic tag, same-loaded-image, no rebuild, same digest, no-`latest`와 output·summary exactness를 검증하고 Docker image label을 inspect한다. `docs/operations.md` contract test로 Git tag authority, package version 독립성과 requester-approved tag creation/push 경계를 검사하고, workflow·helper가 기존 published registry identity를 삭제·이동·재지정하는 command를 포함하지 않는지 정적 검사한다. 실제 registry의 두 tag·digest와 기존 identity 보존은 별도 승인된 publication run에서 immutable evidence로 확인한다.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
id: "ac-0007"
|
id: "ac-0007"
|
||||||
status: active
|
status: retired
|
||||||
---
|
---
|
||||||
|
|
||||||
# built-container package 및 entrypoint smoke
|
# built-container package 및 entrypoint smoke
|
||||||
@@ -13,4 +13,4 @@ GHCR 게시 후보는 source tree가 아니라 실제로 빌드된 image 안에
|
|||||||
|
|
||||||
- 성공 조건: network가 차단되고 운영 config·credential이 없는 최소권한 container에서 기존 네 probe와 installed migration inventory probe가 각각 정해진 timeout 안에 exit code `0`으로 끝나며 container가 정리된다. 현재 회귀 기준선에는 canonical SQL 파일 20개가 있으나, 영구 성공 조건은 고정 수량이 아니라 해당 source commit의 non-empty exact inventory parity이다.
|
- 성공 조건: network가 차단되고 운영 config·credential이 없는 최소권한 container에서 기존 네 probe와 installed migration inventory probe가 각각 정해진 timeout 안에 exit code `0`으로 끝나며 container가 정리된다. 현재 회귀 기준선에는 canonical SQL 파일 20개가 있으나, 영구 성공 조건은 고정 수량이 아니라 해당 source commit의 non-empty exact inventory parity이다.
|
||||||
- 실패·경계 조건: package import 실패, entrypoint 누락, installed migration inventory의 empty·누락·추가·filename mismatch, nonzero exit, timeout, 외부 연결 시도 또는 cleanup 실패는 smoke nonzero로 이어지고 image publish를 차단한다. `trex-core-reset` probe에는 `--yes` 또는 config argument를 전달하지 않는다.
|
- 실패·경계 조건: package import 실패, entrypoint 누락, installed migration inventory의 empty·누락·추가·filename mismatch, nonzero exit, timeout, 외부 연결 시도 또는 cleanup 실패는 smoke nonzero로 이어지고 image publish를 차단한다. `trex-core-reset` probe에는 `--yes` 또는 config argument를 전달하지 않는다.
|
||||||
- 검사 방법: built wheel의 SQL filename set을 source-controlled canonical set과 대조하고, built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 기존 네 command와 side-effect-free installed inventory probe, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하며 CI에서 실제 local image smoke를 실행한다. Inventory probe는 migration을 적용하거나 DB 또는 다른 service에 연결하지 않는다.
|
- 검사 방법: built wheel의 SQL filename set을 source-controlled canonical set과 대조하고, built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 기존 네 command와 side-effect-free installed inventory probe, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하며 CI에서 실제 local image smoke를 실행한다. Inventory probe는 migration을 적용하거나 DB 또는 다른 service에 연결하지 않는다.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
id: "ac-0026"
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
|
# Slot occupancy와 독립된 strategy state lifecycle
|
||||||
|
|
||||||
|
## Criterion
|
||||||
|
|
||||||
|
`trex-core`는 슬롯의 strategy-state map과 각 entry를 slot occupancy lifecycle과 독립적으로 보존해야 한다. Slot이 어떤 경로로든 `INACTIVE`가 되어도 일반 lifecycle transition은 strategy state를 삭제하지 않아야 하며, inactive 상태와 이후 재점유 상태에서 저장된 entry와 version을 다시 읽을 수 있어야 한다. 재점유 뒤 같은 entry에 성공한 write는 close 전 version에 이어서 증가해야 한다. Strategy-state persistence schema, application model, store protocol, read filtering, `strategy_state.set` request/response 및 state CAS에는 `occupancy_seq`나 `expected_occupancy_seq`가 없어야 하며, occupancy 변경만으로 state를 숨기거나 write를 `occupancy_changed`로 거부해서는 안 된다. Slot 자체의 `occupancy_seq`, active-slot write gate, opaque data size limit, slot transition의 나머지 원자성과 별도 파괴적 전체 DB reset은 변경하지 않는다.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 보존 조건: active slot의 instance entry를 version 1 이상으로 저장한 뒤 전략 매도, safety-sell, forced-close 및 operator close를 포함한 각 `→ INACTIVE` 경로를 실행해도 저장소 entry와 version이 삭제되지 않는다. Inactive slot 조회와 이후 재점유 조회에서 같은 data·version이 보이고, 재점유 뒤 기존 version을 `expected_version`으로 사용한 성공 write가 version을 1 증가시킨다.
|
||||||
|
- 격리 조건: 한 slot의 lifecycle transition이 해당 slot map의 일부 또는 전체 entry를 삭제하거나 다른 slot의 state를 변경하면 FAIL이다. Close-delete hook 제거 전후로 slot update, transition log, broker·ledger side effect와 transition failure 원자성이 기존 계약에서 달라지지 않아야 한다.
|
||||||
|
- Occupancy 분리 조건: strategy-state migration/schema, dataclass, in-memory/PostgreSQL store contract, read path와 write RPC payload/response에 `occupancy_seq` 또는 `expected_occupancy_seq`가 남거나, occupancy mismatch read filtering 또는 `occupancy_changed` reject path가 실행되면 FAIL이다. Slot domain의 자체 `occupancy_seq`와 증가 규칙은 그대로 유지되어야 한다.
|
||||||
|
- 경계 조건: inactive slot에 대한 신규 state write는 계속 `slot_inactive`로 거부되고 기존 state는 변경되지 않는다. Size limit 초과 write도 `state_too_large`로 거부되며 보존 중인 entry를 손상시키지 않는다. 명시적 전체 DB reset은 기존 범위의 데이터를 계속 제거할 수 있다.
|
||||||
|
- 검사 방법: InMemory와 PostgreSQL fixtures에서 close 경로별 보존, inactive read, 재점유, version 연속성, write gate와 size limit parity를 검증한다. 기존 slot transition 회귀 테스트, persistence migration/round-trip 테스트, production daemon wiring, 전체 repository test와 정적 검사를 실행한다.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
id: "ac-0027"
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
|
# Strategy-instance별 state identity와 version 격리
|
||||||
|
|
||||||
|
## Criterion
|
||||||
|
|
||||||
|
`trex-core`는 각 slot의 strategy state를 `data[strategy_instance_id] = {version, data, updated_at}` 형태의 하나의 slot-scoped map으로 저장해야 한다. `strategy_instance_id`는 entry의 저장·조회·CAS key이며 entry에 별도의 `strategy_id` metadata를 저장해서는 안 된다. `strategy_state.set`은 `{slot_id, strategy_instance_id, expected_version, data}`를 받아 required non-empty string instance ID, active slot, opaque data size limit와 해당 entry의 version만 검증해야 한다. Entry가 없을 때 `expected_version=0`인 성공 write는 version 1 entry를 만들고, 이후 성공 write는 그 entry의 version만 1 증가시켜야 한다. 같은 slot의 서로 다른 instance entry와 version은 서로 격리되어야 하며, 서로 다른 entry에 대한 동시 성공 write는 어느 entry도 유실하지 않고 모두 보존해야 한다. 기존 state에 대한 `strategy_id → strategy_instance_id` mapping·backfill·귀속 정책과 core-side instance registry를 도입해서는 안 된다.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 저장 형태: 한 slot에 instance A와 B를 저장하면 persistence round-trip 결과가 A·B key 아래 각각 `{version, data, updated_at}`를 보존하고 entry 내부에 `strategy_id`, `occupancy_seq` 또는 다른 identity metadata를 포함하지 않는다. Inner `data`는 입력한 opaque string과 byte 단위로 동일해야 한다.
|
||||||
|
- Version 조건: 새 A에 `expected_version=0` write는 A version 1을 만들고 후속 성공 write는 A만 증가시킨다. B의 version과 data는 변하지 않는다. 존재하는 A에 `expected_version=0` 또는 stale version을 보내면 `version_conflict`로 거부하고 A·B 어느 entry도 변경하지 않는다.
|
||||||
|
- 동시성 조건: 같은 slot의 A와 B에 동시에 유효한 write를 수행하면 둘 다 성공하고 두 entry의 새 data와 version이 모두 남아야 한다. 같은 entry에 경쟁하는 write는 version CAS에 따라 최대 한 건만 해당 expected version에서 성공하며 lost update가 없어야 한다.
|
||||||
|
- Identity 조건: 같은 전략 종류의 서로 다른 `strategy_instance_id`는 독립 entry를 사용하고 다른 ID의 state를 상속하지 않는다. 재시작 뒤 같은 ID를 사용하는 owner는 기존 entry를 이어 사용한다. 누락·비문자열·빈 ID write는 non-retryable `bad_request`로 거부되어 map을 만들거나 변경하지 않는다.
|
||||||
|
- 비변경 조건: active-slot gate와 data size limit를 우회하지 않으며 `slot_inactive`·`state_too_large`·`bad_request:*` 거부가 다른 entry를 변경하지 않는다. `correlation_id` 재사용만으로 write가 deduplicate되어서는 안 된다.
|
||||||
|
- 검사 방법: InMemory/PostgreSQL 공통 contract suite, 실제 PostgreSQL row-lock 또는 동등한 원자적 update fixture, migration 및 restart round-trip에서 create/update/conflict, same-type instance 격리, A·B 동시 write, same-entry 경쟁, malformed ID와 다른 entry 무변경을 검증한다.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
id: "ac-0028"
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
|
# 전략·operator slots RPC의 strategy state 가시성
|
||||||
|
|
||||||
|
## Criterion
|
||||||
|
|
||||||
|
`trex-core`의 `slots.req`는 전략 요청과 operator 조회의 strategy-state 가시성을 구분해야 한다. `spy=false`인 전략용 요청은 required non-empty string `strategy_instance_id`를 받아 각 반환 slot에서 `data[request.strategy_instance_id]`와 정확히 일치하는 entry 하나만 `strategy_state: {version, data, updated_at}` 단일 object로 제공해야 한다. 요청 ID에 해당하는 entry가 없으면 `strategy_state` field를 생략하고 다른 instance의 ID·state 또는 전체 map을 대신 반환해서는 안 된다. 누락·비문자열·빈 ID는 RPC-level error나 새 envelope 없이 `slots=[]`, `available_slots=null`, `degraded=false`, `reason="bad_request:strategy_instance_id"`, `message="strategy_instance_id must be a non-empty string"`인 non-retryable response를 반환해야 한다. `spy=true`인 operator 요청은 `strategy_instance_id`를 요구하지 않고 기존 filter·slot 조회와 watchdog 비갱신 동작을 유지하며, 각 반환 slot의 `strategy_state`에 전체 `strategy_instance_id → {version, data, updated_at}` map을 제공해야 한다. Operator 요청에 `strategy_instance_id`가 포함되어도 map을 특정 entry로 제한해서는 안 된다.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 전략 조회: 같은 slot map에 A와 B가 있을 때 `spy=false, strategy_instance_id=A` 요청은 `strategy_state`에 A의 `{version, data, updated_at}`만 반환하고 B의 ID·data와 outer map을 serialized payload 어디에도 포함하지 않는다. B 요청은 B만 반환하며 존재하지 않는 C 요청은 slot row의 `strategy_state`를 생략한다.
|
||||||
|
- 잘못된 전략 요청: 누락, `null`, boolean, number, collection 및 빈 문자열 ID 각각에서 exact 기존 envelope인 `slots=[]`, `available_slots=null`, `degraded=false`, `reason="bad_request:strategy_instance_id"`, `message="strategy_instance_id must be a non-empty string"`을 반환한다. Slot, state 또는 다른 instance ID가 노출되거나 watchdog이 갱신되면 FAIL이다.
|
||||||
|
- Operator 조회: `strategy_instance_id` 없이 `spy=true`로 기존 `universe`와 `slots` CLI가 성공하고 filter 결과와 watchdog 비갱신을 유지한다. A·B state가 있는 slot row의 `strategy_state`는 A와 B를 각각 `{version, data, updated_at}` entry로 포함한 전체 map이어야 한다. 같은 operator 요청에 A ID를 추가해도 B가 제거되지 않아야 한다.
|
||||||
|
- 기존 계약 조합: AC-0023에 따라 제외된 slot은 strategy-state 가시성 형식을 적용하기 전에 계속 제외되고 warn-store failure는 기존 fail-closed 결과를 유지한다. 포함되는 holding slot의 broker-authoritative quantity와 unavailable metadata는 AC-0022를 유지하며 state 선택 때문에 바뀌지 않는다.
|
||||||
|
- Wire 경계: 전략 응답의 단일 `strategy_state` object 안에는 `strategy_instance_id` key나 collection을 두지 않는다. Operator map의 key만 instance ID를 나타내며 각 value에는 identity metadata를 중복 저장하지 않는다. 별도 operator/diagnostic API, core-side instance registry 또는 caller 인증을 새로 요구하지 않는다.
|
||||||
|
- 검사 방법: slots handler contract tests에서 전략 A/B/absent/malformed 요청과 operator no-ID/ID-present 요청을 serialized response 기준으로 검증한다. Operator CLI, watchdog, reconciliation exclusion, broker quantity, InMemory/PostgreSQL read parity와 production daemon handler wiring 회귀 테스트를 함께 실행한다.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"next_line_number": 9,
|
"next_line_number": 10,
|
||||||
"next_ac_number": 26
|
"next_ac_number": 29
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
id: "dcy-0009"
|
||||||
|
status: confirmed
|
||||||
|
---
|
||||||
|
|
||||||
|
# Persistent strategy-instance state lifecycle and identity
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`slot_strategy_state`는 trex-core가 내용을 해석하지 않는 opaque 전략 상태이지만, 현재 저장·조회·삭제 계약은 그 상태의 lifecycle과 소유권을 core의 slot occupancy 및 `strategy_id`에 결합한다. 슬롯이 어떤 경로로든 `INACTIVE`가 되면 core가 해당 슬롯의 모든 전략 상태를 삭제하고, 저장된 `occupancy_seq`가 현재 슬롯과 다르면 조회에서 숨긴다. 이 계약은 TSL peak처럼 occupancy마다 새로 시작하는 상태에는 맞지만, TVR-v2 reserve처럼 close와 재점유를 넘어 같은 전략 실행 instance가 이어서 사용해야 하는 상태를 표현하지 못한다. Opaque `data`의 의미와 만료 시점을 모르는 core가 모든 전략에 동일한 삭제 정책을 강제한다는 점도 책임 경계와 맞지 않는다.
|
||||||
|
|
||||||
|
현재 row와 RPC namespace는 `(slot_id, strategy_id)`를 사용한다. 여기서 `strategy_id`는 `tvr_v2` 같은 전략 종류를 나타내므로, 같은 종류의 서로 다른 설정·실행 instance가 같은 슬롯을 사용하면 하나의 row와 version counter를 공유한다. 한 instance의 상태가 다른 instance에 상속되거나 서로의 CAS가 충돌할 수 있다.
|
||||||
|
|
||||||
|
`strategy_instance_id`는 state namespace이면서 전략 실행 instance의 안정적인 identity다. 현재 `slots.req`는 한 슬롯에서 조회된 모든 strategy-state namespace를 map으로 반환하지만, 일반 strategy가 필요한 것은 자기 `strategy_instance_id`에 해당하는 state뿐이다. 다른 strategy instance의 state를 함께 노출하면 consumer가 잘못된 entry를 선택할 여지가 있고 wire가 불필요하게 전체 namespace를 공개한다. `strategy_instance_id`는 매 요청에서 임의로 선택하는 값이 아니라 strategy runtime의 필수·불변 non-empty string 설정이어야 한다. 누락·비문자열·빈 값처럼 잘못 설정된 instance가 실행되면 state namespace 오염보다 잘못된 slot·queue·주문 경계에서 전략이 동작하는 문제가 더 크므로, 그런 runtime은 worker나 decision cycle을 시작할 수 없어야 한다.
|
||||||
|
|
||||||
|
기대 상태는 lifecycle과 identity 변경을 함께 다루는 것이다. Strategy state는 slot occupancy에서 완전히 분리되어 close와 재점유를 넘어 보존되고, 하나의 slot-scoped `data` map 안에서 안정적인 `strategy_instance_id`별 entry와 monotonic version을 가져야 한다. State persistence와 write RPC에서 `occupancy_seq`를 제거하고 동시 write는 요청 instance entry의 version CAS만으로 조정한다. 하나의 core `instance_key`에는 여러 strategy instance가 연결될 수 있으며 core가 그 목록을 별도 registry로 저장하지 않는다. 각 strategy runtime은 필수 설정으로 확정한 자기 `strategy_instance_id`만 `slots.req`와 state write에 사용하고, core는 전체 map이 아니라 요청 ID와 정확히 일치하는 entry 하나만 slots 응답에 넣어야 한다. 잘못된 설정이면 strategy는 실행 전에 fail-closed해야 한다.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- GitHub issue [#77](https://github.com/fourblocks2020/trex-core/issues/77)은 close 시 자동 삭제와 read-side occupancy filtering을 제거하고 state 의미·session 만료·갱신 판단을 전략에 돌리도록 요구한다. 본문에는 이전 설계의 `expected_occupancy_seq` CAS 유지가 남아 있다.
|
||||||
|
- GitHub issue [#78](https://github.com/fourblocks2020/trex-core/issues/78)은 그 뒤 확정된 persistence/write contract로서 state namespace와 RPC의 소유 키를 안정적인 `strategy_instance_id`로 바꾸고, 저장 요청을 `{slot_id, strategy_instance_id, expected_version, data}`로 정의한다. 사용자는 #78이 `occupancy_seq`에 관한 마지막 결정이며 이 계약이 #77의 `expected_occupancy_seq` 유지 문구보다 우선한다고 확인했다. 또한 slot persistence는 `data[strategy_instance_id] = {version, data, updated_at}` 형태의 slot-scoped map으로 저장한다고 확인했다. 따라서 이 draft는 strategy-state persistence·application model·write RPC·CAS에서 `occupancy_seq`를 제거하고 map entry별 version CAS만 유지한다.
|
||||||
|
- GitHub issue [#79](https://github.com/fourblocks2020/trex-core/issues/79)는 `slots.req` response에 관한 후속 최종 계약이다. 저장소에는 여러 `strategy_instance_id` entry가 함께 존재하지만 일반 전략 응답에는 요청의 `strategy_instance_id`와 정확히 일치하는 entry 하나만 `{version, data, updated_at}` 단일 object로 반환하고 다른 instance의 ID와 state를 포함하지 않는다. 사용자는 응답 필터 기준이 `strategy_id` 집합이 아니라 요청의 정확한 `strategy_instance_id`라고 확인했다. 이 Discovery에서는 core-side instance 목록 registry나 caller 인증을 두지 않으며, `strategy_instance_id`를 strategy runtime의 필수·불변 설정으로 사용한다.
|
||||||
|
|
||||||
|
- 현재 `main` revision `ddf7d6710bed9d0b8b91a3ce9c96ad17e129bd78`의 `trex_core/adapters/persistence/sql/015_create_slot_strategy_state.sql`은 `PRIMARY KEY (slot_id, strategy_id)`를 선언하고 `occupancy_seq`, `version`, opaque `data`를 저장한다.
|
||||||
|
- `trex_core/application/strategy_state.py`의 `StrategyStateEntry`, `StrategyStateStore`, `InMemoryStrategyStateStore`는 state를 `(slot_id, strategy_id)`로 식별한다. `get_for_slots()`는 저장된 `occupancy_seq`가 현재 값과 다르면 row를 숨기고, `delete_all(slot_id)`는 해당 slot의 모든 namespace를 삭제한다. `_cas()`는 `expected_occupancy_seq == current_occupancy_seq`와 expected/stored version 일치를 독립적으로 검사한다.
|
||||||
|
- `trex_core/adapters/persistence/postgresql.py`의 `PgStrategyStateStore`도 `(slot_id, strategy_id)`로 `SELECT ... FOR UPDATE` 및 upsert를 수행한다. Version은 기존 row가 없으면 0에서 시작하고 성공한 write마다 1 증가한다.
|
||||||
|
- `trex_core/domain/slot/service.py`의 `SlotService.transit_slot()`은 모든 `to_state is INACTIVE` 전이에서 `strategy_state_store.delete_all(slot_id)`을 호출한다. Strategy state 삭제는 slot update 및 transition-log append와 같은 transition 경로 안에서 실행된다.
|
||||||
|
- `trex_core/application/messaging_handlers.py`의 `make_slots_rpc_handler()`는 현재 occupancy와 일치하는 모든 state를 읽어 `strategy_state[strategy_id]` map 전체를 슬롯 row에 넣는다. `make_strategy_state_set_handler()`는 payload의 `strategy_id`를 필수로 받아 그대로 저장소에 전달하며 caller와 namespace의 소유 관계를 검사하지 않는다.
|
||||||
|
- 실제 write 호출은 strategy daemon이 AMQP direct-reply-to RPC로 instance별 `trex.slots.strategy_state.set[.{instance_key}]` queue에 요청하는 방식이다. 현재 TSL client는 `{slot_id, strategy_id, expected_occupancy_seq, expected_version, data}`를 보내며 `data`를 opaque JSON string으로 직렬화한다. 현재 구현의 `correlation_id`는 reply matching에만 사용되고 `expected_occupancy_seq`와 `expected_version` CAS가 각각 occupancy 전환과 version 충돌을 검사한다. 이 중 occupancy 결합은 #78의 최종 payload로 대체할 현재 baseline이다.
|
||||||
|
- `trex_core/runtime/daemon.py`는 `slots.req`와 `strategy_state.set` consumer를 `config.instance_key`별 queue에 연결하지만 두 handler에 strategy-instance registry를 주입하지 않는다. `trex_core/runtime/config.py`와 repository 전체를 `strategy_instance_id`로 검색한 결과 정의나 사용처는 0건이고, per-instance TOML에는 `instance_key`만 있다.
|
||||||
|
- `tests/domain/slot/test_occupancy_seq_and_close_delete.py`는 모든 close 경로의 자동 삭제와 재점유 후 빈 상태를 기대한다. `tests/application/test_slots_handler_strategy_state.py`는 재점유 시 이전 상태가 노출되지 않는 현재 계약을, `tests/application/test_strategy_state_set_handler.py`와 `tests/application/test_strategy_state_store.py`는 occupancy mismatch 거부와 `(slot_id, strategy_id)`별 version CAS를 검증한다.
|
||||||
|
- 위 네 focused test와 PostgreSQL 관련 test를 현재 revision에서 `strategy_state or occupancy_seq or close_delete`로 실행한 결과 `33 passed, 1 skipped, 9 deselected`였다. 이는 issue가 바꾸려는 현재 lifecycle·key·visibility baseline이 실행 가능한 상태임을 확인한다.
|
||||||
|
- Git history의 `e0d4e7130400bbfe576d5a6fc94b87dcc26f0734`는 issue #26과 legacy `dcy-028`의 “strategy state lifecycle은 slot occupancy와 일치한다”는 전제 아래 close-delete, occupancy-filtered read, `(slot_id, strategy_id)` key와 occupancy/version CAS를 함께 도입했다. 현재 `docs/runtime-flows.md` 349~359행과 `docs/interface-contract.md` 527~541행도 같은 C-1/C-2/C-3 계약을 설명한다.
|
||||||
|
- 현재 ProofLine active criteria 전체에서 `strategy_state`, `occupancy_seq`, `delete_all`을 검색한 결과 직접 소유하는 AC는 없었다. 가장 가까운 `AC-0023`은 C-case reconciliation warning에 따른 strategy-facing `slots.req` 제외와 intent blocking을 소유하며 state persistence와 namespace는 다루지 않는다.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 이번 Line은 strategy state의 lifecycle을 slot occupancy에서 분리하고, state namespace의 identity를 `strategy_instance_id`로 전환하는 변경을 함께 다룬다.
|
||||||
|
- 이 변경의 Acceptance Criteria는 version-independent 동작을 검증하는 서로 독립적인 신규 AC 세 건으로 분리한다.
|
||||||
|
- close·재점유를 넘어 strategy state와 version을 삭제하지 않고 보존하는 lifecycle criterion
|
||||||
|
- 슬롯별 state map에서 `strategy_instance_id`를 각 state entry의 key로 사용하고 같은 `strategy_id`의 instance별 state·version 격리를 보장하는 identity criterion
|
||||||
|
- `strategy_instance_id`를 전략용 request-scoped identity로 사용하여 `spy=false`인 `slots.req`가 해당 ID의 state만 반환하고 누락·비문자열·빈 ID 요청을 fail-closed하되, `spy=true`인 operator 조회는 `strategy_instance_id`를 요구하지 않고 watchdog을 갱신하지 않으며 각 슬롯의 전체 strategy-state map을 `strategy_instance_id`별 entry로 반환하는 criterion
|
||||||
|
- 기존 active AC의 `update`, `retire`, `satisfy`, `release evidence` 대상은 없다. 특히 `AC-0023`의 reconciliation warning 판정·slot 제외·intent blocking·clear 권한은 변경하지 않는다.
|
||||||
|
- Legacy Discovery와 REQ는 업데이트 대상이 아니다. 현재 `docs/runtime-flows.md`와 `docs/interface-contract.md`의 strategy-state baseline 문구만 새 AC가 승인된 뒤 approved change로 정합화한다.
|
||||||
|
- 어떤 경로의 `→ INACTIVE` 전이에서도 일반 lifecycle hook이 slot-scoped strategy-state map이나 그 entry를 자동 삭제하지 않게 한다. Map은 inactive와 이후 재점유 상태에서도 유지하며 현재 occupancy와 무관하게 다시 볼 수 있게 한다.
|
||||||
|
- Core는 opaque `data`를 파싱하거나 전략별 session·reserve·peak의 유효성을 판단하지 않는다. State의 session identity와 내용을 이어 쓸지 초기화할지는 전략이 결정한다.
|
||||||
|
- `occupancy_seq`는 slot-scoped strategy-state persistence schema, `StrategyStateEntry`, store protocol, read filtering, `strategy_state.set` request/response와 state CAS에서 제거한다. Slot 자체의 `occupancy_seq`와 증가 규칙은 이 Line에서 변경하지 않지만 strategy-state persistence와 write 승인에는 사용하지 않는다.
|
||||||
|
- State write는 `expected_version`과 slot map의 `data[strategy_instance_id].version`만 비교한다. 해당 entry가 없을 때 `expected_version=0`, 성공 시 그 entry의 version만 기존 값에서 1 증가하며 close·reopen은 version을 초기화하지 않는다. 다른 instance entry와 version은 변경하지 않는다. 같은 slot에서 서로 다른 instance entry를 동시에 갱신해도 각 성공 write가 모두 보존되고 어느 entry도 유실되지 않아야 한다. Slot active-state 검사와 inner opaque data size limit는 유지한다.
|
||||||
|
- 일반 lifecycle에는 strategy-state 삭제 동작을 두지 않는다. 기존의 파괴적 전체 DB reset은 별도 운영 기능으로 유지한다.
|
||||||
|
- Persistence, in-memory model, dataclass, store protocol과 PostgreSQL query/update는 슬롯별로 하나의 state map을 소유하고 `strategy_instance_id`를 각 state entry의 key로 사용하도록 전환한다. 각 entry는 `{version, data, updated_at}`를 가진다. `strategy_instance_id`가 entry의 저장·조회 key이며 별도의 `strategy_id` metadata를 entry에 중복 저장하지 않는다. 여기서 inner `data`만 전략이 소유하는 opaque string이며 outer map과 entry envelope은 core contract다. 아래 JSON은 설명용 응답 예시가 아니라 entry 전체를 그대로 보존하는 canonical persistence shape이다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"tvr-v2-prod-01": {
|
||||||
|
"version": 8,
|
||||||
|
"data": "{...}",
|
||||||
|
"updated_at": "..."
|
||||||
|
},
|
||||||
|
"new-v1-prod-01": {
|
||||||
|
"version": 7,
|
||||||
|
"data": "{...}",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 같은 `strategy_id`를 사용하는 서로 다른 `strategy_instance_id`는 동일 slot map에서도 독립된 entry와 version을 가져야 한다. 재시작 후 같은 instance ID를 사용하는 owner는 기존 entry를 이어 읽고, 다른 instance ID는 이를 상속하지 않는다.
|
||||||
|
- `strategy_state.set`은 `strategy_instance_id`를 필수 field로 받으며, core는 누락·비문자열·빈 값을 state 저장 없이 non-retryable `bad_request`로 거부한다. 별도의 core-side registry나 `instance_key ↔ strategy_instance_id` 목록 검증은 도입하지 않는다.
|
||||||
|
- 저장 순서는 `slots.req` read → 전략의 opaque state 계산 → `strategy_state.set` write다. 변경 후 write payload는 `{slot_id, strategy_instance_id, expected_version, data}`이며, `expected_version`은 직전 `slots.req`의 자기 `strategy_state.version`을 사용하고 entry가 없을 때만 `0`을 사용한다. Core는 active slot·해당 entry의 version CAS·inner data size limit를 모두 통과한 경우에만 slot map의 `data[strategy_instance_id]`를 create/update하고 증가한 version을 반환한다.
|
||||||
|
- `version_conflict`이면 전략은 추정한 값으로 재전송하지 않고 다음 `slots.req`에서 자기 state를 다시 읽어 재계산한다. `occupancy_changed` reason은 계약에서 제거한다. `slot_inactive`, `state_too_large`, `bad_request:*`는 해당 write를 적용하지 않는다. RPC `correlation_id`는 저장 멱등성 key로 사용하지 않는다.
|
||||||
|
- `spy=false`인 전략용 `slots.req` 요청은 `strategy_instance_id`를 필수 field로 받는다. 누락·비문자열·빈 값이면 core는 RPC-level error나 새 envelope을 만들지 않고 기존 `slots.req` response envelope으로 다음 non-retryable `bad_request`를 반환한다. `reason="bad_request:strategy_instance_id"`가 retry하지 않을 configuration error의 machine-readable 판별값이며, `slots=[]`이므로 어떤 slot이나 `strategy_state`도 노출하지 않는다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slots": [],
|
||||||
|
"available_slots": null,
|
||||||
|
"degraded": false,
|
||||||
|
"reason": "bad_request:strategy_instance_id",
|
||||||
|
"message": "strategy_instance_id must be a non-empty string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 전략 요청에서는 각 slot의 전체 map을 wire에 넣지 않고 `data[request.strategy_instance_id]`만 선택하여 응답의 `strategy_state`에 `{version, data, updated_at}` 단일 object로 넣는다. 해당 key가 없으면 기존 absent-state 의미를 유지해 `strategy_state` field를 생략하며, 다른 strategy instance의 state를 대신 반환해서는 안 된다. 다음 JSON은 전체 `slots.req` envelope이 아니라 `slots[]` 내부 row 예시다. `strategy_instance_id="tvr-v2-prod-01"` 요청에는 해당 state만 포함한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slot_id": "slot-001",
|
||||||
|
"strategy_state": {
|
||||||
|
"version": 8,
|
||||||
|
"data": "{...}",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 이 single-object response는 canonical wire contract다. 응답의 `strategy_state` 안에는 `strategy_instance_id` key나 다른 instance를 열거하는 collection을 두지 않는다.
|
||||||
|
- 같은 `instance_key`에 여러 strategy instance가 연결되어도 요청 A에는 `data[A]`만, 요청 B에는 `data[B]`만 반환한다. Core는 이를 위해 strategy-instance 목록 registry를 유지하지 않고 요청의 필수 `strategy_instance_id`를 map lookup key로만 사용한다.
|
||||||
|
- `spy=true`인 operator `slots.req`는 `strategy_instance_id`를 요구하지 않고 기존 filter·slot 조회와 watchdog 비갱신 동작을 유지한다. Operator 응답의 각 `slots[]` row에는 해당 슬롯의 전체 strategy-state map을 `strategy_instance_id`별 entry로 구성한 `strategy_state` field를 포함한다. 요청에 `strategy_instance_id`가 있더라도 operator 조회 결과를 특정 entry로 제한하지 않는다. 이 동작은 기존 `slots.req`를 사용하며 별도 operator/diagnostic API를 신설하지 않는다.
|
||||||
|
- 기존 state에 대해 별도의 `strategy_id → strategy_instance_id` mapping, backfill 또는 instance 귀속 정책을 두지 않는다. 변경된 slot-scoped `data[strategy_instance_id]` persistence contract를 그대로 적용하며, 이를 별도 migration 결정 사항으로 다시 열지 않는다.
|
||||||
|
- Verification은 close 후 map 보존, inactive/read visibility, 재점유 후 entry version 연속 증가, state persistence·model·RPC에서 `occupancy_seq` 부재, entry별 version conflict 거부와 다른 entry 무변경, 같은 종류의 두 instance 격리, 같은 slot의 서로 다른 A·B entry에 대한 동시 성공 write가 모두 보존되는 조건, 동일 slot map에 A·B state가 있을 때 전략 요청 A에는 `data[A]`만/전략 요청 B에는 `data[B]`만 반환되고 다른 ID와 전체 map이 비노출되는 조건, 요청 ID에 해당하는 state가 없을 때 `strategy_state` field 생략, 누락·비문자열·빈 `spy=false` 요청 ID가 기존 envelope의 `slots=[]`, `degraded=false`, `reason="bad_request:strategy_instance_id"`로 반환되어 slot·state를 노출하지 않는 조건과 같은 malformed state-write ID의 저장 거부, `strategy_instance_id` 없는 `spy=true` operator CLI의 기존 slot 조회 성공·watchdog 비갱신·각 슬롯의 전체 strategy-state map 반환, InMemory/PostgreSQL parity 및 production daemon wiring을 포함한다.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- `profit_buffer`, portfolio 공통 모델, slot occupancy state machine 또는 Slot 자체의 `occupancy_seq`와 증가 규칙 변경.
|
||||||
|
- TVR-v2 reserve·cost basis·processed execution 계산, TSL peak/session 해석 또는 어떤 전략의 opaque `data` schema 정의.
|
||||||
|
- Core가 strategy state의 session 만료나 occupancy 귀속을 판단하는 기능.
|
||||||
|
- Core-side strategy-instance registry, `instance_key`별 strategy 목록 저장, caller 인증 또는 AMQP producer 권한 변경.
|
||||||
|
- Operator/diagnostic용 전체 strategy-state 조회 API 신설.
|
||||||
|
- Strategy-state를 audit/event store로 전환하거나 row history, exactly-once write, multi-writer merge를 도입하는 기능.
|
||||||
|
- `AC-0023`·`AC-0024`·`AC-0025`의 reconciliation warning 및 알림 lifecycle 변경.
|
||||||
|
- Strategy consumer 통합. Consumer는 통합 전제 조건으로 runtime 설정에 required non-empty string `strategy_instance_id`를 두고 startup에서 누락·비문자열·빈 값을 검증해야 한다. 검증 실패 시 RPC worker와 decision cycle을 시작하지 않고 configuration error로 종료하며, 시작 후에는 ID를 정규화·변형하거나 요청별로 override하지 않아야 한다. 이 동작과 검증은 각 consumer repository가 소유한다.
|
||||||
|
- TVR-v2 daemon 또는 다른 전략 repository의 구현·배포. 이 Line은 trex-core persistence와 wire contract만 소유한다.
|
||||||
|
|
||||||
|
## Risks and Unknowns
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
|
||||||
|
- Close-delete와 occupancy-filtered read를 제거하면 slot-scoped map과 그 entry가 장기간 남는다. 같은 `strategy_instance_id`의 반복 write는 새 entry를 늘리지 않고 기존 entry를 갱신한다.
|
||||||
|
- Lifecycle, key/wire와 strategy configuration 변경을 부분적으로 배포하면 기존 consumer와 새 core의 contract가 어긋날 수 있으므로 core와 strategy consumer를 lockstep으로 배포해야 한다.
|
||||||
|
- Consumer가 통합 전제 조건인 `strategy_instance_id` startup 검증과 불변 사용을 구현하지 않으면 잘못된 namespace로 요청할 수 있다. 이 위험의 완화와 contract test는 각 consumer repository가 소유한다.
|
||||||
|
- `slots.req` handler가 storage의 전체 map을 먼저 조립한 뒤 consumer 쪽에서 필터링하면 wire 노출은 해소되지 않는다. Core의 조회 또는 response 조립 단계에서 요청 ID 하나만 선택하고 serialized payload에 다른 ID가 없는지 검증해야 한다.
|
||||||
|
- Strategy-state에서 occupancy fence를 제거하므로 close·reopen 자체는 이전 read에서 계산된 write를 거부하는 근거가 아니다. Core는 state 내용을 해석하지 않으며 `strategy_instance_id` namespace와 version CAS만 보장한다. Occupancy/session 전환에 따라 data를 초기화해야 하는 전략은 opaque state 내부 규칙으로 처리해야 한다.
|
||||||
|
- `SlotService.transit_slot()`의 close-delete hook을 제거할 때 slot transition의 나머지 원자성과 실패 동작이 바뀌지 않아야 한다.
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- `strategy_instance_id`는 process PID나 매 restart마다 달라지는 runtime ID가 아니라 배포 재시작 뒤에도 같은 논리적 전략 instance가 재사용하는 안정적인 식별자다.
|
||||||
|
- 하나의 trex-core `instance_key`를 여러 strategy instance가 소비할 수 있으며, 각 strategy instance는 자기 runtime 설정에서 고유하고 안정적인 `strategy_instance_id`를 소유한다.
|
||||||
|
- 하나의 strategy instance가 같은 slot에서 소유하는 current state는 map entry 하나이며, version은 그 entry의 successful write마다 증가하는 CAS counter다. State history나 mergeable multi-writer log는 요구하지 않는다.
|
||||||
|
- Strategy-state row와 wire contract는 slot occupancy identity를 저장하거나 비교하지 않는다.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
id: "line-0009"
|
||||||
|
---
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
id: "req-0009"
|
||||||
|
status: approved
|
||||||
|
discovery: "dcy-0009"
|
||||||
|
criteria:
|
||||||
|
create:
|
||||||
|
- "ac-0026"
|
||||||
|
- "ac-0027"
|
||||||
|
- "ac-0028"
|
||||||
|
update: []
|
||||||
|
retire: []
|
||||||
|
satisfy: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Persistent strategy-instance state lifecycle and identity
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
`trex-core`의 opaque strategy state를 slot occupancy lifecycle과 `strategy_id` namespace에서 분리한다. 상태를 슬롯별 `strategy_instance_id` entry로 영속화하여 close와 재점유 뒤에도 같은 instance가 기존 state와 version을 이어 쓰게 하고, 서로 다른 instance의 state와 CAS를 격리한다. 전략용 `slots.req`에는 요청한 instance의 state 하나만 제공하고 operator `spy=true` 조회에는 슬롯의 전체 instance map을 제공하되, 각 경로의 identity 검증·watchdog·가시성 계약을 명확히 유지한다.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 어떤 경로로 slot이 `INACTIVE`가 되어도 일반 lifecycle hook이 해당 슬롯의 strategy-state map이나 entry를 자동 삭제하지 않으며, inactive 및 재점유 뒤에도 state와 version을 보존한다.
|
||||||
|
- Strategy-state persistence, application model, store protocol, read filtering, write RPC와 CAS에서 `occupancy_seq` 및 `expected_occupancy_seq`를 제거한다. Slot 자체의 `occupancy_seq`와 증가 규칙은 유지한다.
|
||||||
|
- 슬롯별 strategy-state를 `data[strategy_instance_id] = {version, data, updated_at}` 형태의 map으로 저장한다. Entry에는 `strategy_id`를 중복 저장하지 않으며 inner `data`만 core가 해석하지 않는 opaque string이다.
|
||||||
|
- `strategy_state.set` 입력을 `{slot_id, strategy_instance_id, expected_version, data}`로 정의한다. 해당 instance entry의 version만 CAS하고, 서로 다른 instance의 성공한 동시 write를 모두 보존한다.
|
||||||
|
- `spy=false` 전략용 `slots.req`는 required non-empty string `strategy_instance_id`를 받고, 각 slot에서 요청 ID와 정확히 일치하는 entry 하나만 `strategy_state: {version, data, updated_at}`로 반환한다. 해당 entry가 없으면 `strategy_state`를 생략한다.
|
||||||
|
- 누락·비문자열·빈 `strategy_instance_id`를 받은 전략용 `slots.req`는 기존 response envelope에서 `slots=[]`, `available_slots=null`, `degraded=false`, `reason="bad_request:strategy_instance_id"`, `message="strategy_instance_id must be a non-empty string"`을 반환한다. 같은 malformed ID의 state write는 저장 없이 non-retryable `bad_request`로 거부한다.
|
||||||
|
- `spy=true` operator `slots.req`는 `strategy_instance_id`를 요구하지 않고 기존 filter·slot 조회와 watchdog 비갱신 동작을 유지한다. 각 slot row에는 전체 strategy-state map을 `strategy_instance_id`별 entry로 반환하며, 요청에 ID가 있어도 특정 entry로 제한하지 않는다.
|
||||||
|
- Persistence와 RPC 변경을 InMemory/PostgreSQL parity, production daemon wiring 및 repository-local automated tests로 검증한다.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- State write는 active slot 검사, inner opaque data size limit 및 해당 entry의 `expected_version` CAS를 모두 통과해야 한다. Entry가 없을 때만 `expected_version=0`이며 성공할 때 해당 entry의 version만 1 증가한다.
|
||||||
|
- `version_conflict`는 write를 적용하지 않으며 다른 entry를 변경하지 않는다. Consumer는 추정 version으로 재전송하지 않고 다음 `slots.req`에서 자기 state를 다시 읽어 재계산한다.
|
||||||
|
- `occupancy_changed`는 strategy-state reject reason에서 제거한다. `slot_inactive`, `state_too_large`, `bad_request:*`는 write를 적용하지 않고 RPC `correlation_id`를 저장 멱등성 key로 사용하지 않는다.
|
||||||
|
- Core는 opaque state의 session 의미, 만료, 초기화 또는 merge를 판단하지 않으며 core-side strategy-instance registry, `instance_key ↔ strategy_instance_id` 목록 및 caller 인증을 도입하지 않는다.
|
||||||
|
- 기존 state에 대한 `strategy_id → strategy_instance_id` mapping, backfill 또는 instance 귀속 정책을 도입하지 않고 신규 persistence contract를 그대로 적용한다.
|
||||||
|
- 같은 `strategy_instance_id`는 재시작 뒤에도 같은 논리적 전략 instance가 사용하는 안정적 identity다. Consumer 통합은 각 consumer repository가 소유하며 required non-empty string 설정, startup fail-closed, 불변 사용 및 요청별 override 금지를 전제한다.
|
||||||
|
- AC-0023의 reconciliation warning 판정·slot 제외·intent blocking·clear 권한과 AC-0022의 strategy-facing broker quantity 계약을 변경하지 않는다.
|
||||||
|
- Legacy Discovery와 REQ는 업데이트하지 않는다. 새 AC 승인 뒤 현재 `docs/runtime-flows.md`와 `docs/interface-contract.md`의 strategy-state baseline 문구만 approved change에 맞게 정합화한다.
|
||||||
|
- Normative Design Documents: 없음. 이 REQ와 AC-0026~AC-0028이 이번 specification의 normative behavior를 소유한다.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- `profit_buffer`, portfolio 공통 모델, slot occupancy state machine 또는 Slot 자체의 `occupancy_seq`와 증가 규칙 변경
|
||||||
|
- TVR-v2 reserve·cost basis·processed execution, TSL peak/session 또는 다른 전략의 opaque `data` schema 정의
|
||||||
|
- Core가 strategy state의 session 만료·occupancy 귀속·초기화 여부를 판단하는 기능
|
||||||
|
- Core-side instance registry, strategy 목록 저장, caller 인증, AMQP producer 권한 또는 queue topology 변경
|
||||||
|
- 별도 operator/diagnostic strategy-state API 신설
|
||||||
|
- Strategy-state history, audit/event store, exactly-once write 또는 multi-writer merge
|
||||||
|
- Strategy consumer repository의 구현·배포 및 production rollout
|
||||||
|
- 기존 파괴적 전체 DB reset 제거 또는 변경
|
||||||
|
- Legacy Discovery·REQ 및 AC-0023~AC-0025의 lifecycle·본문 변경
|
||||||
@@ -74,11 +74,14 @@
|
|||||||
```
|
```
|
||||||
{ "filter": "all" | "inactive" | "pending" | "holding" | "error", # default "all"
|
{ "filter": "all" | "inactive" | "pending" | "holding" | "error", # default "all"
|
||||||
"spy": true | false, # default false; true = watchdog 갱신 안 함(운영자 조회)
|
"spy": true | false, # default false; true = watchdog 갱신 안 함(운영자 조회)
|
||||||
"strategy": str # optional (REQ-116 BAT-055) — exact-match on strategy_name
|
"strategy": str, # optional (REQ-116 BAT-055) — exact-match on strategy_name
|
||||||
|
"strategy_instance_id": str # required non-empty string when spy=false; ignored for spy=true filtering
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `filter`: 서버 측 슬롯 status 필터. `"inactive"` 는 buyable 슬롯(universe에 바인딩된 매수 가능 슬롯). 미인식 값은 `"all"` 로 처리.
|
- `filter`: 서버 측 슬롯 status 필터. `"inactive"` 는 buyable 슬롯(universe에 바인딩된 매수 가능 슬롯). 미인식 값은 `"all"` 로 처리.
|
||||||
- `spy=true`: 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신하지 않는다(운영자 CLI 조회가 전략 생존 신호로 오인되지 않도록 — REQ-034 CHG-01).
|
- `spy=true`: 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신하지 않는다(운영자 CLI 조회가 전략 생존 신호로 오인되지 않도록 — REQ-034 CHG-01).
|
||||||
|
- `spy=false`: `strategy_instance_id`는 required non-empty string이다. 누락·비문자열·빈 값은 `{slots:[], available_slots:null, degraded:false, reason:"bad_request:strategy_instance_id", message:"strategy_instance_id must be a non-empty string"}`를 반환하며 watchdog을 갱신하지 않는다.
|
||||||
|
- strategy-state visibility: `spy=false`는 exact requested entry 하나를 `strategy_state: {version,data,updated_at}`로만 반환하고 missing entry는 생략한다. `spy=true`는 supplied ID와 무관하게 complete `{strategy_instance_id: {version,data,updated_at}}` map을 반환한다.
|
||||||
- `strategy` (REQ-116 additive): 지정 시 `strategy_name == strategy` 인 슬롯 행만 반환. 대소문자 exact-match. 미지정 시 기존과 완전 동일. `filter` 와 AND 조합. 매칭 없으면 `slots=[]`(에러 없음). `available_slots` 는 strategy 필터와 무관하게 인스턴스 전체 기준으로 계산(REQ-073 AC-6 불변).
|
- `strategy` (REQ-116 additive): 지정 시 `strategy_name == strategy` 인 슬롯 행만 반환. 대소문자 exact-match. 미지정 시 기존과 완전 동일. `filter` 와 AND 조합. 매칭 없으면 `slots=[]`(에러 없음). `available_slots` 는 strategy 필터와 무관하게 인스턴스 전체 기준으로 계산(REQ-073 AC-6 불변).
|
||||||
|
|
||||||
코어가 reply-to 큐로 보내는 응답 payload:
|
코어가 reply-to 큐로 보내는 응답 payload:
|
||||||
@@ -524,21 +527,21 @@ get_current_price(symbol: str, market: str = "J") -> Optional[Decimal]
|
|||||||
- pika BlockingConnection 은 thread-safe 가 아니므로 consumer/publisher 마다 자기 connection/channel 을 가져야 한다.
|
- pika BlockingConnection 은 thread-safe 가 아니므로 consumer/publisher 마다 자기 connection/channel 을 가져야 한다.
|
||||||
- 토폴로지 선언은 멱등하지만 attribute(durable, args) 가 기존 큐와 다르면 RabbitMQ 가 PRECONDITION_FAILED 로 거부 → batch 마이그레이션 시 큐 재선언 절차 필요.
|
- 토폴로지 선언은 멱등하지만 attribute(durable, args) 가 기존 큐와 다르면 RabbitMQ 가 PRECONDITION_FAILED 로 거부 → batch 마이그레이션 시 큐 재선언 절차 필요.
|
||||||
|
|
||||||
## Interface: `trex.slots.strategy_state.set` RPC (BAT-042 / dcy-028, REQ-080)
|
## Interface: `trex.slots.strategy_state.set` RPC (REQ-0009)
|
||||||
|
|
||||||
- Type: external-service (strategy daemon ↔ core write RPC)
|
- Type: external-service (strategy daemon ↔ core write RPC)
|
||||||
- Consumers / Producers: 전략 데몬(produce) ↔ core 핸들러(consume). per-instance 큐(`{base}.{instance_key}`), direct reply-to, JSON.
|
- Consumers / Producers: 전략 데몬(produce) ↔ core 핸들러(consume). per-instance 큐(`{base}.{instance_key}`), direct reply-to, JSON.
|
||||||
- Purpose: 슬롯의 opaque strategy-state 를 occupancy_seq+version CAS 로 write. read 는 `trex.slots.req` 에 piggyback(별도 RPC 아님).
|
- Purpose: 슬롯별 opaque strategy-instance state entry를 version CAS로 write. read는 `trex.slots.req`에 piggyback(별도 RPC 아님).
|
||||||
|
|
||||||
### Inputs
|
### Inputs
|
||||||
- `{slot_id, strategy_id, expected_version:int, expected_occupancy_seq:int, data:str}`. `correlation_id = "{slot_id}:{strategy_id}:{expected_version}"`(멱등), delivery persistent. `data` 는 opaque UTF-8(core 무해석), byte size cap(기본 4KB ceiling, 운영 권장 1~2KB).
|
- `{slot_id, strategy_instance_id, expected_version:int, data:str}`. `strategy_instance_id`는 required non-empty string이며 `correlation_id`는 저장 멱등성 key가 아니다. `data`는 opaque UTF-8(core 무해석), byte size cap(기본 4KB ceiling, 운영 권장 1~2KB).
|
||||||
|
|
||||||
### Outputs / Outcomes
|
### Outputs / Outcomes
|
||||||
- 성공: `{ok:true, slot_id, strategy_id, version:<new>}`.
|
- 성공: `{ok:true, slot_id, strategy_instance_id, version:<new>}`.
|
||||||
- 실패 reason 전체 집합: `occupancy_changed`(+`occupancy_seq`) / `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:<detail>`. 소비자는 미인식 reason 에 fail-closed.
|
- 실패 reason 전체 집합: `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:<detail>`. 소비자는 미인식 reason에 fail-closed.
|
||||||
|
|
||||||
### Compatibility Rules
|
### Compatibility Rules
|
||||||
- read 표면(`trex.slots.req`)의 각 슬롯 행에 additive 로 `occupancy_seq`(int)와, 보유 시 `strategy_state` 맵(`{<strategy_id>:{version,data,updated_at}}`)이 실린다(REQ-073/074 CHG). 기존 소비자는 unknown 필드 무시로 하위호환. `strategy_state` 는 strategy_* reasoning 필드(strategy_id 등, REQ-074 AC-5 제외 대상)와 구분되는 opaque 상태 맵이다.
|
- slot strategy-state는 occupancy와 독립된 slot-scoped `data[strategy_instance_id] = {version,data,updated_at}` map이다. close/reopen은 entry를 삭제하거나 version을 초기화하지 않는다. Entry에는 `strategy_id`나 `occupancy_seq` metadata가 없으며, Slot 자체의 `occupancy_seq` semantics는 별도로 유지된다.
|
||||||
|
|
||||||
## Shared Constraints
|
## Shared Constraints
|
||||||
|
|
||||||
|
|||||||
@@ -40,16 +40,6 @@ pkill -TERM -f trex-core-daemon
|
|||||||
- `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지.
|
- `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지.
|
||||||
- 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN.
|
- 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN.
|
||||||
|
|
||||||
### 컨테이너 설정, 배포 및 rollback
|
|
||||||
|
|
||||||
- GHCR publication은 requester가 별도로 승인하여 생성하고 push한 strict `vX.Y.Z` Git tag에서만 시작한다. 숫자 구성요소는 `0` 또는 leading zero 없는 decimal integer이며 prerelease/build suffix는 허용하지 않는다. 일반 `main`/branch push, PR, non-push event와 malformed tag는 image를 게시하지 않는다. tag 생성과 push 자체는 이 저장소의 구현·검증과 분리된 사용자 승인 외부 쓰기다.
|
|
||||||
- Git tag version은 `pyproject.toml` package version과 독립이다. 두 값의 일치를 publication 조건으로 해석하거나 package version 변경만으로 publication이 시작된다고 해석하지 않는다.
|
|
||||||
- 이미지는 per-instance TOML, `.env`, 자격증명 또는 연결 정보를 포함하지 않는다. 인스턴스 TOML은 read-only volume으로 mount하고 `trex-core-daemon --config <mounted-path>`로 지정한다. secret은 배포 플랫폼의 secret 기능 또는 runtime environment로만 주입하고 image layer나 command line에 기록하지 않는다.
|
|
||||||
- 게시된 `ghcr.io/fourblocks2020/trex-core:<source-commit-first-12>` tag는 source commit을 찾는 stable selector일 뿐이다. 배포 전 registry가 반환한 `ghcr.io/fourblocks2020/trex-core@sha256:<64-hex>` reference를 기록하고 workload는 이 digest로 pin한다.
|
|
||||||
- rollback은 이전에 검증하고 기록한 digest reference로 workload image를 되돌린 뒤 정상적인 배포 절차로 재시작한다. tag를 다시 해석하거나 source에서 다시 build하지 않는다.
|
|
||||||
- `trex-core-reset`은 파괴적인 DB 작업이며 image rollback이나 자동 rollback 절차에 절대로 포함하지 않는다. schema/data 변경의 복구는 image rollback과 분리된 승인 절차로 수행한다.
|
|
||||||
- v0.1.1부터 image smoke는 DB에 연결하거나 migration을 적용하지 않고, installed package의 SQL migration filename inventory가 해당 source commit의 non-empty canonical inventory와 정확히 일치하는지 검증한다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 운영자 CLI
|
## 2. 운영자 CLI
|
||||||
|
|||||||
+11
-12
@@ -34,11 +34,12 @@
|
|||||||
|
|
||||||
1. `trex-core-daemon --config <path>.toml` 기동 → `DaemonConfig.from_config(toml)` = `from_env()`(TREX_POSTGRESQL_DSN/`TREX_RABBITMQ_URL` 등) + TOML(`instance_key`, `portfolio_hub_token`, `trading_timezone`, `max_active_slots`) 병합. `--config` 필수(없으면 오류 종료, BAT-013/REQ-048). `instance_key` 로 큐명·라우팅·DB 행 스코프 분리(REQ-056).
|
1. `trex-core-daemon --config <path>.toml` 기동 → `DaemonConfig.from_config(toml)` = `from_env()`(TREX_POSTGRESQL_DSN/`TREX_RABBITMQ_URL` 등) + TOML(`instance_key`, `portfolio_hub_token`, `trading_timezone`, `max_active_slots`) 병합. `--config` 필수(없으면 오류 종료, BAT-013/REQ-048). `instance_key` 로 큐명·라우팅·DB 행 스코프 분리(REQ-056).
|
||||||
2. setup: `apply_migrations(dsn)`(멱등; 010 inactive CHECK·011 컬럼 drop 포함) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. (일일 preallocation 은 BAT-030 에서 폐기 — 슬롯 생성은 universe reconcile 이 담당.)
|
2. setup: `apply_migrations(dsn)`(멱등; 010 inactive CHECK·011 컬럼 drop 포함) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. (일일 preallocation 은 BAT-030 에서 폐기 — 슬롯 생성은 universe reconcile 이 담당.)
|
||||||
3. start: 7개 백그라운드 스레드 동시 기동
|
3. start: 8개 백그라운드 스레드 동시 기동
|
||||||
- `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014; 매도 수량 브로커 정본 REQ-062). **각 틱 시작 시 `event_publisher.keepalive_tick()` 호출** (REQ-092 — pika 스레드 안전 규칙: 발행 연결 소유 스레드에서만 I/O). 틱 간격(기본 1s) < heartbeat/2(15s) → 서버 측 idle 종료 예방.
|
- `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014; 매도 수량 브로커 정본 REQ-062). **각 틱 시작 시 `event_publisher.keepalive_tick()` 호출** (REQ-092 — pika 스레드 안전 규칙: 발행 연결 소유 스레드에서만 I/O). 틱 간격(기본 1s) < heartbeat/2(15s) → 서버 측 idle 종료 예방.
|
||||||
- `trex-ledger` :: ledger outbox 드레인 (REQ-019)
|
- `trex-ledger` :: ledger outbox 드레인 (REQ-019)
|
||||||
- `trex-intent-rpc` :: `trex.intent.req` 소비자 (prefetch=1, FIFO, dedup via intent_log; `update_thresholds` 액션 포함 REQ-040)
|
- `trex-intent-rpc` :: `trex.intent.req` 소비자 (prefetch=1, FIFO, dedup via intent_log; `update_thresholds` 액션 포함 REQ-040)
|
||||||
- `trex-slots-rpc` :: `trex.slots.req` 소비자 (spy=false 시 holding 슬롯 워치독 `last_seen_at` 갱신 REQ-034; `safety_sell_enabled=false` 시 `trex-safety-sell` 미기동 BAT-036)
|
- `trex-slots-rpc` :: `trex.slots.req` 소비자 (spy=false 시 holding 슬롯 워치독 `last_seen_at` 갱신 REQ-034; `safety_sell_enabled=false` 시 `trex-safety-sell` 미기동 BAT-036)
|
||||||
|
- `trex-strategy-state-set` :: `trex.slots.strategy_state.set` 소비자 (slot-scoped strategy-instance state version CAS)
|
||||||
- `trex-control-rpc` :: `trex.control.req` 소비자 — pause/resume/list + kill_switch/resume_kill_switch (REQ-032/043)
|
- `trex-control-rpc` :: `trex.control.req` 소비자 — pause/resume/list + kill_switch/resume_kill_switch (REQ-032/043)
|
||||||
- `trex-snapshot-scheduler` :: 일일 portfolio-hub 스냅샷 refresh + **universe 슬롯 reconcile**(REQ-058) + holding 불일치 reconcile(REQ-062)
|
- `trex-snapshot-scheduler` :: 일일 portfolio-hub 스냅샷 refresh + **universe 슬롯 reconcile**(REQ-058) + holding 불일치 reconcile(REQ-062)
|
||||||
- `trex-safety-sell` :: 자율 안전 매도 트리거 루프 (forced_close / stop-loss·take-profit / 장 마감, REQ-037/038/039)
|
- `trex-safety-sell` :: 자율 안전 매도 트리거 루프 (forced_close / stop-loss·take-profit / 장 마감, REQ-037/038/039)
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
|
|
||||||
`trex.slots.req` 메시지 처리 (`make_slots_rpc_handler`).
|
`trex.slots.req` 메시지 처리 (`make_slots_rpc_handler`).
|
||||||
|
|
||||||
1. 요청 payload 디코드: `{filter ∈ {all, inactive, pending, holding, error}, spy: bool}`.
|
1. 요청 payload 디코드: `{filter ∈ {all, inactive, pending, holding, error}, spy: bool, strategy_instance_id?: str}`. `spy=false` 전략 요청은 non-empty string `strategy_instance_id`가 필수이며, malformed 값은 slots/state 노출과 watchdog 갱신 없이 `bad_request:strategy_instance_id` 기존 envelope으로 종료한다. `spy=true` 운영자 요청은 ID를 요구하거나 supplied ID로 결과를 필터하지 않는다.
|
||||||
2. `slot_repo.list_slots(instance_key)` → status 기준 서버 필터 적용(`inactive`/`pending`/`holding`/`error`; 미인식 = `all`).
|
2. `slot_repo.list_slots(instance_key)` → status 기준 서버 필터 적용(`inactive`/`pending`/`holding`/`error`; 미인식 = `all`).
|
||||||
3. `spy=false` 면 `slot_repo.list_holding_slots()` 의 모든 holding 슬롯에 `WatchdogStore.update(slot_id)`.
|
3. `spy=false` 면 `slot_repo.list_holding_slots()` 의 모든 holding 슬롯에 `WatchdogStore.update(slot_id)`.
|
||||||
4. 각 슬롯에 `slot_to_dict(s, snapshot_store=..., watchdog_store=..., pause_store=..., watchdog_timeout_sec=...)` 적용 후 broker holdings snapshot 오버레이를 적용해 병합 row 산출:
|
4. 각 슬롯에 `slot_to_dict(s, snapshot_store=..., watchdog_store=..., pause_store=..., watchdog_timeout_sec=...)` 적용 후 broker holdings snapshot 오버레이를 적용해 병합 row 산출:
|
||||||
@@ -149,7 +150,7 @@
|
|||||||
- 추정 현금(REQ-029 OQ-4): `cash_estimated=True` + `cash_balance += pending ledger delta`(best-effort)
|
- 추정 현금(REQ-029 OQ-4): `cash_estimated=True` + `cash_balance += pending ledger delta`(best-effort)
|
||||||
- 워치독: `watchdog: {expired, last_seen}` inline (WatchdogStore)
|
- 워치독: `watchdog: {expired, last_seen}` inline (WatchdogStore)
|
||||||
- 오버레이: `paused` (PauseStore, REQ-032)
|
- 오버레이: `paused` (PauseStore, REQ-032)
|
||||||
- strategy 필드 없음 (REQ-074 AC-5)
|
- strategy-state: `spy=false`면 요청 `strategy_instance_id`와 정확히 일치하는 `{version,data,updated_at}` entry 하나만(없으면 생략), `spy=true`면 `{strategy_instance_id: {version,data,updated_at}}` 전체 map
|
||||||
5. degraded 상태 동봉: `SnapshotStore.degraded_state()` → `{degraded, reason, message}`.
|
5. degraded 상태 동봉: `SnapshotStore.degraded_state()` → `{degraded, reason, message}`.
|
||||||
6. 응답: `{slots[], degraded, reason, message}` — `slots` 는 항상 배열, null 금지.
|
6. 응답: `{slots[], degraded, reason, message}` — `slots` 는 항상 배열, null 금지.
|
||||||
|
|
||||||
@@ -346,17 +347,15 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
|
|||||||
6. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다.
|
6. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다.
|
||||||
7. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지).
|
7. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지).
|
||||||
|
|
||||||
## Flow: `slot strategy-state 저장소` (BAT-042 / dcy-028, REQ-079~082)
|
## Flow: `slot strategy-state 저장소` (REQ-0009)
|
||||||
|
|
||||||
전략 데몬(예: trex-tsl)이 슬롯 점유 동안 누적하는 opaque 상태(예: TSL peak)를 슬롯 수명에 묶어 server-side 로 보관한다. core 는 `data` 를 해석하지 않는다(opaque UTF-8 + byte size cap 만 검증).
|
전략 상태는 슬롯 점유와 독립된 opaque 상태다. core는 inner `data`를 해석하지 않고 UTF-8 byte size cap만 검증하며, 하나의 슬롯별 map `data[strategy_instance_id] = {version,data,updated_at}`를 보관한다.
|
||||||
|
|
||||||
1. **read (piggyback, C-1)**: 데몬이 매 사이클 `trex.slots.req` 로 슬롯을 읽으면 각 행에 `occupancy_seq`(int)와, 보유 시 `strategy_state` 맵(`{<strategy_id>: {version,data,updated_at}}`)이 함께 실린다. 맵은 핸들러가 반환 slot_id 들로 `StrategyStateStore.get_for_slots()` 1회 batched 조회 후 group by 해 조립한다(추가 왕복 없음). 슬롯 현재 occupancy_seq 와 다른 stale 엔트리는 노출하지 않는다.
|
1. **read (piggyback)**: `spy=false` 전략 요청은 required non-empty `strategy_instance_id`의 exact entry만 각 slot 행의 single `strategy_state` object로 받으며, 없으면 field가 생략된다. 다른 ID나 전체 map은 노출하지 않는다. `spy=true` 운영자 요청은 ID 없이도 각 slot의 complete instance map을 받고 watchdog을 갱신하지 않는다.
|
||||||
2. **evaluate (pure)**: 데몬은 행의 `strategy_state[strategy_id]` 를 read-only 입력으로 `evaluate()` 에 넣는다(상태 write 는 evaluate 밖, 엔진이 수행).
|
2. **write (별도 RPC)**: `trex.slots.strategy_state.set` payload는 `{slot_id, strategy_instance_id, expected_version, data}`다. active slot, size limit, 요청 entry version만 CAS한다. entry가 없으면 `expected_version=0`, 성공하면 그 entry version만 증가한다. `version_conflict`, `slot_inactive`, `state_too_large`, `bad_request:*`는 write를 적용하지 않는다.
|
||||||
3. **write (별도 RPC, C-2)**: 상태가 바뀌면 `trex.slots.strategy_state.set` 으로 `{slot_id, strategy_id, expected_version, expected_occupancy_seq, data}` 를 보낸다. core 는 슬롯의 현재 occupancy_seq·active 여부를 조회해 CAS 검증: 점유 불일치 → `occupancy_changed`, version 불일치 → `version_conflict`, inactive/미존재 → `slot_inactive`, size 초과 → `state_too_large`, 일치(없으면 expected_version=0) → commit + version+1. malformed → `bad_request`. 동일 입력 재전송은 무부작용.
|
3. **lifecycle/concurrency**: `→INACTIVE`와 재점유는 map을 삭제하거나 숨기지 않으며 version도 초기화하지 않는다. Slot 자체의 `occupancy_seq`는 기존대로 유지하지만 strategy state persistence/read/write에는 사용하지 않는다. 서로 다른 instance entry의 concurrent 성공 write는 모두 보존된다.
|
||||||
4. **occupancy 가드 (C-2b, R-1)**: 슬롯은 monotonic `occupancy_seq` 를 갖고 새 점유 시작(`inactive→pending_buy`)마다 ++. 영구 슬롯 재사용 시 read v0→close→reopen→write v0 의 ABA(옛 점유 계산이 새 점유에 바인딩)를 occupancy_seq CAS 가 구조적으로 차단한다.
|
|
||||||
5. **close 삭제 (C-3)**: 슬롯이 어떤 경로로든 `→INACTIVE`(전략 매도/safety-sell/forced-close/operator) 되면 `transit_slot` 단일 전이 지점에서 해당 slot_id 의 모든 strategy_state 를 삭제한다. 재사용 슬롯은 새 occupancy_seq + 빈 상태로 시작 — 전략/데몬은 cleanup 책임이 없다(슬롯이 source of truth + GC).
|
|
||||||
|
|
||||||
저장은 별도 `slot_strategy_state` 테이블(복합 PK `(slot_id, strategy_id)`), 슬롯 본체에는 `occupancy_seq` 컬럼 1개만 추가(full 3계층 분할은 후속 Discovery). full 3계층(occupancy 테이블 분할)·다중 writer 동시성·장기 보존은 범위 밖.
|
저장은 별도 `slot_strategy_state` 테이블의 slot-scoped JSON map이며 entry에는 identity metadata를 중복 저장하지 않는다. core-side instance registry, legacy mapping/backfill, caller 인증은 없다.
|
||||||
|
|
||||||
## Flow Constraints
|
## Flow Constraints
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "trex-core"
|
name = "trex-core"
|
||||||
version = "0.4.0"
|
version = "0.5.1"
|
||||||
description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)"
|
description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,515 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Deterministic verification and publication helpers for the release image."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Protocol, Sequence
|
|
||||||
|
|
||||||
IMAGE = "ghcr.io/fourblocks2020/trex-core"
|
|
||||||
CANARY = b"TREX_SECRET_CANARY_DO_NOT_SHIP"
|
|
||||||
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)")
|
|
||||||
IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
||||||
VERSION_TAG_RE = re.compile(r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
|
|
||||||
DEFAULT_MIGRATION_DIR = (
|
|
||||||
Path(__file__).resolve().parents[1] / "trex_core" / "adapters" / "persistence" / "sql"
|
|
||||||
)
|
|
||||||
MAX_ARCHIVE_DEPTH = 8
|
|
||||||
MAX_ARCHIVE_MEMBERS = 200_000
|
|
||||||
MAX_ARCHIVE_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
|
|
||||||
MAX_ARCHIVE_MEMBER_BYTES = 512 * 1024 * 1024
|
|
||||||
MAX_ARCHIVE_INPUT_BYTES = 1024 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Result:
|
|
||||||
stdout: str = ""
|
|
||||||
stderr: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _ScanBudget:
|
|
||||||
max_depth: int = MAX_ARCHIVE_DEPTH
|
|
||||||
max_members: int = MAX_ARCHIVE_MEMBERS
|
|
||||||
max_extracted_bytes: int = MAX_ARCHIVE_EXTRACTED_BYTES
|
|
||||||
max_member_bytes: int = MAX_ARCHIVE_MEMBER_BYTES
|
|
||||||
max_input_bytes: int = MAX_ARCHIVE_INPUT_BYTES
|
|
||||||
members: int = 0
|
|
||||||
extracted_bytes: int = 0
|
|
||||||
|
|
||||||
def check_depth(self, depth: int) -> None:
|
|
||||||
if depth > self.max_depth:
|
|
||||||
_scan_limit_exceeded()
|
|
||||||
|
|
||||||
def add_member(self, size: int) -> None:
|
|
||||||
self.members += 1
|
|
||||||
if self.members > self.max_members:
|
|
||||||
_scan_limit_exceeded()
|
|
||||||
if size < 0 or size > self.max_member_bytes:
|
|
||||||
_scan_limit_exceeded()
|
|
||||||
self.extracted_bytes += size
|
|
||||||
if self.extracted_bytes > self.max_extracted_bytes:
|
|
||||||
_scan_limit_exceeded()
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_limit_exceeded() -> None:
|
|
||||||
raise ValueError("archive scan resource limit exceeded")
|
|
||||||
|
|
||||||
|
|
||||||
class Runner(Protocol):
|
|
||||||
def run(
|
|
||||||
self,
|
|
||||||
command: Sequence[str],
|
|
||||||
*,
|
|
||||||
input_text: str | None = None,
|
|
||||||
timeout: int | None = None,
|
|
||||||
) -> Result: ...
|
|
||||||
|
|
||||||
|
|
||||||
class SubprocessRunner:
|
|
||||||
def run(
|
|
||||||
self,
|
|
||||||
command: Sequence[str],
|
|
||||||
*,
|
|
||||||
input_text: str | None = None,
|
|
||||||
timeout: int | None = None,
|
|
||||||
) -> Result:
|
|
||||||
completed = subprocess.run(
|
|
||||||
command,
|
|
||||||
input=input_text,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
return Result(completed.stdout, completed.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_commit(commit: str) -> str:
|
|
||||||
if not COMMIT_RE.fullmatch(commit):
|
|
||||||
raise ValueError("source commit must be 40 lowercase hexadecimal characters")
|
|
||||||
return commit
|
|
||||||
|
|
||||||
|
|
||||||
def validate_publication_trigger(event_name: str, ref: str) -> str:
|
|
||||||
if event_name != "push":
|
|
||||||
raise ValueError("publication event must be a push")
|
|
||||||
if not VERSION_TAG_RE.fullmatch(ref):
|
|
||||||
raise ValueError("publication ref must be a strict vX.Y.Z tag")
|
|
||||||
return ref.removeprefix("refs/tags/")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_checkout(commit: str, runner: Runner) -> str:
|
|
||||||
expected = validate_commit(commit)
|
|
||||||
actual = runner.run(["git", "rev-parse", "HEAD"]).stdout.strip()
|
|
||||||
if actual != expected:
|
|
||||||
raise ValueError("checked out HEAD does not match source commit")
|
|
||||||
return actual
|
|
||||||
|
|
||||||
|
|
||||||
def release_tags(commit: str, event_name: str, ref: str) -> list[str]:
|
|
||||||
validate_commit(commit)
|
|
||||||
release = validate_publication_trigger(event_name, ref)
|
|
||||||
return [f"{IMAGE}:{commit[:12]}", f"{IMAGE}:{release}"]
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_file(path: Path) -> str:
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with path.open("rb") as source:
|
|
||||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def write_checksum(archive: Path, checksum: Path) -> None:
|
|
||||||
checksum.write_text(f"{sha256_file(archive)} {archive.name}\n", encoding="ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_checksum(archive: Path, checksum: Path) -> None:
|
|
||||||
if not archive.is_file() or not checksum.is_file():
|
|
||||||
raise ValueError("image archive or checksum evidence is missing")
|
|
||||||
fields = checksum.read_text(encoding="ascii").strip().split()
|
|
||||||
if len(fields) != 2 or fields[1] != archive.name or not re.fullmatch(r"[0-9a-f]{64}", fields[0]):
|
|
||||||
raise ValueError("checksum evidence has an invalid format")
|
|
||||||
if sha256_file(archive) != fields[0]:
|
|
||||||
raise ValueError("image archive checksum mismatch")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_image_id(image_id: str) -> str:
|
|
||||||
if not IMAGE_ID_RE.fullmatch(image_id):
|
|
||||||
raise ValueError("image ID must be sha256 followed by 64 lowercase hexadecimal characters")
|
|
||||||
return image_id
|
|
||||||
|
|
||||||
|
|
||||||
def inspect_image_id(image: str, runner: Runner) -> str:
|
|
||||||
result = runner.run(["docker", "image", "inspect", "--format", "{{.Id}}", image])
|
|
||||||
return validate_image_id(result.stdout.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def write_image_id(image: str, output: Path, runner: Runner) -> str:
|
|
||||||
image_id = inspect_image_id(image, runner)
|
|
||||||
output.write_text(f"{image_id}\n", encoding="ascii")
|
|
||||||
return image_id
|
|
||||||
|
|
||||||
|
|
||||||
def read_image_id(path: Path) -> str:
|
|
||||||
text = path.read_text(encoding="ascii")
|
|
||||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}\n?", text):
|
|
||||||
raise ValueError("tested image ID evidence has an invalid format")
|
|
||||||
return text.rstrip("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_image_id(image: str, expected_path: Path, runner: Runner) -> str:
|
|
||||||
expected = read_image_id(expected_path)
|
|
||||||
actual = inspect_image_id(image, runner)
|
|
||||||
if actual != expected:
|
|
||||||
raise ValueError("loaded image ID does not match tested image ID")
|
|
||||||
return actual
|
|
||||||
|
|
||||||
|
|
||||||
SMOKE_PROBES: tuple[tuple[str, ...], ...] = (
|
|
||||||
("python", "-c", "import trex_core"),
|
|
||||||
("trex-core-daemon", "--help"),
|
|
||||||
("trex-core", "--help"),
|
|
||||||
("trex-core-reset", "--help"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_migration_names(migration_dir: Path = DEFAULT_MIGRATION_DIR) -> tuple[str, ...]:
|
|
||||||
names = tuple(sorted(path.name for path in migration_dir.glob("*.sql") if path.is_file()))
|
|
||||||
if not names:
|
|
||||||
raise ValueError("canonical migration inventory is empty")
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def migration_inventory_code(expected: tuple[str, ...]) -> str:
|
|
||||||
return (
|
|
||||||
"from trex_core.adapters.persistence.migrate import migration_files;"
|
|
||||||
f"expected={expected!r};"
|
|
||||||
"actual=tuple(path.name for path in migration_files());"
|
|
||||||
"assert expected and actual == expected, 'installed migration inventory mismatch'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_result_clean(result: Result) -> None:
|
|
||||||
if CANARY in (result.stdout + result.stderr).encode():
|
|
||||||
raise ValueError("canary marker found on a scanned surface")
|
|
||||||
|
|
||||||
|
|
||||||
def smoke_image(
|
|
||||||
image: str,
|
|
||||||
runner: Runner,
|
|
||||||
timeout: int = 30,
|
|
||||||
migration_dir: Path = DEFAULT_MIGRATION_DIR,
|
|
||||||
) -> None:
|
|
||||||
expected_migrations = canonical_migration_names(migration_dir)
|
|
||||||
probes = (
|
|
||||||
*SMOKE_PROBES,
|
|
||||||
("python", "-I", "-c", migration_inventory_code(expected_migrations)),
|
|
||||||
)
|
|
||||||
for index, probe in enumerate(probes):
|
|
||||||
name = f"trex-core-smoke-{os.getpid()}-{index}"
|
|
||||||
command = [
|
|
||||||
"docker",
|
|
||||||
"run",
|
|
||||||
"--name",
|
|
||||||
name,
|
|
||||||
"--network",
|
|
||||||
"none",
|
|
||||||
"--read-only",
|
|
||||||
"--cap-drop",
|
|
||||||
"ALL",
|
|
||||||
"--security-opt",
|
|
||||||
"no-new-privileges",
|
|
||||||
"--pids-limit",
|
|
||||||
"64",
|
|
||||||
"--tmpfs",
|
|
||||||
"/tmp:rw,noexec,nosuid,size=16m",
|
|
||||||
"--entrypoint",
|
|
||||||
probe[0],
|
|
||||||
image,
|
|
||||||
*probe[1:],
|
|
||||||
]
|
|
||||||
failure: BaseException | None = None
|
|
||||||
try:
|
|
||||||
_ensure_result_clean(runner.run(command, timeout=timeout))
|
|
||||||
except BaseException as error:
|
|
||||||
failure = error
|
|
||||||
try:
|
|
||||||
_ensure_result_clean(
|
|
||||||
runner.run(["docker", "rm", "--force", name], timeout=timeout)
|
|
||||||
)
|
|
||||||
except BaseException as cleanup_error:
|
|
||||||
if failure is None:
|
|
||||||
raise RuntimeError("smoke container cleanup failed") from cleanup_error
|
|
||||||
if failure is not None:
|
|
||||||
raise RuntimeError(f"smoke probe {index + 1} failed") from failure
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_stream(path: Path) -> None:
|
|
||||||
with path.open("rb") as stream:
|
|
||||||
overlap = b""
|
|
||||||
while chunk := stream.read(1024 * 1024):
|
|
||||||
data = overlap + chunk
|
|
||||||
if CANARY in data:
|
|
||||||
raise ValueError("canary marker found on a scanned surface")
|
|
||||||
overlap = data[-(len(CANARY) - 1):]
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_metadata(member: tarfile.TarInfo) -> None:
|
|
||||||
values = [member.name, member.linkname, member.uname, member.gname]
|
|
||||||
for key, value in member.pax_headers.items():
|
|
||||||
values.extend((key, value))
|
|
||||||
if any(CANARY in str(value).encode("utf-8", errors="replace") for value in values):
|
|
||||||
raise ValueError("canary marker found on a scanned surface")
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_tar(path: Path, budget: _ScanBudget | None = None, depth: int = 0) -> None:
|
|
||||||
budget = budget or _ScanBudget()
|
|
||||||
if path.stat().st_size > (budget.max_input_bytes if depth == 0 else budget.max_member_bytes):
|
|
||||||
_scan_limit_exceeded()
|
|
||||||
_scan_stream(path)
|
|
||||||
try:
|
|
||||||
archive = tarfile.open(path, mode="r:*")
|
|
||||||
except (tarfile.TarError, OSError, EOFError):
|
|
||||||
return
|
|
||||||
with archive:
|
|
||||||
budget.check_depth(depth)
|
|
||||||
for member in archive:
|
|
||||||
budget.add_member(member.size if member.isfile() else 0)
|
|
||||||
_scan_metadata(member)
|
|
||||||
if not member.isfile():
|
|
||||||
continue
|
|
||||||
extracted = archive.extractfile(member)
|
|
||||||
if extracted is None:
|
|
||||||
continue
|
|
||||||
with tempfile.NamedTemporaryFile() as nested:
|
|
||||||
overlap = b""
|
|
||||||
copied = 0
|
|
||||||
while chunk := extracted.read(1024 * 1024):
|
|
||||||
copied += len(chunk)
|
|
||||||
if copied > member.size or copied > budget.max_member_bytes:
|
|
||||||
_scan_limit_exceeded()
|
|
||||||
data = overlap + chunk
|
|
||||||
if CANARY in data:
|
|
||||||
raise ValueError("canary marker found on a scanned surface")
|
|
||||||
nested.write(chunk)
|
|
||||||
overlap = data[-(len(CANARY) - 1):]
|
|
||||||
nested.flush()
|
|
||||||
_scan_tar(Path(nested.name), budget, depth + 1)
|
|
||||||
|
|
||||||
|
|
||||||
def scan_image(image: str, saved_archive: Path, runner: Runner, timeout: int = 60) -> None:
|
|
||||||
for command in (
|
|
||||||
["docker", "history", "--no-trunc", image],
|
|
||||||
["docker", "image", "inspect", image],
|
|
||||||
):
|
|
||||||
_ensure_result_clean(runner.run(command, timeout=timeout))
|
|
||||||
|
|
||||||
container = runner.run(["docker", "create", "--network", "none", image], timeout=timeout)
|
|
||||||
container_id = container.stdout.strip()
|
|
||||||
if not container_id:
|
|
||||||
raise RuntimeError("docker create returned no container id")
|
|
||||||
try:
|
|
||||||
_ensure_result_clean(container)
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
filesystem = Path(directory) / "filesystem.tar"
|
|
||||||
_ensure_result_clean(
|
|
||||||
runner.run(
|
|
||||||
["docker", "export", "--output", str(filesystem), container_id],
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_scan_tar(filesystem)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
_ensure_result_clean(
|
|
||||||
runner.run(["docker", "rm", "--force", container_id], timeout=timeout)
|
|
||||||
)
|
|
||||||
except BaseException as error:
|
|
||||||
raise RuntimeError("canary scan container cleanup failed") from error
|
|
||||||
_scan_tar(saved_archive)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_clean_output(paths: Sequence[Path]) -> None:
|
|
||||||
for path in paths:
|
|
||||||
_scan_stream(path)
|
|
||||||
|
|
||||||
|
|
||||||
def _push_digest(output: str) -> str:
|
|
||||||
digests = set(DIGEST_RE.findall(output))
|
|
||||||
if len(digests) != 1:
|
|
||||||
raise ValueError("registry push did not return exactly one valid digest")
|
|
||||||
return digests.pop()
|
|
||||||
|
|
||||||
|
|
||||||
def _append(path: Path | None, text: str) -> None:
|
|
||||||
if path is None:
|
|
||||||
return
|
|
||||||
with path.open("a", encoding="utf-8") as destination:
|
|
||||||
destination.write(text)
|
|
||||||
|
|
||||||
|
|
||||||
def publish_image(
|
|
||||||
*,
|
|
||||||
archive: Path,
|
|
||||||
checksum: Path,
|
|
||||||
image_id: Path,
|
|
||||||
local_image: str,
|
|
||||||
commit: str,
|
|
||||||
event_name: str,
|
|
||||||
ref: str,
|
|
||||||
actor: str,
|
|
||||||
token: str,
|
|
||||||
runner: Runner,
|
|
||||||
output_path: Path | None = None,
|
|
||||||
summary_path: Path | None = None,
|
|
||||||
) -> str:
|
|
||||||
tags = release_tags(commit, event_name, ref)
|
|
||||||
version_tag = validate_publication_trigger(event_name, ref)
|
|
||||||
verify_checksum(archive, checksum)
|
|
||||||
expected_image_id = read_image_id(image_id)
|
|
||||||
loaded = runner.run(["docker", "load", "--input", str(archive)])
|
|
||||||
load_lines = {line.strip() for line in (loaded.stdout + "\n" + loaded.stderr).splitlines()}
|
|
||||||
if f"Loaded image: {local_image}" not in load_lines:
|
|
||||||
raise ValueError("docker load did not report the expected local image")
|
|
||||||
actual_image_id = inspect_image_id(local_image, runner)
|
|
||||||
if actual_image_id != expected_image_id:
|
|
||||||
raise ValueError("loaded image ID does not match tested image ID")
|
|
||||||
revision = runner.run(
|
|
||||||
[
|
|
||||||
"docker",
|
|
||||||
"image",
|
|
||||||
"inspect",
|
|
||||||
"--format",
|
|
||||||
"{{ index .Config.Labels \"org.opencontainers.image.revision\" }}",
|
|
||||||
local_image,
|
|
||||||
]
|
|
||||||
).stdout.strip()
|
|
||||||
if revision != commit:
|
|
||||||
raise ValueError("loaded image revision does not match source commit")
|
|
||||||
|
|
||||||
if not actor or not token:
|
|
||||||
raise ValueError("registry credentials are missing")
|
|
||||||
for tag in tags:
|
|
||||||
runner.run(["docker", "tag", local_image, tag])
|
|
||||||
runner.run(
|
|
||||||
["docker", "login", "ghcr.io", "--username", actor, "--password-stdin"],
|
|
||||||
input_text=token,
|
|
||||||
)
|
|
||||||
digests = []
|
|
||||||
for tag in tags:
|
|
||||||
result = runner.run(["docker", "push", tag])
|
|
||||||
digests.append(_push_digest(result.stdout + "\n" + result.stderr))
|
|
||||||
if len(set(digests)) != 1:
|
|
||||||
raise ValueError("published tags returned different registry digests")
|
|
||||||
|
|
||||||
digest = digests[0]
|
|
||||||
reference = f"{IMAGE}@{digest}"
|
|
||||||
_append(
|
|
||||||
output_path,
|
|
||||||
f"image={reference}\ndigest={digest}\nsource_commit={commit}\nversion_tag={version_tag}\n",
|
|
||||||
)
|
|
||||||
_append(
|
|
||||||
summary_path,
|
|
||||||
"## Published trex-core image\n\n"
|
|
||||||
f"- Image: `{reference}`\n"
|
|
||||||
f"- Digest: `{digest}`\n"
|
|
||||||
f"- Source commit: `{commit}`\n"
|
|
||||||
f"- Version tag: `{version_tag}`\n",
|
|
||||||
)
|
|
||||||
return digest
|
|
||||||
|
|
||||||
|
|
||||||
def _parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
commands = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
smoke = commands.add_parser("smoke")
|
|
||||||
smoke.add_argument("--image", required=True)
|
|
||||||
smoke.add_argument("--timeout", type=int, default=30)
|
|
||||||
|
|
||||||
verify_checkout_parser = commands.add_parser("verify-checkout")
|
|
||||||
verify_checkout_parser.add_argument("--commit", required=True)
|
|
||||||
|
|
||||||
scan = commands.add_parser("scan")
|
|
||||||
scan.add_argument("--image", required=True)
|
|
||||||
scan.add_argument("--archive", type=Path, required=True)
|
|
||||||
|
|
||||||
checksum = commands.add_parser("checksum")
|
|
||||||
checksum.add_argument("--archive", type=Path, required=True)
|
|
||||||
checksum.add_argument("--output", type=Path, required=True)
|
|
||||||
|
|
||||||
record_id = commands.add_parser("record-image-id")
|
|
||||||
record_id.add_argument("--image", required=True)
|
|
||||||
record_id.add_argument("--output", type=Path, required=True)
|
|
||||||
|
|
||||||
verify_id = commands.add_parser("verify-image-id")
|
|
||||||
verify_id.add_argument("--image", required=True)
|
|
||||||
verify_id.add_argument("--expected", type=Path, required=True)
|
|
||||||
|
|
||||||
clean = commands.add_parser("assert-clean-output")
|
|
||||||
clean.add_argument("paths", nargs="+", type=Path)
|
|
||||||
|
|
||||||
publish = commands.add_parser("publish")
|
|
||||||
publish.add_argument("--archive", type=Path, required=True)
|
|
||||||
publish.add_argument("--checksum", type=Path, required=True)
|
|
||||||
publish.add_argument("--image-id", type=Path, required=True)
|
|
||||||
publish.add_argument("--local-image", required=True)
|
|
||||||
publish.add_argument("--commit", required=True)
|
|
||||||
publish.add_argument("--event-name", required=True)
|
|
||||||
publish.add_argument("--ref", required=True)
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
|
||||||
args = _parser().parse_args(argv)
|
|
||||||
runner = SubprocessRunner()
|
|
||||||
if args.command == "smoke":
|
|
||||||
smoke_image(args.image, runner, args.timeout)
|
|
||||||
elif args.command == "verify-checkout":
|
|
||||||
verify_checkout(args.commit, runner)
|
|
||||||
elif args.command == "scan":
|
|
||||||
scan_image(args.image, args.archive, runner)
|
|
||||||
elif args.command == "checksum":
|
|
||||||
write_checksum(args.archive, args.output)
|
|
||||||
elif args.command == "record-image-id":
|
|
||||||
write_image_id(args.image, args.output, runner)
|
|
||||||
elif args.command == "verify-image-id":
|
|
||||||
verify_image_id(args.image, args.expected, runner)
|
|
||||||
elif args.command == "assert-clean-output":
|
|
||||||
assert_clean_output(args.paths)
|
|
||||||
elif args.command == "publish":
|
|
||||||
publish_image(
|
|
||||||
archive=args.archive,
|
|
||||||
checksum=args.checksum,
|
|
||||||
image_id=args.image_id,
|
|
||||||
local_image=args.local_image,
|
|
||||||
commit=args.commit,
|
|
||||||
event_name=args.event_name,
|
|
||||||
ref=args.ref,
|
|
||||||
actor=os.environ.get("GITHUB_ACTOR", ""),
|
|
||||||
token=os.environ.get("REGISTRY_TOKEN", ""),
|
|
||||||
runner=runner,
|
|
||||||
output_path=Path(path) if (path := os.environ.get("GITHUB_OUTPUT")) else None,
|
|
||||||
summary_path=Path(path) if (path := os.environ.get("GITHUB_STEP_SUMMARY")) else None,
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
raise SystemExit(main())
|
|
||||||
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
||||||
print(f"image release failed: {error}", file=sys.stderr)
|
|
||||||
raise SystemExit(1) from error
|
|
||||||
@@ -28,6 +28,7 @@ _EXPECTED_TABLES = {
|
|||||||
"broker_account_snapshot", # dcy-041 / GitHub #45
|
"broker_account_snapshot", # dcy-041 / GitHub #45
|
||||||
"ledger_skip", # GitHub #68 skipped-ledger durable audit
|
"ledger_skip", # GitHub #68 skipped-ledger durable audit
|
||||||
"reconcile_notification_state", # req-0007 / ac-0024
|
"reconcile_notification_state", # req-0007 / ac-0024
|
||||||
|
"slot_strategy_state", # req-0009 / ac-0027
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +60,25 @@ def test_daily_slot_columns_match_expected_contract(pg_clean):
|
|||||||
assert actual == set(EXPECTED_SLOT_FIELDS)
|
assert actual == set(EXPECTED_SLOT_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_state_schema_matches_instance_map_contract(pg_clean):
|
||||||
|
with psycopg.connect(pg_clean) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT column_name, data_type, udt_name "
|
||||||
|
"FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = 'public' AND table_name = 'slot_strategy_state'"
|
||||||
|
)
|
||||||
|
columns = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
|
||||||
|
cur.execute(
|
||||||
|
"SELECT a.attname FROM pg_index i "
|
||||||
|
"JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) "
|
||||||
|
"WHERE i.indrelid = 'slot_strategy_state'::regclass AND i.indisprimary"
|
||||||
|
)
|
||||||
|
primary_key = {row[0] for row in cur.fetchall()}
|
||||||
|
assert columns == {"slot_id": ("uuid", "uuid"), "data": ("jsonb", "jsonb")}
|
||||||
|
assert primary_key == {"slot_id"}
|
||||||
|
|
||||||
|
|
||||||
def test_drift_detected_when_contract_extended(pg_clean):
|
def test_drift_detected_when_contract_extended(pg_clean):
|
||||||
"""Negative case: a contract field absent from the table is caught."""
|
"""Negative case: a contract field absent from the table is caught."""
|
||||||
actual = _columns(pg_clean, "daily_slot")
|
actual = _columns(pg_clean, "daily_slot")
|
||||||
|
|||||||
@@ -155,45 +155,92 @@ def test_cancel_attempt_reservation_is_compare_and_set(pg_clean):
|
|||||||
assert repo.fetch_slot(decision.slot_id).cancel_state == "requested"
|
assert repo.fetch_slot(decision.slot_id).cancel_state == "requested"
|
||||||
|
|
||||||
|
|
||||||
# dcy-028 / REQ-079·081 (BAT-042): strategy-state store + occupancy_seq column
|
# REQ-0009: slot-scoped strategy-instance state map.
|
||||||
def test_strategy_state_store_pg(pg_clean):
|
def test_strategy_state_store_pg(pg_clean):
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
from trex_core.adapters.persistence.postgresql import PgStrategyStateStore
|
from trex_core.adapters.persistence.postgresql import PgStrategyStateStore
|
||||||
from trex_core.application import strategy_state as ss
|
from trex_core.application import strategy_state as ss
|
||||||
|
|
||||||
repo = PostgreSQLSlotRepository(pg_clean)
|
repo = PostgreSQLSlotRepository(pg_clean)
|
||||||
store = PgStrategyStateStore(pg_clean)
|
store = PgStrategyStateStore(pg_clean)
|
||||||
svc = SlotService(repo, instance_key=INST, strategy_state_store=store)
|
svc = SlotService(repo, instance_key=INST)
|
||||||
repo.reconcile_universe(INST, {"pf-a": "005930"})
|
repo.reconcile_universe(INST, {"pf-a": "005930"})
|
||||||
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||||
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||||
reason="buy", actor="t")
|
reason="buy", actor="t")
|
||||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||||
reason="filled", actor="t", qty=10, avg_price=100)
|
reason="filled", actor="t", qty=10, avg_price=100)
|
||||||
# occupancy_seq persisted
|
r1 = store.set(slot.slot_id, "instance-a", expected_version=0, data="peak", slot_active=True)
|
||||||
held = repo.fetch_slot(slot.slot_id)
|
|
||||||
assert held.occupancy_seq == 1
|
|
||||||
|
|
||||||
# first-create
|
|
||||||
r1 = store.set(slot.slot_id, "tsl_v1", expected_version=0,
|
|
||||||
expected_occupancy_seq=1, data="peak", current_occupancy_seq=1,
|
|
||||||
slot_active=True)
|
|
||||||
assert r1.ok and r1.version == 1
|
assert r1.ok and r1.version == 1
|
||||||
# version conflict
|
r2 = store.set(slot.slot_id, "instance-a", expected_version=0, data="stale", slot_active=True)
|
||||||
r2 = store.set(slot.slot_id, "tsl_v1", expected_version=0,
|
|
||||||
expected_occupancy_seq=1, data="stale", current_occupancy_seq=1,
|
|
||||||
slot_active=True)
|
|
||||||
assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT
|
assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT
|
||||||
# batched read
|
missing = store.set(slot.slot_id, "instance-missing", expected_version=1, data="stale", slot_active=True)
|
||||||
got = store.get_for_slots([slot.slot_id], {slot.slot_id: 1})
|
assert not missing.ok and missing.reason == ss.REASON_VERSION_CONFLICT and missing.version == 0
|
||||||
assert got[slot.slot_id]["tsl_v1"].data == "peak"
|
store.set(slot.slot_id, "instance-b", expected_version=0, data="other", slot_active=True)
|
||||||
|
got = store.get_for_slots([slot.slot_id])
|
||||||
|
assert got[slot.slot_id]["instance-a"].data == "peak"
|
||||||
|
assert got[slot.slot_id]["instance-b"].data == "other"
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
writes = list(executor.map(
|
||||||
|
lambda item: store.set(slot.slot_id, item, expected_version=0, data=item, slot_active=True),
|
||||||
|
("instance-c", "instance-d"),
|
||||||
|
))
|
||||||
|
assert all(write.ok for write in writes)
|
||||||
|
assert {"instance-c", "instance-d"} <= set(store.get_for_slots([slot.slot_id])[slot.slot_id])
|
||||||
|
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 1
|
||||||
|
|
||||||
|
# Separate connections race on the same entry/version: only one CAS wins.
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
competing = list(executor.map(
|
||||||
|
lambda data: store.set(
|
||||||
|
slot.slot_id, "instance-a", expected_version=1, data=data, slot_active=True,
|
||||||
|
),
|
||||||
|
("winner-one", "winner-two"),
|
||||||
|
))
|
||||||
|
assert sum(result.ok for result in competing) == 1, competing
|
||||||
|
assert sum(result.reason == ss.REASON_VERSION_CONFLICT for result in competing) == 1
|
||||||
|
winner = next(data for data, result in zip(("winner-one", "winner-two"), competing) if result.ok)
|
||||||
|
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].data == winner
|
||||||
|
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 2
|
||||||
|
|
||||||
|
# A fresh adapter sees the byte-identical persisted entry and can continue CAS.
|
||||||
|
before_restart = store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"]
|
||||||
|
after_restart = PgStrategyStateStore(pg_clean).get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"]
|
||||||
|
assert after_restart == before_restart
|
||||||
|
|
||||||
# close deletes state (C-3)
|
|
||||||
svc.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
svc.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||||
reason="sell", actor="t")
|
reason="sell", actor="t")
|
||||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
svc.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||||
reason="sold", actor="t")
|
reason="sold", actor="t")
|
||||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {}
|
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 2
|
||||||
# reuse bumps occupancy_seq
|
|
||||||
reused = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
reused = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||||
reason="buy", actor="t")
|
reason="buy", actor="t")
|
||||||
assert reused.occupancy_seq == 2
|
assert reused.occupancy_seq == 2
|
||||||
|
rehydrated = PgStrategyStateStore(pg_clean)
|
||||||
|
persisted = rehydrated.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"]
|
||||||
|
assert persisted == before_restart
|
||||||
|
assert rehydrated.set(
|
||||||
|
slot.slot_id, "instance-a", expected_version=2, data="continued", slot_active=True,
|
||||||
|
).version == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_state_write_cannot_follow_close(pg_clean):
|
||||||
|
"""The store's locked lifecycle read rejects a write after close wins."""
|
||||||
|
from trex_core.adapters.persistence.postgresql import PgStrategyStateStore
|
||||||
|
|
||||||
|
repo = PostgreSQLSlotRepository(pg_clean)
|
||||||
|
svc = SlotService(repo, instance_key=INST)
|
||||||
|
store = PgStrategyStateStore(pg_clean)
|
||||||
|
repo.reconcile_universe(INST, {"pf-close": "005930"})
|
||||||
|
slot = repo.find_slot_by_portfolio(INST, "pf-close")
|
||||||
|
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
|
||||||
|
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1)
|
||||||
|
assert store.set(slot.slot_id, "instance", expected_version=0, data="before", slot_active=True).ok
|
||||||
|
|
||||||
|
svc.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, reason="sell", actor="t")
|
||||||
|
svc.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, reason="sold", actor="t")
|
||||||
|
result = store.set(slot.slot_id, "instance", expected_version=1, data="after", slot_active=True)
|
||||||
|
assert not result.ok and result.reason == "slot_inactive"
|
||||||
|
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance"].data == "before"
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ def test_apply_cash_estimate_overlays_pending_delta():
|
|||||||
repo = InMemorySlotRepository()
|
repo = InMemorySlotRepository()
|
||||||
repo.reconcile_universe("default", {"PF-1": "SYM"})
|
repo.reconcile_universe("default", {"PF-1": "SYM"})
|
||||||
handler = make_slots_rpc_handler(repo, snapshot_store=store, ledger_outbox=outbox)
|
handler = make_slots_rpc_handler(repo, snapshot_store=store, ledger_outbox=outbox)
|
||||||
resp = handler(RpcRequest(payload={"filter": "all"}, correlation_id="test"))
|
resp = handler(RpcRequest(payload={"filter": "all", "strategy_instance_id": "test-instance"}, correlation_id="test"))
|
||||||
slots = resp.payload["slots"]
|
slots = resp.payload["slots"]
|
||||||
pf1 = next(s for s in slots if s["portfolio_id"] == "PF-1")
|
pf1 = next(s for s in slots if s["portfolio_id"] == "PF-1")
|
||||||
assert pf1["cash_estimated"] is True
|
assert pf1["cash_estimated"] is True
|
||||||
|
|||||||
@@ -693,7 +693,7 @@ def _wire_with_warn(warned_slot_ids=None):
|
|||||||
|
|
||||||
|
|
||||||
def _req(filter=None):
|
def _req(filter=None):
|
||||||
return RpcRequest(payload={"filter": filter or "all"}, correlation_id="t")
|
return RpcRequest(payload={"filter": filter or "all", "spy": True}, correlation_id="t")
|
||||||
|
|
||||||
|
|
||||||
# REQ-104 AC-5: reconcile_warned=True for warned slot
|
# REQ-104 AC-5: reconcile_warned=True for warned slot
|
||||||
@@ -756,7 +756,7 @@ def test_slots_rpc_existing_warned_field_unchanged():
|
|||||||
reason="filled", actor="test", avg_price=100.0, qty=10.0)
|
reason="filled", actor="test", avg_price=100.0, qty=10.0)
|
||||||
warn_store = InMemoryReconcileWarningStore()
|
warn_store = InMemoryReconcileWarningStore()
|
||||||
handler = make_slots_rpc_handler(repo, snapshot_store=store, warn_store=warn_store)
|
handler = make_slots_rpc_handler(repo, snapshot_store=store, warn_store=warn_store)
|
||||||
resp = handler(RpcRequest(payload={"filter": "holding"}, correlation_id="t"))
|
resp = handler(RpcRequest(payload={"filter": "holding", "spy": True}, correlation_id="t"))
|
||||||
row = resp.payload["slots"][0]
|
row = resp.payload["slots"][0]
|
||||||
# slot.qty=10 != hub asset_quantity=5 => warned=True (existing field)
|
# slot.qty=10 != hub asset_quantity=5 => warned=True (existing field)
|
||||||
assert row["warned"] is True
|
assert row["warned"] is True
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ def _req(filter=None, spy=False):
|
|||||||
payload["filter"] = filter
|
payload["filter"] = filter
|
||||||
if spy:
|
if spy:
|
||||||
payload["spy"] = True
|
payload["spy"] = True
|
||||||
|
else:
|
||||||
|
payload["strategy_instance_id"] = "test-instance"
|
||||||
return RpcRequest(payload=payload, correlation_id="test-corr")
|
return RpcRequest(payload=payload, correlation_id="test-corr")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ def _make_handler(repo, warn_store, snapshot_items=None):
|
|||||||
|
|
||||||
def _req(filter=None):
|
def _req(filter=None):
|
||||||
payload = {}
|
payload = {}
|
||||||
|
payload["spy"] = True
|
||||||
if filter:
|
if filter:
|
||||||
payload["filter"] = filter
|
payload["filter"] = filter
|
||||||
return RpcRequest(payload=payload, correlation_id="test")
|
return RpcRequest(payload=payload, correlation_id="test")
|
||||||
@@ -331,6 +332,33 @@ def test_ac7_warn_store_failure_fail_closed():
|
|||||||
assert "excluded_slots" in resp.payload
|
assert "excluded_slots" in resp.payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac23_exclusion_precedes_broker_and_strategy_state_readers():
|
||||||
|
repo = InMemorySlotRepository()
|
||||||
|
svc = SlotService(repo)
|
||||||
|
warn_store = InMemoryReconcileWarningStore()
|
||||||
|
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
|
||||||
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
|
||||||
|
|
||||||
|
class ExplodingReader:
|
||||||
|
def holdings_for_slots_rpc(self):
|
||||||
|
raise AssertionError("excluded slot reached broker reader")
|
||||||
|
|
||||||
|
class ExplodingStateStore:
|
||||||
|
def get_for_slots(self, _slot_ids):
|
||||||
|
raise AssertionError("excluded slot reached state reader")
|
||||||
|
|
||||||
|
handler = make_slots_rpc_handler(
|
||||||
|
repo,
|
||||||
|
instance_key=INSTANCE,
|
||||||
|
warn_store=warn_store,
|
||||||
|
broker_quantity_reader=ExplodingReader(),
|
||||||
|
strategy_state_store=ExplodingStateStore(),
|
||||||
|
)
|
||||||
|
response = handler(_req(filter="holding")).payload
|
||||||
|
assert response["slots"] == []
|
||||||
|
assert response["excluded_slots"] == {"reconcile_quantity_mismatch": 1}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# AC-8: intent rejected if C-case warning set after last slots RPC (race)
|
# AC-8: intent rejected if C-case warning set after last slots RPC (race)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ def _req(filter=None, spy=False, strategy=None):
|
|||||||
payload["filter"] = filter
|
payload["filter"] = filter
|
||||||
if spy:
|
if spy:
|
||||||
payload["spy"] = True
|
payload["spy"] = True
|
||||||
|
else:
|
||||||
|
payload["strategy_instance_id"] = "test-instance"
|
||||||
if strategy is not None:
|
if strategy is not None:
|
||||||
payload["strategy"] = strategy
|
payload["strategy"] = strategy
|
||||||
return RpcRequest(payload=payload, correlation_id="test-corr")
|
return RpcRequest(payload=payload, correlation_id="test-corr")
|
||||||
|
|||||||
@@ -1,93 +1,71 @@
|
|||||||
"""slots.req read piggyback of strategy_state + occupancy_seq (dcy-028 C-1).
|
"""Strategy and operator strategy-state visibility contracts for slots.req."""
|
||||||
|
|
||||||
REQ-073 AC-7 / REQ-074 AC-6 / S-1: each slot row carries occupancy_seq and,
|
|
||||||
when present, a strategy_state map assembled from the store in one batched read.
|
|
||||||
strategy_* reasoning fields remain absent.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from trex_core.application.messaging import RpcRequest
|
from trex_core.application.messaging import RpcRequest
|
||||||
from trex_core.application.messaging_handlers import make_slots_rpc_handler
|
from trex_core.application.messaging_handlers import make_slots_rpc_handler
|
||||||
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
||||||
|
from trex_core.application.watchdog import WatchdogStore
|
||||||
from trex_core.domain.slot import SlotService, SlotState
|
from trex_core.domain.slot import SlotService, SlotState
|
||||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||||
|
|
||||||
INST = "default"
|
|
||||||
|
|
||||||
|
|
||||||
def _wire():
|
def _wire():
|
||||||
repo = InMemorySlotRepository()
|
repo, store, watchdog = InMemorySlotRepository(), InMemoryStrategyStateStore(), WatchdogStore()
|
||||||
store = InMemoryStrategyStateStore()
|
service = SlotService(repo)
|
||||||
service = SlotService(repo, instance_key=INST, strategy_state_store=store)
|
repo.reconcile_universe("default", {"pf": "SYM"})
|
||||||
repo.reconcile_universe(INST, {"pf-0": "SYM"})
|
slot = repo.list_slots("default")[0]
|
||||||
slot = repo.list_slots(INST)[0]
|
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
|
||||||
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1, avg_price=1)
|
||||||
reason="buy", actor="t")
|
store.set(slot.slot_id, "instance-a", expected_version=0, data="a", slot_active=True)
|
||||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
store.set(slot.slot_id, "instance-b", expected_version=0, data="b", slot_active=True)
|
||||||
reason="filled", actor="t", qty=10, avg_price=100)
|
return slot, store, watchdog, make_slots_rpc_handler(repo, strategy_state_store=store, watchdog_store=watchdog)
|
||||||
handler = make_slots_rpc_handler(repo, instance_key=INST,
|
|
||||||
strategy_state_store=store)
|
|
||||||
return repo, store, service, slot, handler
|
|
||||||
|
|
||||||
|
|
||||||
def _rows(handler):
|
def _call(handler, payload):
|
||||||
return handler(RpcRequest(payload={}, correlation_id="c")).payload["slots"]
|
return handler(RpcRequest(payload=payload, correlation_id="c")).payload
|
||||||
|
|
||||||
|
|
||||||
def test_row_carries_occupancy_seq():
|
def test_strategy_receives_only_exact_requested_entry():
|
||||||
_, _, _, slot, handler = _wire()
|
_, _, _, handler = _wire()
|
||||||
row = _rows(handler)[0]
|
payload = _call(handler, {"strategy_instance_id": "instance-a"})
|
||||||
assert row["occupancy_seq"] == 1
|
state = payload["slots"][0]["strategy_state"]
|
||||||
|
assert state["data"] == "a" and set(state) == {"version", "data", "updated_at"}
|
||||||
|
assert "instance-b" not in repr(payload)
|
||||||
|
|
||||||
|
|
||||||
def test_row_carries_strategy_state_when_present():
|
def test_strategy_omits_absent_entry():
|
||||||
_, store, _, slot, handler = _wire()
|
_, _, _, handler = _wire()
|
||||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
row = _call(handler, {"strategy_instance_id": "absent"})["slots"][0]
|
||||||
data='{"peak":100}', current_occupancy_seq=1, slot_active=True)
|
|
||||||
row = _rows(handler)[0]
|
|
||||||
assert "strategy_state" in row
|
|
||||||
assert row["strategy_state"]["tsl_v1"]["version"] == 1
|
|
||||||
assert row["strategy_state"]["tsl_v1"]["data"] == '{"peak":100}'
|
|
||||||
assert "updated_at" in row["strategy_state"]["tsl_v1"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_row_omits_strategy_state_when_absent():
|
|
||||||
_, _, _, _, handler = _wire()
|
|
||||||
row = _rows(handler)[0]
|
|
||||||
assert "strategy_state" not in row
|
assert "strategy_state" not in row
|
||||||
|
|
||||||
|
|
||||||
def test_no_strategy_reasoning_fields():
|
@pytest.mark.parametrize("identity", [None, "", False, 1, []])
|
||||||
"""REQ-074 AC-5 (REQ-116 additive 재도입 후에도 유지): slot-origin strategy fields absent.
|
def test_malformed_strategy_request_has_exact_safe_envelope(identity):
|
||||||
|
_, _, watchdog, handler = _wire()
|
||||||
strategy_params / strategy_name / markets are now present (REQ-116 PortfolioItem-origin);
|
assert _call(handler, {"strategy_instance_id": identity}) == {
|
||||||
slot-origin strategy_id / sell_strategy_id / active_strategy_id remain absent.
|
"slots": [], "available_slots": None, "degraded": False,
|
||||||
"""
|
"reason": "bad_request:strategy_instance_id",
|
||||||
_, store, _, slot, handler = _wire()
|
"message": "strategy_instance_id must be a non-empty string",
|
||||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
}
|
||||||
data="x", current_occupancy_seq=1, slot_active=True)
|
assert watchdog.last_seen("unused") is None
|
||||||
row = _rows(handler)[0]
|
|
||||||
# slot-origin fields must be absent (REQ-074 AC-5 unchanged)
|
|
||||||
for k in ("strategy_id", "sell_strategy_id", "active_strategy_id"):
|
|
||||||
assert k not in row
|
|
||||||
# PortfolioItem-origin fields now present (REQ-116 additive)
|
|
||||||
assert "strategy_params" in row # {} (safe default, no snapshot_store)
|
|
||||||
assert "strategy_name" in row # None
|
|
||||||
assert "markets" in row # []
|
|
||||||
|
|
||||||
|
|
||||||
def test_reused_slot_shows_no_state():
|
def test_operator_without_or_with_id_receives_full_map_without_watchdog_update():
|
||||||
"""After a full close + reuse cycle, no prior-occupancy state surfaces
|
slot, _, watchdog, handler = _wire()
|
||||||
(close-delete + read-side occupancy filter). occupancy_seq advances to 2."""
|
for payload in ({"spy": True}, {"spy": True, "strategy_instance_id": "instance-a"}):
|
||||||
repo, store, service, slot, handler = _wire()
|
state = _call(handler, payload)["slots"][0]["strategy_state"]
|
||||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
assert set(state) == {"instance-a", "instance-b"}
|
||||||
data="old", current_occupancy_seq=1, slot_active=True)
|
assert watchdog.last_seen(str(slot.slot_id)) is None
|
||||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
|
||||||
reason="sell", actor="t")
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
@pytest.mark.parametrize("spy", ["false", "true", 1, [], {}])
|
||||||
reason="sold", actor="t")
|
def test_non_boolean_spy_is_rejected_without_visibility_or_watchdog_update(spy):
|
||||||
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
slot, _, watchdog, handler = _wire()
|
||||||
reason="buy", actor="t") # occupancy_seq -> 2
|
payload = _call(handler, {"spy": spy})
|
||||||
row = _rows(handler)[0]
|
assert payload == {
|
||||||
assert row["occupancy_seq"] == 2
|
"slots": [], "available_slots": None, "degraded": False,
|
||||||
assert "strategy_state" not in row
|
"reason": "bad_request:spy", "message": "spy must be a boolean",
|
||||||
|
}
|
||||||
|
assert watchdog.last_seen(str(slot.slot_id)) is None
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"""strategy_state.set write RPC handler tests (dcy-028 / REQ-080, BAT-042).
|
"""strategy_state.set strategy-instance wire contract tests."""
|
||||||
|
|
||||||
Covers the full reject reason set + success + bad_request, with the handler
|
|
||||||
supplying the slot's current occupancy_seq / active-ness to the store CAS.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from trex_core.application import strategy_state as ss
|
from trex_core.application import strategy_state as ss
|
||||||
from trex_core.application.messaging import RpcRequest
|
from trex_core.application.messaging import RpcRequest
|
||||||
from trex_core.application.messaging_handlers import make_strategy_state_set_handler
|
from trex_core.application.messaging_handlers import make_strategy_state_set_handler
|
||||||
@@ -12,109 +10,41 @@ from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
|||||||
from trex_core.domain.slot import SlotService, SlotState
|
from trex_core.domain.slot import SlotService, SlotState
|
||||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||||
|
|
||||||
INST = "default"
|
|
||||||
|
|
||||||
|
|
||||||
def _wire():
|
def _wire():
|
||||||
repo = InMemorySlotRepository()
|
repo, store = InMemorySlotRepository(), InMemoryStrategyStateStore()
|
||||||
store = InMemoryStrategyStateStore()
|
service = SlotService(repo)
|
||||||
service = SlotService(repo, instance_key=INST, strategy_state_store=store)
|
repo.reconcile_universe("default", {"pf": "SYM"})
|
||||||
repo.reconcile_universe(INST, {"pf-0": "SYM"})
|
slot = repo.list_slots("default")[0]
|
||||||
slot = repo.list_slots(INST)[0]
|
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
|
||||||
# activate to holding (occupancy_seq -> 1)
|
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1, avg_price=1)
|
||||||
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
return repo, store, service, slot, make_strategy_state_set_handler(repo, store)
|
||||||
reason="buy", actor="t")
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
|
||||||
reason="filled", actor="t", qty=10, avg_price=100)
|
|
||||||
handler = make_strategy_state_set_handler(repo, store)
|
|
||||||
return repo, store, service, slot, handler
|
|
||||||
|
|
||||||
|
|
||||||
def _req(slot_id, *, ev=0, eo=1, data="peak", strat="tsl_v1"):
|
def _req(slot_id, instance="instance-a", version=0, data="state"):
|
||||||
return RpcRequest(payload={
|
return RpcRequest(payload={"slot_id": str(slot_id), "strategy_instance_id": instance, "expected_version": version, "data": data}, correlation_id="same-correlation-id")
|
||||||
"slot_id": str(slot_id), "strategy_id": strat,
|
|
||||||
"expected_version": ev, "expected_occupancy_seq": eo, "data": data,
|
|
||||||
}, correlation_id=f"{slot_id}:{strat}:{ev}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_first_write_ok():
|
def test_write_uses_instance_identity_and_entry_cas():
|
||||||
_, _, _, slot, handler = _wire()
|
_, _, _, slot, handler = _wire()
|
||||||
resp = handler(_req(slot.slot_id, ev=0))
|
assert handler(_req(slot.slot_id)).payload == {"ok": True, "slot_id": str(slot.slot_id), "strategy_instance_id": "instance-a", "version": 1}
|
||||||
assert resp.payload["ok"] is True and resp.payload["version"] == 1
|
conflict = handler(_req(slot.slot_id, version=0, data="stale")).payload
|
||||||
|
assert conflict["reason"] == ss.REASON_VERSION_CONFLICT and conflict["version"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_version_conflict():
|
@pytest.mark.parametrize("identity", [None, "", 1, True, []])
|
||||||
_, _, _, slot, handler = _wire()
|
def test_malformed_instance_id_is_rejected_without_write(identity):
|
||||||
handler(_req(slot.slot_id, ev=0)) # version -> 1
|
_, store, _, slot, handler = _wire()
|
||||||
resp = handler(_req(slot.slot_id, ev=0, data="stale"))
|
response = handler(_req(slot.slot_id, identity)).payload
|
||||||
assert resp.payload["ok"] is False
|
assert response["ok"] is False
|
||||||
assert resp.payload["reason"] == ss.REASON_VERSION_CONFLICT
|
assert response["reason"].startswith("bad_request:")
|
||||||
assert resp.payload["version"] == 1
|
assert store.get_for_slots([slot.slot_id]) == {}
|
||||||
|
|
||||||
|
|
||||||
def test_occupancy_changed():
|
def test_inactive_and_oversize_writes_do_not_change_existing_entry():
|
||||||
_, _, _, slot, handler = _wire()
|
|
||||||
# daemon read occupancy 0 (before activation) but slot is occupancy 1 now
|
|
||||||
resp = handler(_req(slot.slot_id, ev=0, eo=0))
|
|
||||||
assert resp.payload["ok"] is False
|
|
||||||
assert resp.payload["reason"] == ss.REASON_OCCUPANCY_CHANGED
|
|
||||||
assert resp.payload["occupancy_seq"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_slot_inactive():
|
|
||||||
repo, store, service, slot, handler = _wire()
|
repo, store, service, slot, handler = _wire()
|
||||||
# close the slot
|
handler(_req(slot.slot_id))
|
||||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, reason="sell", actor="t")
|
||||||
reason="sell", actor="t")
|
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, reason="sold", actor="t")
|
||||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
assert handler(_req(slot.slot_id, version=1)).payload["reason"] == ss.REASON_SLOT_INACTIVE
|
||||||
reason="sold", actor="t")
|
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 1
|
||||||
resp = handler(_req(slot.slot_id, ev=0, eo=1))
|
|
||||||
assert resp.payload["ok"] is False
|
|
||||||
assert resp.payload["reason"] == ss.REASON_SLOT_INACTIVE
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_slot_is_slot_inactive():
|
|
||||||
from uuid import uuid4
|
|
||||||
_, _, _, _, handler = _wire()
|
|
||||||
resp = handler(_req(uuid4(), ev=0, eo=1))
|
|
||||||
assert resp.payload["ok"] is False
|
|
||||||
assert resp.payload["reason"] == ss.REASON_SLOT_INACTIVE
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_too_large():
|
|
||||||
_, _, _, slot, handler = _wire()
|
|
||||||
big = "a" * (ss._STATE_CAP_BYTES + 1)
|
|
||||||
resp = handler(_req(slot.slot_id, ev=0, data=big))
|
|
||||||
assert resp.payload["ok"] is False
|
|
||||||
assert resp.payload["reason"] == ss.REASON_STATE_TOO_LARGE
|
|
||||||
|
|
||||||
|
|
||||||
def test_bad_request_missing_field():
|
|
||||||
_, _, _, slot, handler = _wire()
|
|
||||||
resp = handler(RpcRequest(payload={"slot_id": str(slot.slot_id)},
|
|
||||||
correlation_id="c"))
|
|
||||||
assert resp.payload["ok"] is False
|
|
||||||
assert resp.payload["reason"].startswith("bad_request:")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bad_request_non_string_data():
|
|
||||||
_, _, _, slot, handler = _wire()
|
|
||||||
resp = handler(RpcRequest(payload={
|
|
||||||
"slot_id": str(slot.slot_id), "strategy_id": "tsl_v1",
|
|
||||||
"expected_version": 0, "expected_occupancy_seq": 1, "data": 123,
|
|
||||||
}, correlation_id="c"))
|
|
||||||
assert resp.payload["ok"] is False
|
|
||||||
assert resp.payload["reason"].startswith("bad_request:")
|
|
||||||
|
|
||||||
|
|
||||||
def test_reason_set_is_enumerated():
|
|
||||||
"""All non-ok reasons must be within the documented set (REQ-080 AC-6)."""
|
|
||||||
allowed_prefixes = {
|
|
||||||
ss.REASON_OCCUPANCY_CHANGED, ss.REASON_VERSION_CONFLICT,
|
|
||||||
ss.REASON_SLOT_INACTIVE, ss.REASON_STATE_TOO_LARGE, "bad_request",
|
|
||||||
}
|
|
||||||
_, _, _, slot, handler = _wire()
|
|
||||||
handler(_req(slot.slot_id, ev=0))
|
|
||||||
bad = handler(_req(slot.slot_id, ev=0, data="x")) # version_conflict
|
|
||||||
reason = bad.payload["reason"].split(":")[0]
|
|
||||||
assert reason in allowed_prefixes
|
|
||||||
|
|||||||
@@ -1,118 +1,53 @@
|
|||||||
"""InMemoryStrategyStateStore CAS tests (dcy-028 / REQ-079, BAT-042).
|
"""Slot-scoped strategy-instance state store contract tests."""
|
||||||
|
|
||||||
Covers the 6 CAS outcomes (occupancy+version commit / version_conflict /
|
|
||||||
occupancy_changed / slot_inactive / state_too_large / first-create), idempotent
|
|
||||||
re-send, batched read with stale-occupancy filtering, and delete-on-close.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from trex_core.application import strategy_state as ss
|
from trex_core.application import strategy_state as ss
|
||||||
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def _set(store, slot_id, instance="instance-a", *, version=0, data="x", active=True):
|
||||||
def store():
|
|
||||||
return InMemoryStrategyStateStore()
|
|
||||||
|
|
||||||
|
|
||||||
def _set(store, slot_id, *, ev=0, eo=1, data="x", cur_occ=1, active=True, strat="tsl_v1"):
|
|
||||||
return store.set(
|
return store.set(
|
||||||
slot_id, strat,
|
slot_id, instance, expected_version=version, data=data, slot_active=active
|
||||||
expected_version=ev, expected_occupancy_seq=eo, data=data,
|
|
||||||
current_occupancy_seq=cur_occ, slot_active=active,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestCas:
|
def test_entry_versions_are_isolated():
|
||||||
def test_first_create(self, store):
|
store, slot_id = InMemoryStrategyStateStore(), uuid4()
|
||||||
sid = uuid4()
|
assert _set(store, slot_id, "a", data="a1").version == 1
|
||||||
r = _set(store, sid, ev=0, eo=1, cur_occ=1, data='{"peak":100}')
|
assert _set(store, slot_id, "b", data="b1").version == 1
|
||||||
assert r.ok and r.version == 1
|
assert _set(store, slot_id, "a", version=1, data="a2").version == 2
|
||||||
|
entries = store.get_for_slots([slot_id])[slot_id]
|
||||||
def test_commit_increments_version(self, store):
|
assert entries["a"].version == 2 and entries["a"].data == "a2"
|
||||||
sid = uuid4()
|
assert entries["b"].version == 1 and entries["b"].data == "b1"
|
||||||
_set(store, sid, ev=0)
|
assert not hasattr(entries["a"], "strategy_id")
|
||||||
r = _set(store, sid, ev=1, data="next")
|
assert not hasattr(entries["a"], "occupancy_seq")
|
||||||
assert r.ok and r.version == 2
|
|
||||||
|
|
||||||
def test_version_conflict_does_not_store(self, store):
|
|
||||||
sid = uuid4()
|
|
||||||
_set(store, sid, ev=0, data="first") # version=1
|
|
||||||
r = _set(store, sid, ev=0, data="stale") # expects 0, stored 1
|
|
||||||
assert not r.ok and r.reason == ss.REASON_VERSION_CONFLICT and r.version == 1
|
|
||||||
# original preserved
|
|
||||||
got = store.get_for_slots([sid], {sid: 1})[sid]["tsl_v1"]
|
|
||||||
assert got.data == "first" and got.version == 1
|
|
||||||
|
|
||||||
def test_occupancy_changed_does_not_store(self, store):
|
|
||||||
sid = uuid4()
|
|
||||||
# daemon read occupancy_seq=1 but slot is now occupancy 2
|
|
||||||
r = _set(store, sid, ev=0, eo=1, cur_occ=2)
|
|
||||||
assert not r.ok and r.reason == ss.REASON_OCCUPANCY_CHANGED and r.occupancy_seq == 2
|
|
||||||
assert store.get_for_slots([sid], {sid: 2}) == {}
|
|
||||||
|
|
||||||
def test_slot_inactive_does_not_store(self, store):
|
|
||||||
sid = uuid4()
|
|
||||||
r = _set(store, sid, ev=0, active=False)
|
|
||||||
assert not r.ok and r.reason == ss.REASON_SLOT_INACTIVE
|
|
||||||
assert store.get_for_slots([sid], {sid: 1}) == {}
|
|
||||||
|
|
||||||
def test_state_too_large(self, store, monkeypatch):
|
|
||||||
sid = uuid4()
|
|
||||||
big = "a" * (ss._STATE_CAP_BYTES + 1)
|
|
||||||
r = _set(store, sid, ev=0, data=big)
|
|
||||||
assert not r.ok and r.reason == ss.REASON_STATE_TOO_LARGE
|
|
||||||
|
|
||||||
def test_idempotent_resend_is_conflict_not_double_apply(self, store):
|
|
||||||
sid = uuid4()
|
|
||||||
r1 = _set(store, sid, ev=0, data="v1") # -> version 1
|
|
||||||
assert r1.ok and r1.version == 1
|
|
||||||
# re-send identical expected_version=0 -> rejected (already advanced)
|
|
||||||
r2 = _set(store, sid, ev=0, data="v1")
|
|
||||||
assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT
|
|
||||||
|
|
||||||
|
|
||||||
class TestAbaGuard:
|
def test_conflict_inactive_and_size_limit_preserve_entries(monkeypatch):
|
||||||
def test_turnover_first_write_rejected(self, store):
|
store, slot_id = InMemoryStrategyStateStore(), uuid4()
|
||||||
"""dcy-028 R-1 / S-4: read v0 of occupancy 1, slot reused as occupancy 2,
|
_set(store, slot_id, "a", data="saved")
|
||||||
stale write against occupancy 1 must not bind to occupancy 2."""
|
assert _set(store, slot_id, "a", data="stale").reason == ss.REASON_VERSION_CONFLICT
|
||||||
sid = uuid4()
|
assert _set(store, slot_id, "b", active=False).reason == ss.REASON_SLOT_INACTIVE
|
||||||
# occupancy 2 is current; daemon computed against occupancy 1 (eo=1)
|
assert _set(store, slot_id, "b", data="x" * (ss._STATE_CAP_BYTES + 1)).reason == ss.REASON_STATE_TOO_LARGE
|
||||||
r = _set(store, sid, ev=0, eo=1, cur_occ=2, data="stale-peak")
|
entries = store.get_for_slots([slot_id])[slot_id]
|
||||||
assert not r.ok and r.reason == ss.REASON_OCCUPANCY_CHANGED
|
assert set(entries) == {"a"} and entries["a"].data == "saved"
|
||||||
# new occupancy starts clean
|
|
||||||
assert store.get_for_slots([sid], {sid: 2}) == {}
|
|
||||||
|
|
||||||
|
|
||||||
class TestBatchedRead:
|
def test_concurrent_different_entries_both_survive():
|
||||||
def test_get_for_slots_groups_by_slot_and_strategy(self, store):
|
store, slot_id = InMemoryStrategyStateStore(), uuid4()
|
||||||
a, b = uuid4(), uuid4()
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
_set(store, a, ev=0, cur_occ=1, strat="tsl_v1", data="A")
|
results = list(executor.map(lambda key: _set(store, slot_id, key, data=key), ("a", "b")))
|
||||||
_set(store, a, ev=0, cur_occ=1, strat="buy_v1", data="A2")
|
assert all(result.ok for result in results)
|
||||||
_set(store, b, ev=0, cur_occ=1, strat="tsl_v1", data="B")
|
assert set(store.get_for_slots([slot_id])[slot_id]) == {"a", "b"}
|
||||||
out = store.get_for_slots([a, b], {a: 1, b: 1})
|
|
||||||
assert set(out[a]) == {"tsl_v1", "buy_v1"}
|
|
||||||
assert out[a]["tsl_v1"].data == "A"
|
|
||||||
assert out[b]["tsl_v1"].data == "B"
|
|
||||||
|
|
||||||
def test_stale_occupancy_entries_invisible(self, store):
|
|
||||||
sid = uuid4()
|
|
||||||
_set(store, sid, ev=0, cur_occ=1, data="old") # stored at occupancy 1
|
|
||||||
# slot is now occupancy 2 -> the occupancy-1 entry must not surface
|
|
||||||
assert store.get_for_slots([sid], {sid: 2}) == {}
|
|
||||||
|
|
||||||
def test_empty_slot_ids(self, store):
|
|
||||||
assert store.get_for_slots([], {}) == {}
|
|
||||||
|
|
||||||
|
|
||||||
class TestDelete:
|
def test_same_entry_competing_cas_has_one_winner():
|
||||||
def test_delete_all_removes_every_strategy(self, store):
|
store, slot_id = InMemoryStrategyStateStore(), uuid4()
|
||||||
sid = uuid4()
|
_set(store, slot_id, "a", data="first")
|
||||||
_set(store, sid, ev=0, strat="tsl_v1")
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
_set(store, sid, ev=0, strat="buy_v1")
|
results = list(executor.map(lambda data: _set(store, slot_id, "a", version=1, data=data), ("x", "y")))
|
||||||
store.delete_all(sid)
|
assert sum(result.ok for result in results) == 1
|
||||||
assert store.get_for_slots([sid], {sid: 1}) == {}
|
assert store.get_for_slots([slot_id])[slot_id]["a"].version == 2
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ _PG_TABLES = (
|
|||||||
"daily_symbol_entries",
|
"daily_symbol_entries",
|
||||||
"daily_slot",
|
"daily_slot",
|
||||||
"reconcile_notification_state",
|
"reconcile_notification_state",
|
||||||
|
"slot_strategy_state",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +1,23 @@
|
|||||||
"""Slot occupancy_seq bump + close-delete hook (dcy-028 / REQ-081, BAT-042).
|
"""Slot occupancy remains intact while strategy state survives close/reopen."""
|
||||||
|
|
||||||
S-3: every close path (→INACTIVE) deletes the slot's strategy_state and a reused
|
|
||||||
slot starts with a new occupancy_seq + empty state. occupancy_seq is stable
|
|
||||||
across one holding episode and bumps on inactive→pending_buy.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
||||||
from trex_core.domain.slot import SlotService, SlotState
|
from trex_core.domain.slot import SlotService, SlotState
|
||||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||||
|
|
||||||
INST = "default"
|
|
||||||
|
|
||||||
|
def test_close_preserves_state_and_reopen_continues_version():
|
||||||
def _seed(repo):
|
repo, store = InMemorySlotRepository(), InMemoryStrategyStateStore()
|
||||||
repo.reconcile_universe(INST, {"pf-0": "SYM"})
|
service = SlotService(repo)
|
||||||
return repo.list_slots(INST)[0]
|
repo.reconcile_universe("default", {"pf": "SYM"})
|
||||||
|
slot = repo.list_slots("default")[0]
|
||||||
|
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
|
||||||
def _service(repo, store=None):
|
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1, avg_price=1)
|
||||||
return SlotService(repo, instance_key=INST, strategy_state_store=store)
|
assert store.set(slot.slot_id, "instance", expected_version=0, data="old", slot_active=True).version == 1
|
||||||
|
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, reason="sell", actor="t")
|
||||||
|
inactive = service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, reason="sold", actor="t")
|
||||||
def _activate_to_holding(service, slot_id):
|
assert inactive.occupancy_seq == 1
|
||||||
service.transit_slot(slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance"].data == "old"
|
||||||
reason="buy", actor="t")
|
reopened = service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
|
||||||
return service.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
assert reopened.occupancy_seq == 2
|
||||||
reason="filled", actor="t", qty=10, avg_price=100)
|
assert store.set(slot.slot_id, "instance", expected_version=1, data="new", slot_active=True).version == 2
|
||||||
|
|
||||||
|
|
||||||
class TestOccupancySeq:
|
|
||||||
def test_bumps_on_inactive_to_pending_buy(self):
|
|
||||||
repo = InMemorySlotRepository()
|
|
||||||
service = _service(repo)
|
|
||||||
slot = _seed(repo)
|
|
||||||
assert slot.occupancy_seq == 0
|
|
||||||
updated = service.transit_slot(
|
|
||||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
|
||||||
reason="buy", actor="t")
|
|
||||||
assert updated.occupancy_seq == 1
|
|
||||||
|
|
||||||
def test_stable_across_holding_episode(self):
|
|
||||||
repo = InMemorySlotRepository()
|
|
||||||
service = _service(repo)
|
|
||||||
slot = _seed(repo)
|
|
||||||
held = _activate_to_holding(service, slot.slot_id)
|
|
||||||
assert held.occupancy_seq == 1
|
|
||||||
sold = service.transit_slot(slot.slot_id, SlotState.HOLDING,
|
|
||||||
SlotState.PENDING_SELL, reason="sell", actor="t")
|
|
||||||
assert sold.occupancy_seq == 1 # unchanged within the episode
|
|
||||||
|
|
||||||
def test_increments_on_reuse(self):
|
|
||||||
repo = InMemorySlotRepository()
|
|
||||||
service = _service(repo)
|
|
||||||
slot = _seed(repo)
|
|
||||||
_activate_to_holding(service, slot.slot_id)
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
|
||||||
reason="sell", actor="t")
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
|
||||||
reason="sold", actor="t")
|
|
||||||
# reuse: occupancy_seq bumps from 1 to 2
|
|
||||||
reused = service.transit_slot(slot.slot_id, SlotState.INACTIVE,
|
|
||||||
SlotState.PENDING_BUY, reason="buy", actor="t")
|
|
||||||
assert reused.occupancy_seq == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestCloseDelete:
|
|
||||||
def _setup(self):
|
|
||||||
repo = InMemorySlotRepository()
|
|
||||||
store = InMemoryStrategyStateStore()
|
|
||||||
service = _service(repo, store)
|
|
||||||
slot = _seed(repo)
|
|
||||||
_activate_to_holding(service, slot.slot_id)
|
|
||||||
# daemon writes state during holding (occupancy_seq=1)
|
|
||||||
store.set(slot.slot_id, "tsl_v1", expected_version=0,
|
|
||||||
expected_occupancy_seq=1, data="peak", current_occupancy_seq=1,
|
|
||||||
slot_active=True)
|
|
||||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1})
|
|
||||||
return repo, store, service, slot
|
|
||||||
|
|
||||||
def test_sell_fill_close_deletes_state(self):
|
|
||||||
repo, store, service, slot = self._setup()
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
|
||||||
reason="sell", actor="t")
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
|
||||||
reason="sold", actor="t")
|
|
||||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {}
|
|
||||||
|
|
||||||
def test_reused_slot_starts_empty_with_new_occupancy(self):
|
|
||||||
repo, store, service, slot = self._setup()
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
|
||||||
reason="sell", actor="t")
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
|
||||||
reason="sold", actor="t")
|
|
||||||
reused = service.transit_slot(slot.slot_id, SlotState.INACTIVE,
|
|
||||||
SlotState.PENDING_BUY, reason="buy", actor="t")
|
|
||||||
assert reused.occupancy_seq == 2
|
|
||||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 2}) == {}
|
|
||||||
|
|
||||||
def test_error_close_path_also_deletes(self):
|
|
||||||
repo, store, service, slot = self._setup()
|
|
||||||
# holding → error → inactive (operator resolve) also clears state
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.ERROR,
|
|
||||||
reason="err", actor="t")
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.ERROR, SlotState.INACTIVE,
|
|
||||||
reason="resolve", actor="t")
|
|
||||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {}
|
|
||||||
|
|
||||||
def test_no_store_injected_is_noop(self):
|
|
||||||
# backward-compat: service without store does not raise on close
|
|
||||||
repo = InMemorySlotRepository()
|
|
||||||
service = _service(repo, store=None)
|
|
||||||
slot = _seed(repo)
|
|
||||||
_activate_to_holding(service, slot.slot_id)
|
|
||||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
|
||||||
reason="sell", actor="t")
|
|
||||||
out = service.transit_slot(slot.slot_id, SlotState.PENDING_SELL,
|
|
||||||
SlotState.INACTIVE, reason="sold", actor="t")
|
|
||||||
assert out.status is SlotState.INACTIVE
|
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
|
||||||
WORKFLOW_PATH = REPO / ".github" / "workflows" / "publish-image.yml"
|
|
||||||
|
|
||||||
|
|
||||||
def _workflow():
|
|
||||||
return yaml.load(WORKFLOW_PATH.read_text(), Loader=yaml.BaseLoader)
|
|
||||||
|
|
||||||
|
|
||||||
def _step_index(steps, name):
|
|
||||||
return next(index for index, step in enumerate(steps) if step.get("name") == name)
|
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_triggers_only_v_tag_push_candidates():
|
|
||||||
workflow = _workflow()
|
|
||||||
assert set(workflow["on"]) == {"push"}
|
|
||||||
assert workflow["on"]["push"] == {"tags": ["v*"]}
|
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_rejects_non_strict_version_events_before_checkout_and_build():
|
|
||||||
steps = _workflow()["jobs"]["verify"]["steps"]
|
|
||||||
guard = steps[0]
|
|
||||||
assert guard["name"] == "Reject non-version publication events"
|
|
||||||
assert guard["env"] == {
|
|
||||||
"PUBLICATION_EVENT": "${{ github.event_name }}",
|
|
||||||
"PUBLICATION_REF": "${{ github.ref }}",
|
|
||||||
}
|
|
||||||
assert '"${PUBLICATION_EVENT}" != "push"' in guard["run"]
|
|
||||||
strict_ref = (
|
|
||||||
r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$"
|
|
||||||
)
|
|
||||||
assert strict_ref in guard["run"]
|
|
||||||
assert _step_index(steps, guard["name"]) < _step_index(steps, "Check out exact source commit")
|
|
||||||
assert _step_index(steps, guard["name"]) < _step_index(steps, "Build source image once")
|
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_reverifies_exact_checked_out_commit_before_repository_or_registry_work():
|
|
||||||
workflow = _workflow()
|
|
||||||
verify_steps = workflow["jobs"]["verify"]["steps"]
|
|
||||||
publish_steps = workflow["jobs"]["publish"]["steps"]
|
|
||||||
checkout_index = _step_index(verify_steps, "Check out exact source commit")
|
|
||||||
reverify_index = _step_index(verify_steps, "Reverify exact checked out commit")
|
|
||||||
|
|
||||||
assert checkout_index < reverify_index < _step_index(verify_steps, "Install repository checks")
|
|
||||||
assert verify_steps[reverify_index]["run"] == (
|
|
||||||
'python scripts/image_release.py verify-checkout --commit "${GITHUB_SHA}"'
|
|
||||||
)
|
|
||||||
assert reverify_index < _step_index(verify_steps, "Build source image once")
|
|
||||||
assert all("REGISTRY_TOKEN" not in str(step) for step in verify_steps[: reverify_index + 1])
|
|
||||||
assert _step_index(publish_steps, "Verify, load, and publish without rebuilding") > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_publish_is_gated_and_has_the_only_package_write_permission():
|
|
||||||
workflow = _workflow()
|
|
||||||
verify = workflow["jobs"]["verify"]
|
|
||||||
publish = workflow["jobs"]["publish"]
|
|
||||||
|
|
||||||
assert publish["needs"] == "verify"
|
|
||||||
assert publish["permissions"]["packages"] == "write"
|
|
||||||
assert "packages" not in workflow["permissions"]
|
|
||||||
assert "packages" not in verify["permissions"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_publish_job_exposes_selected_version_tag_with_other_release_evidence():
|
|
||||||
outputs = _workflow()["jobs"]["publish"]["outputs"]
|
|
||||||
assert outputs == {
|
|
||||||
"image": "${{ steps.publish.outputs.image }}",
|
|
||||||
"digest": "${{ steps.publish.outputs.digest }}",
|
|
||||||
"source_commit": "${{ steps.publish.outputs.source_commit }}",
|
|
||||||
"version_tag": "${{ steps.publish.outputs.version_tag }}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_canary_build_smoke_export_scan_checksum_upload_contract():
|
|
||||||
steps = _workflow()["jobs"]["verify"]["steps"]
|
|
||||||
names = [step["name"] for step in steps]
|
|
||||||
assert names.count("Create excluded synthetic canary") == 1
|
|
||||||
assert names.count("Build source image once") == 1
|
|
||||||
assert names.count("Scan all canary surfaces") == 1
|
|
||||||
|
|
||||||
canary = steps[_step_index(steps, "Create excluded synthetic canary")]["run"]
|
|
||||||
assert 'Path(".env.ci-canary")' in canary
|
|
||||||
assert "TREX_SECRET_CANARY_DO_NOT_SHIP" in canary
|
|
||||||
|
|
||||||
scan = steps[_step_index(steps, "Scan all canary surfaces")]["run"]
|
|
||||||
assert "image_release.py scan" in scan
|
|
||||||
status_contract = '''>canary-scan.stdout 2>canary-scan.stderr
|
|
||||||
scan_status=$?
|
|
||||||
set -e
|
|
||||||
python scripts/image_release.py assert-clean-output \\
|
|
||||||
canary-scan.stdout canary-scan.stderr
|
|
||||||
exit "${scan_status}"'''
|
|
||||||
assert status_contract in scan
|
|
||||||
assert scan.count("scan_status=$?") == 1
|
|
||||||
assert scan.rstrip().endswith('exit "${scan_status}"')
|
|
||||||
|
|
||||||
assert _step_index(steps, "Run repository lint") < _step_index(steps, "Build source image once")
|
|
||||||
assert _step_index(steps, "Run repository tests") < _step_index(steps, "Build source image once")
|
|
||||||
ordered = [
|
|
||||||
"Create excluded synthetic canary",
|
|
||||||
"Build source image once",
|
|
||||||
"Smoke built image",
|
|
||||||
"Export tested image",
|
|
||||||
"Scan all canary surfaces",
|
|
||||||
"Record tested archive checksum",
|
|
||||||
"Upload immutable tested image artifact",
|
|
||||||
]
|
|
||||||
assert [_step_index(steps, name) for name in ordered] == sorted(
|
|
||||||
_step_index(steps, name) for name in ordered
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_repository_checks_use_the_locked_uv_environment():
|
|
||||||
steps = _workflow()["jobs"]["verify"]["steps"]
|
|
||||||
install = steps[_step_index(steps, "Install repository checks")]["run"]
|
|
||||||
lint = steps[_step_index(steps, "Run repository lint")]["run"]
|
|
||||||
tests = steps[_step_index(steps, "Run repository tests")]["run"]
|
|
||||||
|
|
||||||
assert 'python -m pip install "uv==0.11.14"' in install
|
|
||||||
assert "uv sync --extra dev --locked" in install
|
|
||||||
assert lint == "uv run ruff check ."
|
|
||||||
assert tests == "uv run pytest"
|
|
||||||
assert 'pip install ".[dev]"' not in install
|
|
||||||
|
|
||||||
|
|
||||||
def test_tested_image_id_is_recorded_after_smoke_and_transferred_to_publish():
|
|
||||||
workflow = _workflow()
|
|
||||||
verify_steps = workflow["jobs"]["verify"]["steps"]
|
|
||||||
publish_steps = workflow["jobs"]["publish"]["steps"]
|
|
||||||
|
|
||||||
smoke_index = _step_index(verify_steps, "Smoke built image")
|
|
||||||
identity_index = _step_index(verify_steps, "Record tested image ID")
|
|
||||||
export_index = _step_index(verify_steps, "Export tested image")
|
|
||||||
assert smoke_index < identity_index < export_index
|
|
||||||
|
|
||||||
identity = verify_steps[identity_index]["run"]
|
|
||||||
assert "image_release.py record-image-id" in identity
|
|
||||||
assert "--output trex-core-image.id" in identity
|
|
||||||
|
|
||||||
upload = verify_steps[_step_index(verify_steps, "Upload immutable tested image artifact")]["with"]
|
|
||||||
assert "trex-core-image.id" in upload["path"].splitlines()
|
|
||||||
|
|
||||||
publish = publish_steps[_step_index(publish_steps, "Verify, load, and publish without rebuilding")][
|
|
||||||
"run"
|
|
||||||
]
|
|
||||||
assert "--image-id tested-image/trex-core-image.id" in publish
|
|
||||||
|
|
||||||
|
|
||||||
def test_publish_job_has_only_download_then_verified_publish_helper():
|
|
||||||
steps = _workflow()["jobs"]["publish"]["steps"]
|
|
||||||
assert len(steps) == 2
|
|
||||||
assert "run" not in steps[0]
|
|
||||||
assert steps[0]["uses"].startswith("actions/download-artifact@")
|
|
||||||
|
|
||||||
run_steps = [step for step in steps if "run" in step]
|
|
||||||
assert run_steps == [steps[1]]
|
|
||||||
expected_run = '''python tested-image/scripts/image_release.py publish \\
|
|
||||||
--archive tested-image/trex-core-image.tar \\
|
|
||||||
--checksum tested-image/trex-core-image.tar.sha256 \\
|
|
||||||
--image-id tested-image/trex-core-image.id \\
|
|
||||||
--local-image "trex-core-ci:${GITHUB_SHA}" \\
|
|
||||||
--commit "${GITHUB_SHA}" \\
|
|
||||||
--event-name "${GITHUB_EVENT_NAME}" \\
|
|
||||||
--ref "${GITHUB_REF}"'''
|
|
||||||
assert steps[1]["run"].strip() == expected_run
|
|
||||||
|
|
||||||
rendered = "\n".join(str(step) for step in steps).lower()
|
|
||||||
for forbidden in (
|
|
||||||
"actions/checkout",
|
|
||||||
"docker login",
|
|
||||||
"docker push",
|
|
||||||
"docker build",
|
|
||||||
"buildx",
|
|
||||||
):
|
|
||||||
assert forbidden not in rendered
|
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_uses_full_sha_and_contains_no_latest_tag():
|
|
||||||
text = WORKFLOW_PATH.read_text()
|
|
||||||
assert "${GITHUB_SHA}" in text
|
|
||||||
assert "github.sha" in text
|
|
||||||
assert "--provenance=false" in text
|
|
||||||
assert ":latest" not in text.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_docker_context_contract():
|
|
||||||
dockerfile = (REPO / "Dockerfile").read_text()
|
|
||||||
copy_lines = [line.strip() for line in dockerfile.splitlines() if line.strip().startswith("COPY ")]
|
|
||||||
assert copy_lines == ["COPY pyproject.toml uv.lock ./", "COPY trex_core/ ./trex_core/"]
|
|
||||||
assert 'python -m pip install --no-cache-dir "uv==0.11.14"' in dockerfile
|
|
||||||
assert "uv sync --locked --no-dev --no-editable" in dockerfile
|
|
||||||
assert "pip install --no-cache-dir ." not in dockerfile
|
|
||||||
assert "org.opencontainers.image.revision" in dockerfile
|
|
||||||
assert "python:3.12" in dockerfile
|
|
||||||
|
|
||||||
ignored = set((REPO / ".dockerignore").read_text().splitlines())
|
|
||||||
assert {".git", ".env", ".env.*", "config/", ".proofline/"} <= ignored
|
|
||||||
|
|
||||||
|
|
||||||
def test_operator_documentation_preserves_rollback_boundaries():
|
|
||||||
operations = (REPO / "docs" / "operations.md").read_text()
|
|
||||||
assert "strict `vX.Y.Z` Git tag" in operations
|
|
||||||
assert "사용자 승인 외부 쓰기" in operations
|
|
||||||
assert "Git tag version은 `pyproject.toml` package version과 독립" in operations
|
|
||||||
assert "read-only volume" in operations
|
|
||||||
assert "runtime environment" in operations
|
|
||||||
assert "sha256:<64-hex>" in operations
|
|
||||||
assert "이전" in operations and "digest" in operations
|
|
||||||
assert "`trex-core-reset`" in operations
|
|
||||||
assert "자동 rollback 절차에 절대로 포함하지 않는다" in operations
|
|
||||||
@@ -1,687 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import gzip
|
|
||||||
import io
|
|
||||||
import subprocess
|
|
||||||
import tarfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scripts import image_release
|
|
||||||
|
|
||||||
|
|
||||||
COMMIT = "a" * 40
|
|
||||||
DIGEST = "sha256:" + "b" * 64
|
|
||||||
IMAGE_ID = "sha256:" + "c" * 64
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
|
||||||
MIGRATION_DIR = REPO / "trex_core" / "adapters" / "persistence" / "sql"
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingRunner:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
fail_on: tuple[str, ...] | None = None,
|
|
||||||
digest: str = DIGEST,
|
|
||||||
revision: str = COMMIT,
|
|
||||||
image_id: str = IMAGE_ID,
|
|
||||||
loaded_image: str = f"trex-core-ci:{COMMIT}",
|
|
||||||
):
|
|
||||||
self.commands: list[tuple[list[str], str | None, int | None]] = []
|
|
||||||
self.fail_on = fail_on
|
|
||||||
self.digest = digest
|
|
||||||
self.revision = revision
|
|
||||||
self.image_id = image_id
|
|
||||||
self.loaded_image = loaded_image
|
|
||||||
|
|
||||||
def run(self, command, *, input_text=None, timeout=None):
|
|
||||||
command = list(command)
|
|
||||||
self.commands.append((command, input_text, timeout))
|
|
||||||
if self.fail_on and command[: len(self.fail_on)] == list(self.fail_on):
|
|
||||||
raise subprocess.CalledProcessError(1, command)
|
|
||||||
if command == ["git", "rev-parse", "HEAD"]:
|
|
||||||
return image_release.Result(self.revision + "\n")
|
|
||||||
if command[:2] == ["docker", "load"]:
|
|
||||||
return image_release.Result(f"Loaded image: {self.loaded_image}\n")
|
|
||||||
if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command:
|
|
||||||
return image_release.Result(self.image_id + "\n")
|
|
||||||
if command[:3] == ["docker", "image", "inspect"]:
|
|
||||||
return image_release.Result(self.revision + "\n")
|
|
||||||
if command[:2] == ["docker", "push"]:
|
|
||||||
return image_release.Result(f"digest: {self.digest} size: 1234\n")
|
|
||||||
if command[:2] == ["docker", "create"]:
|
|
||||||
return image_release.Result("container-id\n")
|
|
||||||
return image_release.Result()
|
|
||||||
|
|
||||||
|
|
||||||
def _artifact(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
||||||
archive = tmp_path / "trex-core-image.tar"
|
|
||||||
checksum = tmp_path / "trex-core-image.tar.sha256"
|
|
||||||
image_id = tmp_path / "trex-core-image.id"
|
|
||||||
archive.write_bytes(b"tested image")
|
|
||||||
image_release.write_checksum(archive, checksum)
|
|
||||||
image_id.write_text(IMAGE_ID + "\n", encoding="ascii")
|
|
||||||
return archive, checksum, image_id
|
|
||||||
|
|
||||||
|
|
||||||
def test_release_tags_are_deterministic_and_never_latest():
|
|
||||||
release = image_release.release_tags(COMMIT, "push", "refs/tags/v1.2.3")
|
|
||||||
|
|
||||||
assert release == [
|
|
||||||
f"{image_release.IMAGE}:{COMMIT[:12]}",
|
|
||||||
f"{image_release.IMAGE}:v1.2.3",
|
|
||||||
]
|
|
||||||
assert all(not tag.endswith(":latest") for tag in release)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("ref", ["refs/tags/v0.0.0", "refs/tags/v10.20.30"])
|
|
||||||
def test_strict_version_tags_accept_zero_and_nonzero_decimal_components(ref):
|
|
||||||
assert image_release.validate_publication_trigger("push", ref) == ref.removeprefix(
|
|
||||||
"refs/tags/"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("event_name", "ref"),
|
|
||||||
[
|
|
||||||
("workflow_dispatch", "refs/tags/v1.2.3"),
|
|
||||||
("pull_request", "refs/tags/v1.2.3"),
|
|
||||||
("push", "refs/heads/main"),
|
|
||||||
("push", "refs/heads/release"),
|
|
||||||
("push", "refs/tags/1.2.3"),
|
|
||||||
("push", "refs/tags/v1.2"),
|
|
||||||
("push", "refs/tags/v1.2.3.4"),
|
|
||||||
("push", "refs/tags/v01.2.3"),
|
|
||||||
("push", "refs/tags/v1.02.3"),
|
|
||||||
("push", "refs/tags/v1.2.03"),
|
|
||||||
("push", "refs/tags/v1.2.3-rc.1"),
|
|
||||||
("push", "refs/tags/v1.2.3+build.1"),
|
|
||||||
("push", "refs/tags/v-1.2.3"),
|
|
||||||
("push", "refs/tags/v1.a.3"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_non_push_branch_and_malformed_version_triggers_are_rejected(event_name, ref):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
image_release.validate_publication_trigger(event_name, ref)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("commit", ["A" * 40, "a" * 39, "g" * 40, ""])
|
|
||||||
def test_invalid_source_commit_is_rejected(commit):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
image_release.release_tags(commit, "push", "refs/tags/v1.2.3")
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_checkout_requires_exact_git_head_match():
|
|
||||||
runner = RecordingRunner()
|
|
||||||
|
|
||||||
assert image_release.verify_checkout(COMMIT, runner) == COMMIT
|
|
||||||
assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_checkout_mismatch_fails_closed():
|
|
||||||
runner = RecordingRunner(revision="c" * 40)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="checked out HEAD does not match source commit"):
|
|
||||||
image_release.verify_checkout(COMMIT, runner)
|
|
||||||
assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
|
|
||||||
runner = RecordingRunner()
|
|
||||||
image_release.smoke_image("candidate", runner, timeout=17, migration_dir=MIGRATION_DIR)
|
|
||||||
|
|
||||||
runs = [call for call in runner.commands if call[0][:2] == ["docker", "run"]]
|
|
||||||
removals = [call for call in runner.commands if call[0][:2] == ["docker", "rm"]]
|
|
||||||
expected_migrations = tuple(sorted(path.name for path in MIGRATION_DIR.glob("*.sql")))
|
|
||||||
assert expected_migrations
|
|
||||||
assert len(runs) == len(removals) == 5
|
|
||||||
assert [call[0][call[0].index("--entrypoint") + 1 :] for call in runs] == [
|
|
||||||
["python", "candidate", "-c", "import trex_core"],
|
|
||||||
["trex-core-daemon", "candidate", "--help"],
|
|
||||||
["trex-core", "candidate", "--help"],
|
|
||||||
["trex-core-reset", "candidate", "--help"],
|
|
||||||
["python", "candidate", "-I", "-c", image_release.migration_inventory_code(expected_migrations)],
|
|
||||||
]
|
|
||||||
assert all("--network" in call[0] and "none" in call[0] for call in runs)
|
|
||||||
assert all("--read-only" in call[0] and "--cap-drop" in call[0] for call in runs)
|
|
||||||
assert all(call[2] == 17 for call in runs + removals)
|
|
||||||
assert all("--config" not in call[0] and "--yes" not in call[0] for call in runs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_smoke_rejects_empty_canonical_migration_inventory(tmp_path):
|
|
||||||
with pytest.raises(ValueError, match="canonical migration inventory is empty"):
|
|
||||||
image_release.smoke_image("candidate", RecordingRunner(), migration_dir=tmp_path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_inventory_probe_failure_propagates_after_cleanup():
|
|
||||||
class FifthProbeFailsRunner(RecordingRunner):
|
|
||||||
def run(self, command, *, input_text=None, timeout=None):
|
|
||||||
run_count = len([call for call in self.commands if call[0][:2] == ["docker", "run"]])
|
|
||||||
if list(command)[:2] == ["docker", "run"] and run_count == 4:
|
|
||||||
self.commands.append((list(command), input_text, timeout))
|
|
||||||
raise subprocess.CalledProcessError(1, command)
|
|
||||||
return super().run(command, input_text=input_text, timeout=timeout)
|
|
||||||
|
|
||||||
runner = FifthProbeFailsRunner()
|
|
||||||
with pytest.raises(RuntimeError, match="smoke probe 5 failed"):
|
|
||||||
image_release.smoke_image("candidate", runner, migration_dir=MIGRATION_DIR)
|
|
||||||
assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_smoke_failure_propagates_after_cleanup():
|
|
||||||
runner = RecordingRunner(fail_on=("docker", "run"))
|
|
||||||
with pytest.raises(RuntimeError, match="smoke probe 1 failed"):
|
|
||||||
image_release.smoke_image("candidate", runner)
|
|
||||||
assert [call[0][:3] for call in runner.commands] == [
|
|
||||||
["docker", "run", "--name"],
|
|
||||||
["docker", "rm", "--force"],
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_smoke_fails_if_captured_probe_output_contains_canary():
|
|
||||||
class LeakingRunner(RecordingRunner):
|
|
||||||
def run(self, command, *, input_text=None, timeout=None):
|
|
||||||
result = super().run(command, input_text=input_text, timeout=timeout)
|
|
||||||
if list(command)[:2] == ["docker", "run"]:
|
|
||||||
return image_release.Result(image_release.CANARY.decode())
|
|
||||||
return result
|
|
||||||
|
|
||||||
runner = LeakingRunner()
|
|
||||||
with pytest.raises(RuntimeError, match="smoke probe 1 failed"):
|
|
||||||
image_release.smoke_image("candidate", runner)
|
|
||||||
assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_mutated_archive_stops_before_load_or_registry(tmp_path):
|
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
|
||||||
archive.write_bytes(b"mutated")
|
|
||||||
runner = RecordingRunner()
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="checksum mismatch"):
|
|
||||||
image_release.publish_image(
|
|
||||||
archive=archive,
|
|
||||||
checksum=checksum,
|
|
||||||
image_id=image_id,
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name="push",
|
|
||||||
ref="refs/tags/v1.2.3",
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
)
|
|
||||||
assert runner.commands == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("event_name", "ref"),
|
|
||||||
[
|
|
||||||
("workflow_dispatch", "refs/tags/v1.2.3"),
|
|
||||||
("push", "refs/heads/main"),
|
|
||||||
("push", "refs/tags/v01.2.3"),
|
|
||||||
("push", "refs/tags/v1.2.3-rc.1"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_invalid_publication_trigger_stops_before_artifact_load_or_registry(
|
|
||||||
tmp_path, event_name, ref
|
|
||||||
):
|
|
||||||
runner = RecordingRunner()
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="publication"):
|
|
||||||
image_release.publish_image(
|
|
||||||
archive=tmp_path / "missing.tar",
|
|
||||||
checksum=tmp_path / "missing.sha256",
|
|
||||||
image_id=tmp_path / "missing.id",
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name=event_name,
|
|
||||||
ref=ref,
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
)
|
|
||||||
assert runner.commands == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_failure_stops_before_login_and_push(tmp_path):
|
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
|
||||||
runner = RecordingRunner(fail_on=("docker", "load"))
|
|
||||||
|
|
||||||
with pytest.raises(subprocess.CalledProcessError):
|
|
||||||
image_release.publish_image(
|
|
||||||
archive=archive,
|
|
||||||
checksum=checksum,
|
|
||||||
image_id=image_id,
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name="push",
|
|
||||||
ref="refs/tags/v1.2.3",
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
)
|
|
||||||
assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands)
|
|
||||||
|
|
||||||
|
|
||||||
def test_revision_mismatch_stops_before_login_and_push(tmp_path):
|
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
|
||||||
runner = RecordingRunner(revision="c" * 40)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="revision does not match"):
|
|
||||||
image_release.publish_image(
|
|
||||||
archive=archive,
|
|
||||||
checksum=checksum,
|
|
||||||
image_id=image_id,
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name="push",
|
|
||||||
ref="refs/tags/v1.2.3",
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
)
|
|
||||||
assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands)
|
|
||||||
|
|
||||||
|
|
||||||
def test_expected_image_id_mismatch_stops_before_credentials_tags_or_push(tmp_path):
|
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
|
||||||
runner = RecordingRunner(image_id="sha256:" + "d" * 64)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="image ID does not match"):
|
|
||||||
image_release.publish_image(
|
|
||||||
archive=archive,
|
|
||||||
checksum=checksum,
|
|
||||||
image_id=image_id,
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name="push",
|
|
||||||
ref="refs/tags/v1.2.3",
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
)
|
|
||||||
assert not any(
|
|
||||||
call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"])
|
|
||||||
for call in runner.commands
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unrelated_load_output_cannot_use_stale_expected_local_tag(tmp_path):
|
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
|
||||||
runner = RecordingRunner(loaded_image="unrelated:image", image_id=IMAGE_ID)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="did not report the expected local image"):
|
|
||||||
image_release.publish_image(
|
|
||||||
archive=archive,
|
|
||||||
checksum=checksum,
|
|
||||||
image_id=image_id,
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name="push",
|
|
||||||
ref="refs/tags/v1.2.3",
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
)
|
|
||||||
assert not any(
|
|
||||||
call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"])
|
|
||||||
for call in runner.commands
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"invalid_id",
|
|
||||||
["", "sha256:" + "c" * 63, "sha256:" + "C" * 64, "sha512:" + "c" * 64],
|
|
||||||
)
|
|
||||||
def test_invalid_tested_image_id_stops_before_load(tmp_path, invalid_id):
|
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
|
||||||
image_id.write_text(invalid_id, encoding="ascii")
|
|
||||||
runner = RecordingRunner()
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="image ID evidence"):
|
|
||||||
image_release.publish_image(
|
|
||||||
archive=archive,
|
|
||||||
checksum=checksum,
|
|
||||||
image_id=image_id,
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name="push",
|
|
||||||
ref="refs/tags/v1.2.3",
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
)
|
|
||||||
assert runner.commands == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_record_image_id_writes_strict_inspected_id(tmp_path):
|
|
||||||
output = tmp_path / "trex-core-image.id"
|
|
||||||
runner = RecordingRunner()
|
|
||||||
assert image_release.write_image_id("candidate", output, runner) == IMAGE_ID
|
|
||||||
assert output.read_text(encoding="ascii") == IMAGE_ID + "\n"
|
|
||||||
assert runner.commands == [
|
|
||||||
(["docker", "image", "inspect", "--format", "{{.Id}}", "candidate"], None, None)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_publish_records_exact_release_evidence_only_after_push(tmp_path):
|
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
|
||||||
output = tmp_path / "output"
|
|
||||||
summary = tmp_path / "summary"
|
|
||||||
runner = RecordingRunner()
|
|
||||||
|
|
||||||
digest = image_release.publish_image(
|
|
||||||
archive=archive,
|
|
||||||
checksum=checksum,
|
|
||||||
image_id=image_id,
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name="push",
|
|
||||||
ref="refs/tags/v2.0.0",
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
output_path=output,
|
|
||||||
summary_path=summary,
|
|
||||||
)
|
|
||||||
|
|
||||||
commands = [call[0][:2] for call in runner.commands]
|
|
||||||
assert commands.index(["docker", "load"]) < commands.index(["docker", "login"])
|
|
||||||
assert commands.count(["docker", "push"]) == 2
|
|
||||||
assert digest == DIGEST
|
|
||||||
assert output.read_text() == (
|
|
||||||
f"image={image_release.IMAGE}@{DIGEST}\n"
|
|
||||||
f"digest={DIGEST}\n"
|
|
||||||
f"source_commit={COMMIT}\n"
|
|
||||||
"version_tag=v2.0.0\n"
|
|
||||||
)
|
|
||||||
assert summary.read_text() == (
|
|
||||||
"## Published trex-core image\n\n"
|
|
||||||
f"- Image: `{image_release.IMAGE}@{DIGEST}`\n"
|
|
||||||
f"- Digest: `{DIGEST}`\n"
|
|
||||||
f"- Source commit: `{COMMIT}`\n"
|
|
||||||
"- Version tag: `v2.0.0`\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_digest", ["", "sha256:" + "b" * 63, "sha512:" + "b" * 64])
|
|
||||||
def test_invalid_post_push_digest_emits_no_success_evidence(tmp_path, bad_digest):
|
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
|
||||||
output = tmp_path / "output"
|
|
||||||
summary = tmp_path / "summary"
|
|
||||||
runner = RecordingRunner(digest=bad_digest)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="valid digest"):
|
|
||||||
image_release.publish_image(
|
|
||||||
archive=archive,
|
|
||||||
checksum=checksum,
|
|
||||||
image_id=image_id,
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name="push",
|
|
||||||
ref="refs/tags/v1.2.3",
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
output_path=output,
|
|
||||||
summary_path=summary,
|
|
||||||
)
|
|
||||||
assert any(call[0][:2] == ["docker", "push"] for call in runner.commands)
|
|
||||||
assert not output.exists()
|
|
||||||
assert not summary.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_release_second_push_failure_preserves_nonzero_without_success_evidence(tmp_path):
|
|
||||||
class SecondPushFailsRunner(RecordingRunner):
|
|
||||||
def run(self, command, *, input_text=None, timeout=None):
|
|
||||||
result = super().run(command, input_text=input_text, timeout=timeout)
|
|
||||||
pushes = [call for call in self.commands if call[0][:2] == ["docker", "push"]]
|
|
||||||
if list(command)[:2] == ["docker", "push"] and len(pushes) == 2:
|
|
||||||
return image_release.Result("digest unavailable\n")
|
|
||||||
return result
|
|
||||||
|
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
|
||||||
output = tmp_path / "output"
|
|
||||||
summary = tmp_path / "summary"
|
|
||||||
runner = SecondPushFailsRunner()
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="valid digest"):
|
|
||||||
image_release.publish_image(
|
|
||||||
archive=archive,
|
|
||||||
checksum=checksum,
|
|
||||||
image_id=image_id,
|
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
|
||||||
commit=COMMIT,
|
|
||||||
event_name="push",
|
|
||||||
ref="refs/tags/v2.0.0",
|
|
||||||
actor="actor",
|
|
||||||
token="fake-token",
|
|
||||||
runner=runner,
|
|
||||||
output_path=output,
|
|
||||||
summary_path=summary,
|
|
||||||
)
|
|
||||||
assert len([call for call in runner.commands if call[0][:2] == ["docker", "push"]]) == 2
|
|
||||||
assert not output.exists()
|
|
||||||
assert not summary.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_canary_is_found_inside_nested_export_archive(tmp_path):
|
|
||||||
layer = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=layer, mode="w") as archive:
|
|
||||||
payload = image_release.CANARY
|
|
||||||
member = tarfile.TarInfo("app/data")
|
|
||||||
member.size = len(payload)
|
|
||||||
archive.addfile(member, io.BytesIO(payload))
|
|
||||||
|
|
||||||
saved = tmp_path / "image.tar"
|
|
||||||
with tarfile.open(saved, mode="w") as archive:
|
|
||||||
payload = layer.getvalue()
|
|
||||||
member = tarfile.TarInfo("layer.tar")
|
|
||||||
member.size = len(payload)
|
|
||||||
archive.addfile(member, io.BytesIO(payload))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="canary marker found"):
|
|
||||||
image_release._scan_tar(saved)
|
|
||||||
|
|
||||||
|
|
||||||
def test_canary_is_found_inside_compressed_digest_named_blob(tmp_path):
|
|
||||||
inner = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=inner, mode="w") as archive:
|
|
||||||
payload = image_release.CANARY
|
|
||||||
member = tarfile.TarInfo("app/data")
|
|
||||||
member.size = len(payload)
|
|
||||||
archive.addfile(member, io.BytesIO(payload))
|
|
||||||
|
|
||||||
saved = tmp_path / "oci-image.tar"
|
|
||||||
with tarfile.open(saved, mode="w") as archive:
|
|
||||||
payload = gzip.compress(inner.getvalue())
|
|
||||||
member = tarfile.TarInfo("blobs/sha256/deadbeef")
|
|
||||||
member.size = len(payload)
|
|
||||||
archive.addfile(member, io.BytesIO(payload))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="canary marker found"):
|
|
||||||
image_release._scan_tar(saved)
|
|
||||||
|
|
||||||
|
|
||||||
def test_canary_is_found_in_symlink_metadata_inside_compressed_digest_blob(tmp_path):
|
|
||||||
inner = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=inner, mode="w") as archive:
|
|
||||||
member = tarfile.TarInfo("app/link")
|
|
||||||
member.type = tarfile.SYMTYPE
|
|
||||||
member.linkname = image_release.CANARY.decode()
|
|
||||||
archive.addfile(member)
|
|
||||||
|
|
||||||
saved = tmp_path / "oci-image.tar"
|
|
||||||
with tarfile.open(saved, mode="w") as archive:
|
|
||||||
payload = gzip.compress(inner.getvalue())
|
|
||||||
member = tarfile.TarInfo("blobs/sha256/deadbeef")
|
|
||||||
member.size = len(payload)
|
|
||||||
archive.addfile(member, io.BytesIO(payload))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="canary marker found"):
|
|
||||||
image_release._scan_tar(saved)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"pax_headers",
|
|
||||||
[
|
|
||||||
{"comment": image_release.CANARY.decode()},
|
|
||||||
{image_release.CANARY.decode(): "safe"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_canary_is_found_in_pax_metadata_inside_compressed_digest_blob(tmp_path, pax_headers):
|
|
||||||
inner = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=inner, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
|
||||||
member = tarfile.TarInfo("app/data")
|
|
||||||
member.pax_headers = pax_headers
|
|
||||||
archive.addfile(member)
|
|
||||||
|
|
||||||
saved = tmp_path / "oci-image.tar"
|
|
||||||
with tarfile.open(saved, mode="w") as archive:
|
|
||||||
payload = gzip.compress(inner.getvalue())
|
|
||||||
member = tarfile.TarInfo("blobs/sha256/deadbeef")
|
|
||||||
member.size = len(payload)
|
|
||||||
archive.addfile(member, io.BytesIO(payload))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="canary marker found"):
|
|
||||||
image_release._scan_tar(saved)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field", ["uname", "gname"])
|
|
||||||
def test_canary_is_found_in_owner_metadata_inside_compressed_digest_blob(tmp_path, field):
|
|
||||||
inner = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=inner, mode="w") as archive:
|
|
||||||
member = tarfile.TarInfo("app/data")
|
|
||||||
setattr(member, field, image_release.CANARY.decode())
|
|
||||||
archive.addfile(member)
|
|
||||||
|
|
||||||
saved = tmp_path / "oci-image.tar"
|
|
||||||
with tarfile.open(saved, mode="w") as archive:
|
|
||||||
payload = gzip.compress(inner.getvalue())
|
|
||||||
member = tarfile.TarInfo("blobs/sha256/deadbeef")
|
|
||||||
member.size = len(payload)
|
|
||||||
archive.addfile(member, io.BytesIO(payload))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="canary marker found"):
|
|
||||||
image_release._scan_tar(saved)
|
|
||||||
|
|
||||||
|
|
||||||
def test_archive_scan_rejects_excessive_nesting_depth(tmp_path):
|
|
||||||
payload = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=payload, mode="w"):
|
|
||||||
pass
|
|
||||||
nested = payload.getvalue()
|
|
||||||
for _ in range(4):
|
|
||||||
wrapped = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=wrapped, mode="w") as archive:
|
|
||||||
member = tarfile.TarInfo("blob")
|
|
||||||
member.size = len(nested)
|
|
||||||
archive.addfile(member, io.BytesIO(nested))
|
|
||||||
nested = wrapped.getvalue()
|
|
||||||
path = tmp_path / "deep.tar"
|
|
||||||
path.write_bytes(nested)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="archive scan resource limit exceeded") as error:
|
|
||||||
image_release._scan_tar(path, image_release._ScanBudget(max_depth=2))
|
|
||||||
assert image_release.CANARY.decode() not in str(error.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_archive_scan_rejects_excessive_aggregate_members(tmp_path):
|
|
||||||
path = tmp_path / "members.tar"
|
|
||||||
with tarfile.open(path, mode="w") as archive:
|
|
||||||
for index in range(3):
|
|
||||||
member = tarfile.TarInfo(f"directory-{index}")
|
|
||||||
member.type = tarfile.DIRTYPE
|
|
||||||
archive.addfile(member)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
|
|
||||||
image_release._scan_tar(path, image_release._ScanBudget(max_members=2))
|
|
||||||
|
|
||||||
|
|
||||||
def test_archive_scan_rejects_excessive_aggregate_extracted_bytes(tmp_path):
|
|
||||||
path = tmp_path / "aggregate.tar"
|
|
||||||
with tarfile.open(path, mode="w") as archive:
|
|
||||||
payload = b"123456"
|
|
||||||
member = tarfile.TarInfo("payload")
|
|
||||||
member.size = len(payload)
|
|
||||||
archive.addfile(member, io.BytesIO(payload))
|
|
||||||
|
|
||||||
budget = image_release._ScanBudget(max_extracted_bytes=5, max_member_bytes=10)
|
|
||||||
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
|
|
||||||
image_release._scan_tar(path, budget)
|
|
||||||
|
|
||||||
|
|
||||||
def test_archive_scan_rejects_excessive_per_member_bytes(tmp_path):
|
|
||||||
path = tmp_path / "member.tar"
|
|
||||||
with tarfile.open(path, mode="w") as archive:
|
|
||||||
payload = b"123456"
|
|
||||||
member = tarfile.TarInfo("payload")
|
|
||||||
member.size = len(payload)
|
|
||||||
archive.addfile(member, io.BytesIO(payload))
|
|
||||||
|
|
||||||
budget = image_release._ScanBudget(max_extracted_bytes=100, max_member_bytes=5)
|
|
||||||
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
|
|
||||||
image_release._scan_tar(path, budget)
|
|
||||||
|
|
||||||
|
|
||||||
def test_archive_scan_enforces_top_level_input_bytes_and_accepts_boundary(tmp_path):
|
|
||||||
path = tmp_path / "input.tar"
|
|
||||||
with tarfile.open(path, mode="w"):
|
|
||||||
pass
|
|
||||||
input_size = path.stat().st_size
|
|
||||||
|
|
||||||
image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size))
|
|
||||||
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
|
|
||||||
image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size - 1))
|
|
||||||
|
|
||||||
|
|
||||||
def test_canary_failure_message_does_not_disclose_marker(tmp_path):
|
|
||||||
output = tmp_path / "captured-output"
|
|
||||||
output.write_bytes(image_release.CANARY)
|
|
||||||
with pytest.raises(ValueError) as error:
|
|
||||||
image_release.assert_clean_output([output])
|
|
||||||
assert image_release.CANARY.decode() not in str(error.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_canary_scan_checks_metadata_filesystem_archive_and_cleanup(tmp_path):
|
|
||||||
class ScanRunner(RecordingRunner):
|
|
||||||
def run(self, command, *, input_text=None, timeout=None):
|
|
||||||
result = super().run(command, input_text=input_text, timeout=timeout)
|
|
||||||
if list(command)[:2] == ["docker", "export"]:
|
|
||||||
output = Path(command[command.index("--output") + 1])
|
|
||||||
with tarfile.open(output, mode="w"):
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
saved = tmp_path / "image.tar"
|
|
||||||
with tarfile.open(saved, mode="w"):
|
|
||||||
pass
|
|
||||||
runner = ScanRunner()
|
|
||||||
|
|
||||||
image_release.scan_image("candidate", saved, runner)
|
|
||||||
|
|
||||||
commands = [call[0] for call in runner.commands]
|
|
||||||
assert ["docker", "history", "--no-trunc", "candidate"] in commands
|
|
||||||
assert ["docker", "image", "inspect", "candidate"] in commands
|
|
||||||
assert ["docker", "create", "--network", "none", "candidate"] in commands
|
|
||||||
assert any(command[:2] == ["docker", "export"] for command in commands)
|
|
||||||
assert ["docker", "rm", "--force", "container-id"] in commands
|
|
||||||
|
|
||||||
|
|
||||||
def test_canary_in_create_stderr_still_cleans_container(tmp_path):
|
|
||||||
class LeakingCreateRunner(RecordingRunner):
|
|
||||||
def run(self, command, *, input_text=None, timeout=None):
|
|
||||||
result = super().run(command, input_text=input_text, timeout=timeout)
|
|
||||||
if list(command)[:2] == ["docker", "create"]:
|
|
||||||
return image_release.Result("container-id\n", image_release.CANARY.decode())
|
|
||||||
return result
|
|
||||||
|
|
||||||
saved = tmp_path / "image.tar"
|
|
||||||
with tarfile.open(saved, mode="w"):
|
|
||||||
pass
|
|
||||||
runner = LeakingCreateRunner()
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="canary marker found"):
|
|
||||||
image_release.scan_image("candidate", saved, runner)
|
|
||||||
assert runner.commands[-1][0] == ["docker", "rm", "--force", "container-id"]
|
|
||||||
@@ -28,7 +28,7 @@ from trex_core.application.ports import IntentRecord
|
|||||||
from trex_core.application.strategy_state import (
|
from trex_core.application.strategy_state import (
|
||||||
SetResult,
|
SetResult,
|
||||||
StrategyStateEntry,
|
StrategyStateEntry,
|
||||||
_cas,
|
_STATE_CAP_BYTES,
|
||||||
)
|
)
|
||||||
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
|
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
|
||||||
|
|
||||||
@@ -701,13 +701,7 @@ class PostgreSQLLedgerOutbox:
|
|||||||
|
|
||||||
|
|
||||||
class PgStrategyStateStore:
|
class PgStrategyStateStore:
|
||||||
"""slot_strategy_state-backed StrategyStateStore (dcy-028 / REQ-079).
|
"""PostgreSQL slot-scoped strategy-instance map store."""
|
||||||
|
|
||||||
Stores opaque per-strategy state keyed by (slot_id, strategy_id). The CAS
|
|
||||||
decision is the same `_cas` used by the in-memory double; occupancy_seq and
|
|
||||||
slot_active are supplied by the caller (fetched from the slot) so the store
|
|
||||||
only owns the slot_strategy_state table.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
||||||
self._psycopg = _require_psycopg(psycopg_module)
|
self._psycopg = _require_psycopg(psycopg_module)
|
||||||
@@ -716,71 +710,65 @@ class PgStrategyStateStore:
|
|||||||
def set(
|
def set(
|
||||||
self,
|
self,
|
||||||
slot_id: UUID,
|
slot_id: UUID,
|
||||||
strategy_id: str,
|
strategy_instance_id: str,
|
||||||
*,
|
*,
|
||||||
expected_occupancy_seq: int,
|
|
||||||
expected_version: int,
|
expected_version: int,
|
||||||
data: str,
|
data: str,
|
||||||
current_occupancy_seq: int,
|
|
||||||
slot_active: bool,
|
slot_active: bool,
|
||||||
) -> SetResult:
|
) -> SetResult:
|
||||||
|
if len(data.encode("utf-8")) > _STATE_CAP_BYTES:
|
||||||
|
return SetResult(ok=False, reason="state_too_large")
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
# Lock the lifecycle row before changing state. A close UPDATE
|
||||||
|
# and this write therefore have one serial order: a writer that
|
||||||
|
# follows a close observes INACTIVE and cannot recreate state.
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT occupancy_seq, version, data, updated_at "
|
"SELECT status FROM daily_slot WHERE slot_id = %s::uuid FOR UPDATE",
|
||||||
"FROM slot_strategy_state "
|
(slot_id,),
|
||||||
"WHERE slot_id = %s AND strategy_id = %s FOR UPDATE",
|
|
||||||
(slot_id, strategy_id),
|
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
slot = cur.fetchone()
|
||||||
existing = (
|
if slot is None or slot[0] not in {"pending_buy", "holding", "pending_sell"}:
|
||||||
StrategyStateEntry(
|
return SetResult(ok=False, reason="slot_inactive")
|
||||||
strategy_id=strategy_id,
|
cur.execute(
|
||||||
occupancy_seq=int(row[0]),
|
"SELECT data FROM slot_strategy_state WHERE slot_id = %s::uuid",
|
||||||
version=int(row[1]),
|
(slot_id,),
|
||||||
data=row[2],
|
)
|
||||||
updated_at=row[3],
|
state_row = cur.fetchone()
|
||||||
|
state_map = state_row[0] if state_row is not None else {}
|
||||||
|
entry = state_map.get(strategy_instance_id)
|
||||||
|
version = int(entry["version"]) if entry is not None else 0
|
||||||
|
if expected_version != version:
|
||||||
|
return SetResult(
|
||||||
|
ok=False,
|
||||||
|
reason="version_conflict",
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
entry_json = "jsonb_build_object('version', %s::bigint, " \
|
||||||
|
"'data', %s::text, 'updated_at', %s::timestamptz)"
|
||||||
|
if state_row is None:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO slot_strategy_state (slot_id, data) VALUES ("
|
||||||
|
"%s::uuid, jsonb_build_object(%s::text, " + entry_json + "))",
|
||||||
|
(slot_id, strategy_instance_id, version + 1, data, now),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE slot_strategy_state SET data = jsonb_set(data, "
|
||||||
|
"ARRAY[%s::text]::text[], " + entry_json + ", true) "
|
||||||
|
"WHERE slot_id = %s::uuid",
|
||||||
|
(strategy_instance_id, version + 1, data, now, slot_id),
|
||||||
)
|
)
|
||||||
if row is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
result = _cas(
|
|
||||||
existing,
|
|
||||||
expected_occupancy_seq=expected_occupancy_seq,
|
|
||||||
expected_version=expected_version,
|
|
||||||
data=data,
|
|
||||||
current_occupancy_seq=current_occupancy_seq,
|
|
||||||
slot_active=slot_active,
|
|
||||||
)
|
|
||||||
if not result.ok:
|
|
||||||
conn.rollback()
|
|
||||||
return result
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO slot_strategy_state "
|
|
||||||
"(slot_id, strategy_id, occupancy_seq, version, data, updated_at) "
|
|
||||||
"VALUES (%s, %s, %s, %s, %s, %s) "
|
|
||||||
"ON CONFLICT (slot_id, strategy_id) DO UPDATE SET "
|
|
||||||
"occupancy_seq = EXCLUDED.occupancy_seq, "
|
|
||||||
"version = EXCLUDED.version, data = EXCLUDED.data, "
|
|
||||||
"updated_at = EXCLUDED.updated_at",
|
|
||||||
(
|
|
||||||
slot_id,
|
|
||||||
strategy_id,
|
|
||||||
current_occupancy_seq,
|
|
||||||
result.version,
|
|
||||||
data,
|
|
||||||
datetime.now(tz=timezone.utc),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return result
|
return SetResult(ok=True, version=version + 1)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get_for_slots(
|
def get_for_slots(
|
||||||
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
|
self, slot_ids: list[UUID]
|
||||||
) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
||||||
if not slot_ids:
|
if not slot_ids:
|
||||||
return {}
|
return {}
|
||||||
@@ -788,26 +776,16 @@ class PgStrategyStateStore:
|
|||||||
with self._psycopg.connect(self._dsn) as conn:
|
with self._psycopg.connect(self._dsn) as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT slot_id, strategy_id, occupancy_seq, version, data, updated_at "
|
"SELECT slot_id, data "
|
||||||
"FROM slot_strategy_state WHERE slot_id = ANY(%s)",
|
"FROM slot_strategy_state WHERE slot_id = ANY(%s)",
|
||||||
(list(slot_ids),),
|
(list(slot_ids),),
|
||||||
)
|
)
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
sid = row[0] if isinstance(row[0], UUID) else UUID(str(row[0]))
|
sid = row[0] if isinstance(row[0], UUID) else UUID(str(row[0]))
|
||||||
# stale-occupancy entries are invisible (dcy-028 FR-3 defense)
|
for instance_id, entry in row[1].items():
|
||||||
if int(row[2]) != current_occupancy_seq.get(sid):
|
out.setdefault(sid, {})[instance_id] = StrategyStateEntry(
|
||||||
continue
|
version=int(entry["version"]),
|
||||||
out.setdefault(sid, {})[row[1]] = StrategyStateEntry(
|
data=entry["data"],
|
||||||
strategy_id=row[1],
|
updated_at=datetime.fromisoformat(entry["updated_at"]),
|
||||||
occupancy_seq=int(row[2]),
|
)
|
||||||
version=int(row[3]),
|
|
||||||
data=row[4],
|
|
||||||
updated_at=row[5],
|
|
||||||
)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def delete_all(self, slot_id: UUID) -> None:
|
|
||||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,))
|
|
||||||
conn.commit()
|
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
-- REQ-0009: state is one opaque map per slot, keyed by strategy_instance_id.
|
||||||
|
-- The old strategy_id/occupancy-scoped rows cannot be assigned an instance ID;
|
||||||
|
-- no mapping or backfill is permitted by this contract.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'slot_strategy_state'
|
||||||
|
AND column_name = 'strategy_id'
|
||||||
|
) THEN
|
||||||
|
DROP TABLE slot_strategy_state;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
CREATE TABLE IF NOT EXISTS slot_strategy_state (
|
||||||
|
slot_id UUID PRIMARY KEY,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
@@ -207,7 +207,7 @@ def make_slots_rpc_handler(
|
|||||||
):
|
):
|
||||||
"""Build the RPC handler for `trex.slots.req` (REQ-073/074/116).
|
"""Build the RPC handler for `trex.slots.req` (REQ-073/074/116).
|
||||||
|
|
||||||
Request: {filter?: str, spy?: bool, strategy?: str}
|
Request: {filter?: str, spy?: bool, strategy?: str, strategy_instance_id?: str}
|
||||||
filter: "all"|"inactive"|"pending"|"holding"|"error" (default "all")
|
filter: "all"|"inactive"|"pending"|"holding"|"error" (default "all")
|
||||||
spy=true: watchdog not updated (operator view, REQ-034 CHG-01)
|
spy=true: watchdog not updated (operator view, REQ-034 CHG-01)
|
||||||
strategy: exact-match on strategy_name (REQ-116, optional). When specified
|
strategy: exact-match on strategy_name (REQ-116, optional). When specified
|
||||||
@@ -223,7 +223,25 @@ def make_slots_rpc_handler(
|
|||||||
def handler(req: RpcRequest) -> RpcResponse:
|
def handler(req: RpcRequest) -> RpcResponse:
|
||||||
body = req.payload or {}
|
body = req.payload or {}
|
||||||
filter_val = str(body.get("filter") or "all").lower()
|
filter_val = str(body.get("filter") or "all").lower()
|
||||||
spy = bool(body.get("spy", False))
|
spy_value = body.get("spy", False)
|
||||||
|
if not isinstance(spy_value, bool):
|
||||||
|
return RpcResponse(payload={
|
||||||
|
"slots": [],
|
||||||
|
"available_slots": None,
|
||||||
|
"degraded": False,
|
||||||
|
"reason": "bad_request:spy",
|
||||||
|
"message": "spy must be a boolean",
|
||||||
|
})
|
||||||
|
spy = spy_value
|
||||||
|
strategy_instance_id = body.get("strategy_instance_id")
|
||||||
|
if not spy and (not isinstance(strategy_instance_id, str) or not strategy_instance_id):
|
||||||
|
return RpcResponse(payload={
|
||||||
|
"slots": [],
|
||||||
|
"available_slots": None,
|
||||||
|
"degraded": False,
|
||||||
|
"reason": "bad_request:strategy_instance_id",
|
||||||
|
"message": "strategy_instance_id must be a non-empty string",
|
||||||
|
})
|
||||||
# REQ-116 (BAT-055): optional strategy filter — exact-match on strategy_name.
|
# REQ-116 (BAT-055): optional strategy filter — exact-match on strategy_name.
|
||||||
# None means no filter (all slots returned regardless of strategy_name).
|
# None means no filter (all slots returned regardless of strategy_name).
|
||||||
strategy_filter = _opt_str(body.get("strategy"))
|
strategy_filter = _opt_str(body.get("strategy"))
|
||||||
@@ -259,10 +277,42 @@ def make_slots_rpc_handler(
|
|||||||
for s in slots
|
for s in slots
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# AC-0023 exclusion must precede all optional data readers so excluded
|
||||||
|
# slots cannot expose state or make an otherwise valid response fail.
|
||||||
|
excluded_count = 0
|
||||||
|
if warn_store is not None:
|
||||||
|
try:
|
||||||
|
c_warned_ids = warn_store.list_c_case_warned()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception("warn_store.list_c_case_warned() failed — fail-closed")
|
||||||
|
return RpcResponse(payload={
|
||||||
|
"slots": [],
|
||||||
|
"available_slots": None,
|
||||||
|
"degraded": True,
|
||||||
|
"reason": "warn_store_unavailable",
|
||||||
|
"message": (
|
||||||
|
"reconcile warning store unavailable; "
|
||||||
|
"returning no slots (fail-closed)"
|
||||||
|
),
|
||||||
|
"excluded_slots": {"reconcile_quantity_mismatch": 0},
|
||||||
|
})
|
||||||
|
if c_warned_ids:
|
||||||
|
included = [
|
||||||
|
(slot, row)
|
||||||
|
for slot, row in zip(slots, slot_rows, strict=True)
|
||||||
|
if not (
|
||||||
|
slot.status.value == "holding"
|
||||||
|
and str(slot.slot_id) in c_warned_ids
|
||||||
|
)
|
||||||
|
]
|
||||||
|
excluded_count = len(slots) - len(included)
|
||||||
|
slots = [slot for slot, _ in included]
|
||||||
|
slot_rows = [row for _, row in included]
|
||||||
|
|
||||||
broker_qty_by_symbol: dict[str, int] = {}
|
broker_qty_by_symbol: dict[str, int] = {}
|
||||||
broker_qty_available = False
|
broker_qty_available = False
|
||||||
broker_qty_captured_at: Optional[datetime] = None
|
broker_qty_captured_at: Optional[datetime] = None
|
||||||
if broker_quantity_reader is not None:
|
if broker_quantity_reader is not None and slots:
|
||||||
try:
|
try:
|
||||||
holdings, broker_qty_captured_at = broker_quantity_reader.holdings_for_slots_rpc()
|
holdings, broker_qty_captured_at = broker_quantity_reader.holdings_for_slots_rpc()
|
||||||
broker_qty_by_symbol = {
|
broker_qty_by_symbol = {
|
||||||
@@ -290,24 +340,29 @@ def make_slots_rpc_handler(
|
|||||||
row["qty"] = None
|
row["qty"] = None
|
||||||
row["qty_source"] = "unavailable"
|
row["qty_source"] = "unavailable"
|
||||||
|
|
||||||
# dcy-028 / REQ-073/074·079 (C-1): attach opaque strategy_state map per
|
# Strategy requests receive only their exact entry; operator requests
|
||||||
# row via one batched store read (read piggyback — no extra round trip).
|
# receive the complete slot map without changing watchdog behavior.
|
||||||
# Only entries matching the slot's current occupancy_seq are surfaced.
|
|
||||||
if strategy_state_store is not None and slots:
|
if strategy_state_store is not None and slots:
|
||||||
slot_ids = [s.slot_id for s in slots]
|
slot_ids = [s.slot_id for s in slots]
|
||||||
occ = {s.slot_id: getattr(s, "occupancy_seq", 0) for s in slots}
|
state_map = strategy_state_store.get_for_slots(slot_ids)
|
||||||
state_map = strategy_state_store.get_for_slots(slot_ids, occ)
|
|
||||||
for row, s in zip(slot_rows, slots, strict=True):
|
for row, s in zip(slot_rows, slots, strict=True):
|
||||||
entries = state_map.get(s.slot_id)
|
entries = state_map.get(s.slot_id)
|
||||||
if entries:
|
if entries:
|
||||||
row["strategy_state"] = {
|
if spy:
|
||||||
strat: {
|
row["strategy_state"] = {
|
||||||
"version": e.version,
|
instance_id: {
|
||||||
"data": e.data,
|
"version": e.version,
|
||||||
"updated_at": e.updated_at.isoformat(),
|
"data": e.data,
|
||||||
|
"updated_at": e.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
for instance_id, e in entries.items()
|
||||||
|
}
|
||||||
|
elif (entry := entries.get(strategy_instance_id)) is not None:
|
||||||
|
row["strategy_state"] = {
|
||||||
|
"version": entry.version,
|
||||||
|
"data": entry.data,
|
||||||
|
"updated_at": entry.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
for strat, e in entries.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
# dcy-018 / REQ-029: cash-estimated overlay (best-effort)
|
# dcy-018 / REQ-029: cash-estimated overlay (best-effort)
|
||||||
if ledger_outbox is not None:
|
if ledger_outbox is not None:
|
||||||
@@ -347,43 +402,6 @@ def make_slots_rpc_handler(
|
|||||||
if strategy_filter is not None:
|
if strategy_filter is not None:
|
||||||
slot_rows = [r for r in slot_rows if r.get("strategy_name") == strategy_filter]
|
slot_rows = [r for r in slot_rows if r.get("strategy_name") == strategy_filter]
|
||||||
|
|
||||||
# AC-0023: exclude holding slots with active case_code='C' reconcile warning.
|
|
||||||
# Fail-closed: if warn_store exists but throws, return degraded=True / no slots.
|
|
||||||
excluded_count = 0
|
|
||||||
if warn_store is not None:
|
|
||||||
try:
|
|
||||||
c_warned_ids = warn_store.list_c_case_warned()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
_log.exception("warn_store.list_c_case_warned() failed — fail-closed")
|
|
||||||
return RpcResponse(payload={
|
|
||||||
"slots": [],
|
|
||||||
"available_slots": available_slots,
|
|
||||||
"degraded": True,
|
|
||||||
"reason": "warn_store_unavailable",
|
|
||||||
"message": (
|
|
||||||
"reconcile warning store unavailable; "
|
|
||||||
"returning no slots (fail-closed)"
|
|
||||||
),
|
|
||||||
"excluded_slots": {"reconcile_quantity_mismatch": 0},
|
|
||||||
})
|
|
||||||
if c_warned_ids:
|
|
||||||
# Rebuild slot lookup from the original `slots` list (before strategy
|
|
||||||
# filter reduced slot_rows). slot_rows[i] corresponds to slots[i]
|
|
||||||
# only before filtering; use slot_id stored in each row.
|
|
||||||
slot_by_id = {str(s.slot_id): s for s in slots}
|
|
||||||
filtered_rows: list[dict] = []
|
|
||||||
for row in slot_rows:
|
|
||||||
slot_obj = slot_by_id.get(str(row.get("slot_id", "")))
|
|
||||||
if (
|
|
||||||
slot_obj is not None
|
|
||||||
and str(slot_obj.slot_id) in c_warned_ids
|
|
||||||
and slot_obj.status.value == "holding"
|
|
||||||
):
|
|
||||||
excluded_count += 1
|
|
||||||
else:
|
|
||||||
filtered_rows.append(row)
|
|
||||||
slot_rows = filtered_rows
|
|
||||||
|
|
||||||
base_payload: dict = {
|
base_payload: dict = {
|
||||||
"slots": slot_rows,
|
"slots": slot_rows,
|
||||||
"available_slots": available_slots,
|
"available_slots": available_slots,
|
||||||
@@ -406,15 +424,12 @@ def make_strategy_state_set_handler(
|
|||||||
):
|
):
|
||||||
"""Build the RPC handler for `trex.slots.strategy_state.set` (REQ-080).
|
"""Build the RPC handler for `trex.slots.strategy_state.set` (REQ-080).
|
||||||
|
|
||||||
Request: {slot_id, strategy_id, expected_version:int,
|
Request: {slot_id, strategy_instance_id, expected_version:int, data:str}
|
||||||
expected_occupancy_seq:int, data:str}
|
Response (ok): {ok:true, slot_id, strategy_instance_id, version:int}
|
||||||
Response (ok): {ok:true, slot_id, strategy_id, version:int}
|
Response (fail): {ok:false, slot_id, strategy_instance_id, reason, version?}
|
||||||
Response (fail): {ok:false, slot_id, strategy_id, reason, version?|occupancy_seq?}
|
|
||||||
|
|
||||||
reason ∈ {occupancy_changed, version_conflict, slot_inactive,
|
The store owns entry-version CAS; the handler supplies active-ness and never
|
||||||
state_too_large, bad_request:<detail>}. The store owns the CAS
|
interprets opaque data.
|
||||||
(REQ-079); the handler supplies the slot's current occupancy_seq and
|
|
||||||
active-ness and never interprets `data` (opaque).
|
|
||||||
"""
|
"""
|
||||||
_ACTIVE_STATES = {"pending_buy", "holding", "pending_sell"}
|
_ACTIVE_STATES = {"pending_buy", "holding", "pending_sell"}
|
||||||
|
|
||||||
@@ -422,9 +437,10 @@ def make_strategy_state_set_handler(
|
|||||||
body = req.payload or {}
|
body = req.payload or {}
|
||||||
try:
|
try:
|
||||||
slot_id = UUID(str(body["slot_id"]))
|
slot_id = UUID(str(body["slot_id"]))
|
||||||
strategy_id = str(body["strategy_id"])
|
strategy_instance_id = body["strategy_instance_id"]
|
||||||
|
if not isinstance(strategy_instance_id, str) or not strategy_instance_id:
|
||||||
|
raise ValueError("strategy_instance_id must be a non-empty string")
|
||||||
expected_version = int(body["expected_version"])
|
expected_version = int(body["expected_version"])
|
||||||
expected_occupancy_seq = int(body["expected_occupancy_seq"])
|
|
||||||
data = body["data"]
|
data = body["data"]
|
||||||
if not isinstance(data, str):
|
if not isinstance(data, str):
|
||||||
raise ValueError("data must be a string")
|
raise ValueError("data must be a string")
|
||||||
@@ -432,27 +448,23 @@ def make_strategy_state_set_handler(
|
|||||||
return RpcResponse(payload={
|
return RpcResponse(payload={
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"slot_id": body.get("slot_id"),
|
"slot_id": body.get("slot_id"),
|
||||||
"strategy_id": body.get("strategy_id"),
|
"strategy_instance_id": body.get("strategy_instance_id"),
|
||||||
"reason": f"bad_request:{exc}",
|
"reason": f"bad_request:{exc}",
|
||||||
})
|
})
|
||||||
|
|
||||||
slot = slot_repo.fetch_slot(slot_id)
|
slot = slot_repo.fetch_slot(slot_id)
|
||||||
slot_active = slot is not None and slot.status.value in _ACTIVE_STATES
|
slot_active = slot is not None and slot.status.value in _ACTIVE_STATES
|
||||||
current_occupancy_seq = getattr(slot, "occupancy_seq", 0) if slot else 0
|
|
||||||
|
|
||||||
result = strategy_state_store.set(
|
result = strategy_state_store.set(
|
||||||
slot_id,
|
slot_id,
|
||||||
strategy_id,
|
strategy_instance_id,
|
||||||
expected_occupancy_seq=expected_occupancy_seq,
|
|
||||||
expected_version=expected_version,
|
expected_version=expected_version,
|
||||||
data=data,
|
data=data,
|
||||||
current_occupancy_seq=current_occupancy_seq,
|
|
||||||
slot_active=slot_active,
|
slot_active=slot_active,
|
||||||
)
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"ok": result.ok,
|
"ok": result.ok,
|
||||||
"slot_id": str(slot_id),
|
"slot_id": str(slot_id),
|
||||||
"strategy_id": strategy_id,
|
"strategy_instance_id": strategy_instance_id,
|
||||||
}
|
}
|
||||||
if result.ok:
|
if result.ok:
|
||||||
payload["version"] = result.version
|
payload["version"] = result.version
|
||||||
@@ -460,8 +472,6 @@ def make_strategy_state_set_handler(
|
|||||||
payload["reason"] = result.reason
|
payload["reason"] = result.reason
|
||||||
if result.version is not None:
|
if result.version is not None:
|
||||||
payload["version"] = result.version
|
payload["version"] = result.version
|
||||||
if result.occupancy_seq is not None:
|
|
||||||
payload["occupancy_seq"] = result.occupancy_seq
|
|
||||||
return RpcResponse(payload=payload)
|
return RpcResponse(payload=payload)
|
||||||
|
|
||||||
return handler
|
return handler
|
||||||
|
|||||||
@@ -564,7 +564,7 @@ def slot_to_dict(
|
|||||||
"forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None,
|
"forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None,
|
||||||
"universe_detached": getattr(s, "retired_at", None) is not None,
|
"universe_detached": getattr(s, "retired_at", None) is not None,
|
||||||
# dcy-028/REQ-081 (BAT-042): per-occupancy guard counter — the strategy
|
# dcy-028/REQ-081 (BAT-042): per-occupancy guard counter — the strategy
|
||||||
# daemon echoes this as expected_occupancy_seq on strategy_state writes.
|
# Slot occupancy remains domain state; strategy-state writes do not use it.
|
||||||
"occupancy_seq": getattr(s, "occupancy_seq", 0),
|
"occupancy_seq": getattr(s, "occupancy_seq", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,4 +638,3 @@ def _sanitize(message: str) -> str:
|
|||||||
break
|
break
|
||||||
return out[:200]
|
return out[:200]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,4 @@
|
|||||||
"""Slot strategy-state store (dcy-028 / REQ-079, BAT-042).
|
"""Slot-scoped opaque strategy-instance state."""
|
||||||
|
|
||||||
A strategy daemon (e.g. trex-tsl) accumulates opaque state across a slot's
|
|
||||||
occupancy — for a Trailing Stop Loss the peak price since entry. `evaluate()`
|
|
||||||
must stay pure, so the state cannot live in the daemon (lost on restart) nor in
|
|
||||||
an external store (orphan-cleanup problem). Since the state's lifecycle matches
|
|
||||||
the slot occupancy exactly, trex-core stores it server-side keyed by
|
|
||||||
`(slot_id, strategy_id)`, round-trips it on the slots snapshot (read), accepts
|
|
||||||
version-CAS writes via a dedicated RPC, and deletes it when the slot closes.
|
|
||||||
|
|
||||||
trex-core never interprets `data` (opaque UTF-8 string). It validates only
|
|
||||||
(1) valid UTF-8 and (2) a byte size cap — encoding (JSON/base64) is the
|
|
||||||
strategy's business (dcy-028 OQ-1).
|
|
||||||
|
|
||||||
Occupancy guard (dcy-028 R-1 / REQ-081): a permanent slot is reused, so two
|
|
||||||
occupancies of the same slot both start at version 0. A write computed against
|
|
||||||
occupancy X must not land on a later occupancy Y. The slot carries a monotonic
|
|
||||||
`occupancy_seq`; the CAS rejects a write whose `expected_occupancy_seq` differs
|
|
||||||
from the slot's current value (`occupancy_changed`). The version CAS alone
|
|
||||||
cannot catch this at the first write (0 vs 0).
|
|
||||||
|
|
||||||
Single-writer assumption: one daemon operates a given slot·strategy. The CAS is
|
|
||||||
for retry / duplicate / occupancy-turnover safety, not multi-writer concurrency.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -31,13 +8,8 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional, Protocol
|
from typing import Optional, Protocol
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
# dcy-028 OQ-1: hard ceiling for the opaque `data` blob, measured in UTF-8
|
|
||||||
# bytes. Operators target 1~2KB for the known TSL use case; 4KB is the cap.
|
|
||||||
_STATE_CAP_BYTES = int(os.environ.get("TREX_STRATEGY_STATE_CAP_BYTES", "4096"))
|
_STATE_CAP_BYTES = int(os.environ.get("TREX_STRATEGY_STATE_CAP_BYTES", "4096"))
|
||||||
|
|
||||||
# Reject reason tokens (dcy-028 / REQ-080). The full enumerated set; consumers
|
|
||||||
# fail-closed on anything unrecognized.
|
|
||||||
REASON_OCCUPANCY_CHANGED = "occupancy_changed"
|
|
||||||
REASON_VERSION_CONFLICT = "version_conflict"
|
REASON_VERSION_CONFLICT = "version_conflict"
|
||||||
REASON_SLOT_INACTIVE = "slot_inactive"
|
REASON_SLOT_INACTIVE = "slot_inactive"
|
||||||
REASON_STATE_TOO_LARGE = "state_too_large"
|
REASON_STATE_TOO_LARGE = "state_too_large"
|
||||||
@@ -45,10 +17,8 @@ REASON_STATE_TOO_LARGE = "state_too_large"
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class StrategyStateEntry:
|
class StrategyStateEntry:
|
||||||
"""One stored opaque state value for a (slot_id, strategy_id)."""
|
"""One opaque entry in a slot's strategy_instance_id map."""
|
||||||
|
|
||||||
strategy_id: str
|
|
||||||
occupancy_seq: int
|
|
||||||
version: int
|
version: int
|
||||||
data: str
|
data: str
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -56,64 +26,20 @@ class StrategyStateEntry:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SetResult:
|
class SetResult:
|
||||||
"""Outcome of a CAS set. ok=True carries the new version; else `reason`."""
|
|
||||||
|
|
||||||
ok: bool
|
ok: bool
|
||||||
version: Optional[int] = None
|
version: Optional[int] = None
|
||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
occupancy_seq: Optional[int] = None # current slot occupancy_seq on conflict
|
|
||||||
|
|
||||||
|
|
||||||
def _byte_len(data: str) -> int:
|
def _byte_len(data: str) -> int:
|
||||||
return len(data.encode("utf-8"))
|
return len(data.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
class StrategyStateStore(Protocol):
|
|
||||||
def set(
|
|
||||||
self,
|
|
||||||
slot_id: UUID,
|
|
||||||
strategy_id: str,
|
|
||||||
*,
|
|
||||||
expected_occupancy_seq: int,
|
|
||||||
expected_version: int,
|
|
||||||
data: str,
|
|
||||||
current_occupancy_seq: int,
|
|
||||||
slot_active: bool,
|
|
||||||
) -> SetResult:
|
|
||||||
"""occupancy_seq + version CAS upsert of opaque `data` (dcy-028 FR-2)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_for_slots(
|
|
||||||
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
|
|
||||||
) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
|
||||||
"""Batched read for the snapshot (C-1). Returns slot_id -> {strategy_id: entry}.
|
|
||||||
|
|
||||||
Only entries whose stored occupancy_seq matches the slot's current
|
|
||||||
occupancy_seq are returned (stale entries from a prior occupancy are
|
|
||||||
invisible — defense in depth alongside close-delete, dcy-028 FR-3)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def delete_all(self, slot_id: UUID) -> None:
|
|
||||||
"""Delete all strategy_state for a slot (close hook, dcy-028 C-3)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def _cas(
|
def _cas(
|
||||||
existing: Optional[StrategyStateEntry],
|
existing: Optional[StrategyStateEntry], *, expected_version: int, data: str, slot_active: bool
|
||||||
*,
|
|
||||||
expected_occupancy_seq: int,
|
|
||||||
expected_version: int,
|
|
||||||
data: str,
|
|
||||||
current_occupancy_seq: int,
|
|
||||||
slot_active: bool,
|
|
||||||
) -> SetResult:
|
) -> SetResult:
|
||||||
"""Pure CAS decision shared by all store implementations (dcy-028 FR-2)."""
|
|
||||||
if not slot_active:
|
if not slot_active:
|
||||||
return SetResult(ok=False, reason=REASON_SLOT_INACTIVE)
|
return SetResult(ok=False, reason=REASON_SLOT_INACTIVE)
|
||||||
if expected_occupancy_seq != current_occupancy_seq:
|
|
||||||
return SetResult(
|
|
||||||
ok=False, reason=REASON_OCCUPANCY_CHANGED, occupancy_seq=current_occupancy_seq
|
|
||||||
)
|
|
||||||
if _byte_len(data) > _STATE_CAP_BYTES:
|
if _byte_len(data) > _STATE_CAP_BYTES:
|
||||||
return SetResult(ok=False, reason=REASON_STATE_TOO_LARGE)
|
return SetResult(ok=False, reason=REASON_STATE_TOO_LARGE)
|
||||||
stored_version = existing.version if existing is not None else 0
|
stored_version = existing.version if existing is not None else 0
|
||||||
@@ -122,62 +48,54 @@ def _cas(
|
|||||||
return SetResult(ok=True, version=stored_version + 1)
|
return SetResult(ok=True, version=stored_version + 1)
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyStateStore(Protocol):
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
slot_id: UUID,
|
||||||
|
strategy_instance_id: str,
|
||||||
|
*,
|
||||||
|
expected_version: int,
|
||||||
|
data: str,
|
||||||
|
slot_active: bool,
|
||||||
|
) -> SetResult: ...
|
||||||
|
|
||||||
|
def get_for_slots(self, slot_ids: list[UUID]) -> dict[UUID, dict[str, StrategyStateEntry]]: ...
|
||||||
|
|
||||||
|
|
||||||
class InMemoryStrategyStateStore:
|
class InMemoryStrategyStateStore:
|
||||||
"""Process-local strategy-state store (paper / tests / explicit double)."""
|
"""Process-local slot-scoped state-map store."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# (slot_id, strategy_id) -> StrategyStateEntry
|
self._store: dict[UUID, dict[str, StrategyStateEntry]] = {}
|
||||||
self._store: dict[tuple[UUID, str], StrategyStateEntry] = {}
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def set(
|
def set(
|
||||||
self,
|
self,
|
||||||
slot_id: UUID,
|
slot_id: UUID,
|
||||||
strategy_id: str,
|
strategy_instance_id: str,
|
||||||
*,
|
*,
|
||||||
expected_occupancy_seq: int,
|
|
||||||
expected_version: int,
|
expected_version: int,
|
||||||
data: str,
|
data: str,
|
||||||
current_occupancy_seq: int,
|
|
||||||
slot_active: bool,
|
slot_active: bool,
|
||||||
) -> SetResult:
|
) -> SetResult:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
existing = self._store.get((slot_id, strategy_id))
|
entries = self._store.setdefault(slot_id, {})
|
||||||
|
existing = entries.get(strategy_instance_id)
|
||||||
result = _cas(
|
result = _cas(
|
||||||
existing,
|
existing, expected_version=expected_version, data=data, slot_active=slot_active
|
||||||
expected_occupancy_seq=expected_occupancy_seq,
|
|
||||||
expected_version=expected_version,
|
|
||||||
data=data,
|
|
||||||
current_occupancy_seq=current_occupancy_seq,
|
|
||||||
slot_active=slot_active,
|
|
||||||
)
|
)
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
|
if not entries:
|
||||||
|
self._store.pop(slot_id, None)
|
||||||
return result
|
return result
|
||||||
self._store[(slot_id, strategy_id)] = StrategyStateEntry(
|
entries[strategy_instance_id] = StrategyStateEntry(
|
||||||
strategy_id=strategy_id,
|
|
||||||
occupancy_seq=current_occupancy_seq,
|
|
||||||
version=result.version, # type: ignore[arg-type]
|
version=result.version, # type: ignore[arg-type]
|
||||||
data=data,
|
data=data,
|
||||||
updated_at=datetime.now(tz=timezone.utc),
|
updated_at=datetime.now(tz=timezone.utc),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_for_slots(
|
def get_for_slots(self, slot_ids: list[UUID]) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
||||||
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
|
|
||||||
) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
|
||||||
wanted = set(slot_ids)
|
wanted = set(slot_ids)
|
||||||
out: dict[UUID, dict[str, StrategyStateEntry]] = {}
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for (sid, strat), entry in self._store.items():
|
return {sid: dict(entries) for sid, entries in self._store.items() if sid in wanted}
|
||||||
if sid not in wanted:
|
|
||||||
continue
|
|
||||||
# stale-occupancy entries are invisible (dcy-028 FR-3 defense)
|
|
||||||
if entry.occupancy_seq != current_occupancy_seq.get(sid):
|
|
||||||
continue
|
|
||||||
out.setdefault(sid, {})[strat] = entry
|
|
||||||
return out
|
|
||||||
|
|
||||||
def delete_all(self, slot_id: UUID) -> None:
|
|
||||||
with self._lock:
|
|
||||||
for key in [k for k in self._store if k[0] == slot_id]:
|
|
||||||
del self._store[key]
|
|
||||||
|
|||||||
@@ -44,17 +44,12 @@ class SlotService:
|
|||||||
self,
|
self,
|
||||||
repository: SlotRepository,
|
repository: SlotRepository,
|
||||||
instance_key: str = "default",
|
instance_key: str = "default",
|
||||||
*,
|
|
||||||
strategy_state_store: Any = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._repo = repository
|
self._repo = repository
|
||||||
# dcy-020/REQ-057: advisory lock is scoped per instance so a shared DB
|
# dcy-020/REQ-057: advisory lock is scoped per instance so a shared DB
|
||||||
# does not serialize transitions across instances (NFR-1).
|
# does not serialize transitions across instances (NFR-1).
|
||||||
self._instance_key = instance_key
|
self._instance_key = instance_key
|
||||||
self._lock_id = slot_advisory_lock_id(instance_key)
|
self._lock_id = slot_advisory_lock_id(instance_key)
|
||||||
# dcy-028/REQ-081 (C-3): when injected, all strategy_state for a slot is
|
|
||||||
# deleted on close (→INACTIVE). None keeps legacy behaviour (no store).
|
|
||||||
self._strategy_state_store = strategy_state_store
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def instance_key(self) -> str:
|
def instance_key(self) -> str:
|
||||||
@@ -155,11 +150,6 @@ class SlotService:
|
|||||||
extra=extra or {},
|
extra=extra or {},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# dcy-028/REQ-081 (C-3): close deletes the slot's strategy_state so a
|
|
||||||
# reused slot starts empty. Covers every close path (strategy sell,
|
|
||||||
# safety-sell, forced-close, operator) since all funnel to →INACTIVE.
|
|
||||||
if to_state is SlotState.INACTIVE and self._strategy_state_store is not None:
|
|
||||||
self._strategy_state_store.delete_all(slot_id)
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -182,13 +182,12 @@ class Daemon:
|
|||||||
# Persistence + services
|
# Persistence + services
|
||||||
self.slot_repo = PostgreSQLSlotRepository(config.dsn)
|
self.slot_repo = PostgreSQLSlotRepository(config.dsn)
|
||||||
self.intent_store = PostgreSQLIntentStore(config.dsn)
|
self.intent_store = PostgreSQLIntentStore(config.dsn)
|
||||||
# dcy-028 / REQ-079·081 (BAT-042): opaque strategy-state store. Injected
|
# dcy-028 / REQ-079·081 (BAT-042): opaque strategy-state store persists
|
||||||
# into the slot service so close (→INACTIVE) deletes the slot's state.
|
# independently of slot occupancy and is shared by both RPC handlers.
|
||||||
self.strategy_state_store = PgStrategyStateStore(config.dsn)
|
self.strategy_state_store = PgStrategyStateStore(config.dsn)
|
||||||
self.slot_service = SlotService(
|
self.slot_service = SlotService(
|
||||||
self.slot_repo,
|
self.slot_repo,
|
||||||
instance_key=config.instance_key,
|
instance_key=config.instance_key,
|
||||||
strategy_state_store=self.strategy_state_store,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
|
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
|
||||||
@@ -458,7 +457,7 @@ class Daemon:
|
|||||||
topology.slots_queue(self.config.instance_key), handler, self.stop)
|
topology.slots_queue(self.config.instance_key), handler, self.stop)
|
||||||
|
|
||||||
def _strategy_state_set_loop(self) -> None:
|
def _strategy_state_set_loop(self) -> None:
|
||||||
# dcy-028 / REQ-080: opaque strategy-state write RPC (occupancy_seq+version CAS).
|
# dcy-028 / REQ-080: opaque strategy-state write RPC (entry-version CAS).
|
||||||
transport = _build_transport(self.config)
|
transport = _build_transport(self.config)
|
||||||
self._transports.append(transport)
|
self._transports.append(transport)
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user