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"
|
||||
status: active
|
||||
status: retired
|
||||
---
|
||||
|
||||
# strict version-tag 검증 성공 이미지에 한정된 GHCR 게시
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: "ac-0005"
|
||||
status: active
|
||||
status: retired
|
||||
---
|
||||
|
||||
# 검증 이미지와 게시 이미지의 동일성
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: "ac-0006"
|
||||
status: active
|
||||
status: retired
|
||||
---
|
||||
|
||||
# requester version tag·source tag와 immutable digest 추적성
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: "ac-0007"
|
||||
status: active
|
||||
status: retired
|
||||
---
|
||||
|
||||
# built-container package 및 entrypoint smoke
|
||||
|
||||
@@ -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,
|
||||
"next_line_number": 9,
|
||||
"next_ac_number": 26
|
||||
"next_line_number": 10,
|
||||
"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"
|
||||
"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"` 로 처리.
|
||||
- `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 불변).
|
||||
|
||||
코어가 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 을 가져야 한다.
|
||||
- 토폴로지 선언은 멱등하지만 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)
|
||||
- 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
|
||||
- `{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
|
||||
- 성공: `{ok:true, slot_id, strategy_id, version:<new>}`.
|
||||
- 실패 reason 전체 집합: `occupancy_changed`(+`occupancy_seq`) / `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:<detail>`. 소비자는 미인식 reason 에 fail-closed.
|
||||
- 성공: `{ok:true, slot_id, strategy_instance_id, version:<new>}`.
|
||||
- 실패 reason 전체 집합: `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:<detail>`. 소비자는 미인식 reason에 fail-closed.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -40,16 +40,6 @@ pkill -TERM -f trex-core-daemon
|
||||
- `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지.
|
||||
- 테스트는 `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
|
||||
|
||||
+10
-11
@@ -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).
|
||||
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-ledger` :: ledger outbox 드레인 (REQ-019)
|
||||
- `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-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-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)
|
||||
@@ -138,7 +139,7 @@
|
||||
|
||||
`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`).
|
||||
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 산출:
|
||||
@@ -149,7 +150,7 @@
|
||||
- 추정 현금(REQ-029 OQ-4): `cash_estimated=True` + `cash_balance += pending ledger delta`(best-effort)
|
||||
- 워치독: `watchdog: {expired, last_seen}` inline (WatchdogStore)
|
||||
- 오버레이: `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}`.
|
||||
6. 응답: `{slots[], degraded, reason, message}` — `slots` 는 항상 배열, null 금지.
|
||||
|
||||
@@ -346,17 +347,15 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
|
||||
6. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다.
|
||||
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 엔트리는 노출하지 않는다.
|
||||
2. **evaluate (pure)**: 데몬은 행의 `strategy_state[strategy_id]` 를 read-only 입력으로 `evaluate()` 에 넣는다(상태 write 는 evaluate 밖, 엔진이 수행).
|
||||
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`. 동일 입력 재전송은 무부작용.
|
||||
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).
|
||||
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. **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. **lifecycle/concurrency**: `→INACTIVE`와 재점유는 map을 삭제하거나 숨기지 않으며 version도 초기화하지 않는다. Slot 자체의 `occupancy_seq`는 기존대로 유지하지만 strategy state persistence/read/write에는 사용하지 않는다. 서로 다른 instance entry의 concurrent 성공 write는 모두 보존된다.
|
||||
|
||||
저장은 별도 `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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
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)"
|
||||
requires-python = ">=3.12"
|
||||
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
|
||||
"ledger_skip", # GitHub #68 skipped-ledger durable audit
|
||||
"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)
|
||||
|
||||
|
||||
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):
|
||||
"""Negative case: a contract field absent from the table is caught."""
|
||||
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"
|
||||
|
||||
|
||||
# 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):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from trex_core.adapters.persistence.postgresql import PgStrategyStateStore
|
||||
from trex_core.application import strategy_state as ss
|
||||
|
||||
repo = PostgreSQLSlotRepository(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"})
|
||||
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
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="filled", actor="t", qty=10, avg_price=100)
|
||||
# occupancy_seq persisted
|
||||
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)
|
||||
r1 = store.set(slot.slot_id, "instance-a", expected_version=0, data="peak", slot_active=True)
|
||||
assert r1.ok and r1.version == 1
|
||||
# version conflict
|
||||
r2 = store.set(slot.slot_id, "tsl_v1", expected_version=0,
|
||||
expected_occupancy_seq=1, data="stale", current_occupancy_seq=1,
|
||||
slot_active=True)
|
||||
r2 = store.set(slot.slot_id, "instance-a", expected_version=0, data="stale", slot_active=True)
|
||||
assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT
|
||||
# batched read
|
||||
got = store.get_for_slots([slot.slot_id], {slot.slot_id: 1})
|
||||
assert got[slot.slot_id]["tsl_v1"].data == "peak"
|
||||
missing = store.set(slot.slot_id, "instance-missing", expected_version=1, data="stale", slot_active=True)
|
||||
assert not missing.ok and missing.reason == ss.REASON_VERSION_CONFLICT and missing.version == 0
|
||||
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,
|
||||
reason="sell", actor="t")
|
||||
svc.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}) == {}
|
||||
# reuse bumps occupancy_seq
|
||||
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 2
|
||||
reused = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
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.reconcile_universe("default", {"PF-1": "SYM"})
|
||||
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"]
|
||||
pf1 = next(s for s in slots if s["portfolio_id"] == "PF-1")
|
||||
assert pf1["cash_estimated"] is True
|
||||
|
||||
@@ -693,7 +693,7 @@ def _wire_with_warn(warned_slot_ids=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
|
||||
@@ -756,7 +756,7 @@ def test_slots_rpc_existing_warned_field_unchanged():
|
||||
reason="filled", actor="test", avg_price=100.0, qty=10.0)
|
||||
warn_store = InMemoryReconcileWarningStore()
|
||||
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]
|
||||
# slot.qty=10 != hub asset_quantity=5 => warned=True (existing field)
|
||||
assert row["warned"] is True
|
||||
|
||||
@@ -43,6 +43,8 @@ def _req(filter=None, spy=False):
|
||||
payload["filter"] = filter
|
||||
if spy:
|
||||
payload["spy"] = True
|
||||
else:
|
||||
payload["strategy_instance_id"] = "test-instance"
|
||||
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):
|
||||
payload = {}
|
||||
payload["spy"] = True
|
||||
if filter:
|
||||
payload["filter"] = filter
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -67,6 +67,8 @@ def _req(filter=None, spy=False, strategy=None):
|
||||
payload["filter"] = filter
|
||||
if spy:
|
||||
payload["spy"] = True
|
||||
else:
|
||||
payload["strategy_instance_id"] = "test-instance"
|
||||
if strategy is not None:
|
||||
payload["strategy"] = strategy
|
||||
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).
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Strategy and operator strategy-state visibility contracts for slots.req."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_slots_rpc_handler
|
||||
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.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
INST = "default"
|
||||
|
||||
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
store = InMemoryStrategyStateStore()
|
||||
service = SlotService(repo, instance_key=INST, strategy_state_store=store)
|
||||
repo.reconcile_universe(INST, {"pf-0": "SYM"})
|
||||
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.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", qty=10, avg_price=100)
|
||||
handler = make_slots_rpc_handler(repo, instance_key=INST,
|
||||
strategy_state_store=store)
|
||||
return repo, store, service, slot, handler
|
||||
repo, store, watchdog = InMemorySlotRepository(), InMemoryStrategyStateStore(), WatchdogStore()
|
||||
service = SlotService(repo)
|
||||
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")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1, avg_price=1)
|
||||
store.set(slot.slot_id, "instance-a", expected_version=0, data="a", slot_active=True)
|
||||
store.set(slot.slot_id, "instance-b", expected_version=0, data="b", slot_active=True)
|
||||
return slot, store, watchdog, make_slots_rpc_handler(repo, strategy_state_store=store, watchdog_store=watchdog)
|
||||
|
||||
|
||||
def _rows(handler):
|
||||
return handler(RpcRequest(payload={}, correlation_id="c")).payload["slots"]
|
||||
def _call(handler, payload):
|
||||
return handler(RpcRequest(payload=payload, correlation_id="c")).payload
|
||||
|
||||
|
||||
def test_row_carries_occupancy_seq():
|
||||
_, _, _, slot, handler = _wire()
|
||||
row = _rows(handler)[0]
|
||||
assert row["occupancy_seq"] == 1
|
||||
def test_strategy_receives_only_exact_requested_entry():
|
||||
_, _, _, handler = _wire()
|
||||
payload = _call(handler, {"strategy_instance_id": "instance-a"})
|
||||
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():
|
||||
_, store, _, slot, handler = _wire()
|
||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
||||
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]
|
||||
def test_strategy_omits_absent_entry():
|
||||
_, _, _, handler = _wire()
|
||||
row = _call(handler, {"strategy_instance_id": "absent"})["slots"][0]
|
||||
assert "strategy_state" not in row
|
||||
|
||||
|
||||
def test_no_strategy_reasoning_fields():
|
||||
"""REQ-074 AC-5 (REQ-116 additive 재도입 후에도 유지): slot-origin strategy fields absent.
|
||||
|
||||
strategy_params / strategy_name / markets are now present (REQ-116 PortfolioItem-origin);
|
||||
slot-origin strategy_id / sell_strategy_id / active_strategy_id remain absent.
|
||||
"""
|
||||
_, store, _, slot, handler = _wire()
|
||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
||||
data="x", current_occupancy_seq=1, slot_active=True)
|
||||
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 # []
|
||||
@pytest.mark.parametrize("identity", [None, "", False, 1, []])
|
||||
def test_malformed_strategy_request_has_exact_safe_envelope(identity):
|
||||
_, _, watchdog, handler = _wire()
|
||||
assert _call(handler, {"strategy_instance_id": identity}) == {
|
||||
"slots": [], "available_slots": None, "degraded": False,
|
||||
"reason": "bad_request:strategy_instance_id",
|
||||
"message": "strategy_instance_id must be a non-empty string",
|
||||
}
|
||||
assert watchdog.last_seen("unused") is None
|
||||
|
||||
|
||||
def test_reused_slot_shows_no_state():
|
||||
"""After a full close + reuse cycle, no prior-occupancy state surfaces
|
||||
(close-delete + read-side occupancy filter). occupancy_seq advances to 2."""
|
||||
repo, store, service, slot, handler = _wire()
|
||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
||||
data="old", current_occupancy_seq=1, slot_active=True)
|
||||
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")
|
||||
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t") # occupancy_seq -> 2
|
||||
row = _rows(handler)[0]
|
||||
assert row["occupancy_seq"] == 2
|
||||
assert "strategy_state" not in row
|
||||
def test_operator_without_or_with_id_receives_full_map_without_watchdog_update():
|
||||
slot, _, watchdog, handler = _wire()
|
||||
for payload in ({"spy": True}, {"spy": True, "strategy_instance_id": "instance-a"}):
|
||||
state = _call(handler, payload)["slots"][0]["strategy_state"]
|
||||
assert set(state) == {"instance-a", "instance-b"}
|
||||
assert watchdog.last_seen(str(slot.slot_id)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spy", ["false", "true", 1, [], {}])
|
||||
def test_non_boolean_spy_is_rejected_without_visibility_or_watchdog_update(spy):
|
||||
slot, _, watchdog, handler = _wire()
|
||||
payload = _call(handler, {"spy": spy})
|
||||
assert payload == {
|
||||
"slots": [], "available_slots": None, "degraded": False,
|
||||
"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).
|
||||
|
||||
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.
|
||||
"""
|
||||
"""strategy_state.set strategy-instance wire contract tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application import strategy_state as ss
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
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.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
INST = "default"
|
||||
|
||||
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
store = InMemoryStrategyStateStore()
|
||||
service = SlotService(repo, instance_key=INST, strategy_state_store=store)
|
||||
repo.reconcile_universe(INST, {"pf-0": "SYM"})
|
||||
slot = repo.list_slots(INST)[0]
|
||||
# activate to holding (occupancy_seq -> 1)
|
||||
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
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
|
||||
repo, store = InMemorySlotRepository(), InMemoryStrategyStateStore()
|
||||
service = SlotService(repo)
|
||||
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")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1, avg_price=1)
|
||||
return repo, store, service, slot, make_strategy_state_set_handler(repo, store)
|
||||
|
||||
|
||||
def _req(slot_id, *, ev=0, eo=1, data="peak", strat="tsl_v1"):
|
||||
return RpcRequest(payload={
|
||||
"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 _req(slot_id, instance="instance-a", version=0, data="state"):
|
||||
return RpcRequest(payload={"slot_id": str(slot_id), "strategy_instance_id": instance, "expected_version": version, "data": data}, correlation_id="same-correlation-id")
|
||||
|
||||
|
||||
def test_first_write_ok():
|
||||
def test_write_uses_instance_identity_and_entry_cas():
|
||||
_, _, _, slot, handler = _wire()
|
||||
resp = handler(_req(slot.slot_id, ev=0))
|
||||
assert resp.payload["ok"] is True and resp.payload["version"] == 1
|
||||
assert handler(_req(slot.slot_id)).payload == {"ok": True, "slot_id": str(slot.slot_id), "strategy_instance_id": "instance-a", "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():
|
||||
_, _, _, slot, handler = _wire()
|
||||
handler(_req(slot.slot_id, ev=0)) # version -> 1
|
||||
resp = handler(_req(slot.slot_id, ev=0, data="stale"))
|
||||
assert resp.payload["ok"] is False
|
||||
assert resp.payload["reason"] == ss.REASON_VERSION_CONFLICT
|
||||
assert resp.payload["version"] == 1
|
||||
@pytest.mark.parametrize("identity", [None, "", 1, True, []])
|
||||
def test_malformed_instance_id_is_rejected_without_write(identity):
|
||||
_, store, _, slot, handler = _wire()
|
||||
response = handler(_req(slot.slot_id, identity)).payload
|
||||
assert response["ok"] is False
|
||||
assert response["reason"].startswith("bad_request:")
|
||||
assert store.get_for_slots([slot.slot_id]) == {}
|
||||
|
||||
|
||||
def test_occupancy_changed():
|
||||
_, _, _, 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():
|
||||
def test_inactive_and_oversize_writes_do_not_change_existing_entry():
|
||||
repo, store, service, slot, handler = _wire()
|
||||
# close the slot
|
||||
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")
|
||||
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
|
||||
handler(_req(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")
|
||||
assert handler(_req(slot.slot_id, version=1)).payload["reason"] == ss.REASON_SLOT_INACTIVE
|
||||
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 1
|
||||
|
||||
@@ -1,118 +1,53 @@
|
||||
"""InMemoryStrategyStateStore CAS tests (dcy-028 / REQ-079, BAT-042).
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Slot-scoped strategy-instance state store contract tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application import strategy_state as ss
|
||||
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return InMemoryStrategyStateStore()
|
||||
|
||||
|
||||
def _set(store, slot_id, *, ev=0, eo=1, data="x", cur_occ=1, active=True, strat="tsl_v1"):
|
||||
def _set(store, slot_id, instance="instance-a", *, version=0, data="x", active=True):
|
||||
return store.set(
|
||||
slot_id, strat,
|
||||
expected_version=ev, expected_occupancy_seq=eo, data=data,
|
||||
current_occupancy_seq=cur_occ, slot_active=active,
|
||||
slot_id, instance, expected_version=version, data=data, slot_active=active
|
||||
)
|
||||
|
||||
|
||||
class TestCas:
|
||||
def test_first_create(self, store):
|
||||
sid = uuid4()
|
||||
r = _set(store, sid, ev=0, eo=1, cur_occ=1, data='{"peak":100}')
|
||||
assert r.ok and r.version == 1
|
||||
|
||||
def test_commit_increments_version(self, store):
|
||||
sid = uuid4()
|
||||
_set(store, sid, ev=0)
|
||||
r = _set(store, sid, ev=1, data="next")
|
||||
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
|
||||
def test_entry_versions_are_isolated():
|
||||
store, slot_id = InMemoryStrategyStateStore(), uuid4()
|
||||
assert _set(store, slot_id, "a", data="a1").version == 1
|
||||
assert _set(store, slot_id, "b", data="b1").version == 1
|
||||
assert _set(store, slot_id, "a", version=1, data="a2").version == 2
|
||||
entries = store.get_for_slots([slot_id])[slot_id]
|
||||
assert entries["a"].version == 2 and entries["a"].data == "a2"
|
||||
assert entries["b"].version == 1 and entries["b"].data == "b1"
|
||||
assert not hasattr(entries["a"], "strategy_id")
|
||||
assert not hasattr(entries["a"], "occupancy_seq")
|
||||
|
||||
|
||||
class TestAbaGuard:
|
||||
def test_turnover_first_write_rejected(self, store):
|
||||
"""dcy-028 R-1 / S-4: read v0 of occupancy 1, slot reused as occupancy 2,
|
||||
stale write against occupancy 1 must not bind to occupancy 2."""
|
||||
sid = uuid4()
|
||||
# occupancy 2 is current; daemon computed against occupancy 1 (eo=1)
|
||||
r = _set(store, sid, ev=0, eo=1, cur_occ=2, data="stale-peak")
|
||||
assert not r.ok and r.reason == ss.REASON_OCCUPANCY_CHANGED
|
||||
# new occupancy starts clean
|
||||
assert store.get_for_slots([sid], {sid: 2}) == {}
|
||||
def test_conflict_inactive_and_size_limit_preserve_entries(monkeypatch):
|
||||
store, slot_id = InMemoryStrategyStateStore(), uuid4()
|
||||
_set(store, slot_id, "a", data="saved")
|
||||
assert _set(store, slot_id, "a", data="stale").reason == ss.REASON_VERSION_CONFLICT
|
||||
assert _set(store, slot_id, "b", active=False).reason == ss.REASON_SLOT_INACTIVE
|
||||
assert _set(store, slot_id, "b", data="x" * (ss._STATE_CAP_BYTES + 1)).reason == ss.REASON_STATE_TOO_LARGE
|
||||
entries = store.get_for_slots([slot_id])[slot_id]
|
||||
assert set(entries) == {"a"} and entries["a"].data == "saved"
|
||||
|
||||
|
||||
class TestBatchedRead:
|
||||
def test_get_for_slots_groups_by_slot_and_strategy(self, store):
|
||||
a, b = uuid4(), uuid4()
|
||||
_set(store, a, ev=0, cur_occ=1, strat="tsl_v1", data="A")
|
||||
_set(store, a, ev=0, cur_occ=1, strat="buy_v1", data="A2")
|
||||
_set(store, b, ev=0, cur_occ=1, strat="tsl_v1", data="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([], {}) == {}
|
||||
def test_concurrent_different_entries_both_survive():
|
||||
store, slot_id = InMemoryStrategyStateStore(), uuid4()
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(lambda key: _set(store, slot_id, key, data=key), ("a", "b")))
|
||||
assert all(result.ok for result in results)
|
||||
assert set(store.get_for_slots([slot_id])[slot_id]) == {"a", "b"}
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_all_removes_every_strategy(self, store):
|
||||
sid = uuid4()
|
||||
_set(store, sid, ev=0, strat="tsl_v1")
|
||||
_set(store, sid, ev=0, strat="buy_v1")
|
||||
store.delete_all(sid)
|
||||
assert store.get_for_slots([sid], {sid: 1}) == {}
|
||||
def test_same_entry_competing_cas_has_one_winner():
|
||||
store, slot_id = InMemoryStrategyStateStore(), uuid4()
|
||||
_set(store, slot_id, "a", data="first")
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(lambda data: _set(store, slot_id, "a", version=1, data=data), ("x", "y")))
|
||||
assert sum(result.ok for result in results) == 1
|
||||
assert store.get_for_slots([slot_id])[slot_id]["a"].version == 2
|
||||
|
||||
@@ -27,6 +27,7 @@ _PG_TABLES = (
|
||||
"daily_symbol_entries",
|
||||
"daily_slot",
|
||||
"reconcile_notification_state",
|
||||
"slot_strategy_state",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,120 +1,23 @@
|
||||
"""Slot occupancy_seq bump + close-delete hook (dcy-028 / REQ-081, BAT-042).
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Slot occupancy remains intact while strategy state survives close/reopen."""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
INST = "default"
|
||||
|
||||
|
||||
def _seed(repo):
|
||||
repo.reconcile_universe(INST, {"pf-0": "SYM"})
|
||||
return repo.list_slots(INST)[0]
|
||||
|
||||
|
||||
def _service(repo, store=None):
|
||||
return SlotService(repo, instance_key=INST, strategy_state_store=store)
|
||||
|
||||
|
||||
def _activate_to_holding(service, slot_id):
|
||||
service.transit_slot(slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
return service.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", qty=10, avg_price=100)
|
||||
|
||||
|
||||
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
|
||||
def test_close_preserves_state_and_reopen_continues_version():
|
||||
repo, store = InMemorySlotRepository(), InMemoryStrategyStateStore()
|
||||
service = SlotService(repo)
|
||||
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")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1, avg_price=1)
|
||||
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")
|
||||
assert inactive.occupancy_seq == 1
|
||||
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance"].data == "old"
|
||||
reopened = service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
|
||||
assert reopened.occupancy_seq == 2
|
||||
assert store.set(slot.slot_id, "instance", expected_version=1, data="new", slot_active=True).version == 2
|
||||
|
||||
@@ -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 (
|
||||
SetResult,
|
||||
StrategyStateEntry,
|
||||
_cas,
|
||||
_STATE_CAP_BYTES,
|
||||
)
|
||||
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
|
||||
|
||||
@@ -701,13 +701,7 @@ class PostgreSQLLedgerOutbox:
|
||||
|
||||
|
||||
class PgStrategyStateStore:
|
||||
"""slot_strategy_state-backed StrategyStateStore (dcy-028 / REQ-079).
|
||||
|
||||
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.
|
||||
"""
|
||||
"""PostgreSQL slot-scoped strategy-instance map store."""
|
||||
|
||||
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
||||
self._psycopg = _require_psycopg(psycopg_module)
|
||||
@@ -716,71 +710,65 @@ class PgStrategyStateStore:
|
||||
def set(
|
||||
self,
|
||||
slot_id: UUID,
|
||||
strategy_id: str,
|
||||
strategy_instance_id: str,
|
||||
*,
|
||||
expected_occupancy_seq: int,
|
||||
expected_version: int,
|
||||
data: str,
|
||||
current_occupancy_seq: int,
|
||||
slot_active: bool,
|
||||
) -> 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:
|
||||
try:
|
||||
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(
|
||||
"SELECT occupancy_seq, version, data, updated_at "
|
||||
"FROM slot_strategy_state "
|
||||
"WHERE slot_id = %s AND strategy_id = %s FOR UPDATE",
|
||||
(slot_id, strategy_id),
|
||||
"SELECT status FROM daily_slot WHERE slot_id = %s::uuid FOR UPDATE",
|
||||
(slot_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
existing = (
|
||||
StrategyStateEntry(
|
||||
strategy_id=strategy_id,
|
||||
occupancy_seq=int(row[0]),
|
||||
version=int(row[1]),
|
||||
data=row[2],
|
||||
updated_at=row[3],
|
||||
)
|
||||
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
|
||||
slot = cur.fetchone()
|
||||
if slot is None or slot[0] not in {"pending_buy", "holding", "pending_sell"}:
|
||||
return SetResult(ok=False, reason="slot_inactive")
|
||||
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),
|
||||
),
|
||||
"SELECT data FROM slot_strategy_state WHERE slot_id = %s::uuid",
|
||||
(slot_id,),
|
||||
)
|
||||
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),
|
||||
)
|
||||
conn.commit()
|
||||
return result
|
||||
return SetResult(ok=True, version=version + 1)
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
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]]:
|
||||
if not slot_ids:
|
||||
return {}
|
||||
@@ -788,26 +776,16 @@ class PgStrategyStateStore:
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
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)",
|
||||
(list(slot_ids),),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
sid = row[0] if isinstance(row[0], UUID) else UUID(str(row[0]))
|
||||
# stale-occupancy entries are invisible (dcy-028 FR-3 defense)
|
||||
if int(row[2]) != current_occupancy_seq.get(sid):
|
||||
continue
|
||||
out.setdefault(sid, {})[row[1]] = StrategyStateEntry(
|
||||
strategy_id=row[1],
|
||||
occupancy_seq=int(row[2]),
|
||||
version=int(row[3]),
|
||||
data=row[4],
|
||||
updated_at=row[5],
|
||||
for instance_id, entry in row[1].items():
|
||||
out.setdefault(sid, {})[instance_id] = StrategyStateEntry(
|
||||
version=int(entry["version"]),
|
||||
data=entry["data"],
|
||||
updated_at=datetime.fromisoformat(entry["updated_at"]),
|
||||
)
|
||||
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).
|
||||
|
||||
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")
|
||||
spy=true: watchdog not updated (operator view, REQ-034 CHG-01)
|
||||
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:
|
||||
body = req.payload or {}
|
||||
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.
|
||||
# None means no filter (all slots returned regardless of strategy_name).
|
||||
strategy_filter = _opt_str(body.get("strategy"))
|
||||
@@ -259,10 +277,42 @@ def make_slots_rpc_handler(
|
||||
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_available = False
|
||||
broker_qty_captured_at: Optional[datetime] = None
|
||||
if broker_quantity_reader is not None:
|
||||
if broker_quantity_reader is not None and slots:
|
||||
try:
|
||||
holdings, broker_qty_captured_at = broker_quantity_reader.holdings_for_slots_rpc()
|
||||
broker_qty_by_symbol = {
|
||||
@@ -290,23 +340,28 @@ def make_slots_rpc_handler(
|
||||
row["qty"] = None
|
||||
row["qty_source"] = "unavailable"
|
||||
|
||||
# dcy-028 / REQ-073/074·079 (C-1): attach opaque strategy_state map per
|
||||
# row via one batched store read (read piggyback — no extra round trip).
|
||||
# Only entries matching the slot's current occupancy_seq are surfaced.
|
||||
# Strategy requests receive only their exact entry; operator requests
|
||||
# receive the complete slot map without changing watchdog behavior.
|
||||
if strategy_state_store is not None and 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, occ)
|
||||
state_map = strategy_state_store.get_for_slots(slot_ids)
|
||||
for row, s in zip(slot_rows, slots, strict=True):
|
||||
entries = state_map.get(s.slot_id)
|
||||
if entries:
|
||||
if spy:
|
||||
row["strategy_state"] = {
|
||||
strat: {
|
||||
instance_id: {
|
||||
"version": e.version,
|
||||
"data": e.data,
|
||||
"updated_at": e.updated_at.isoformat(),
|
||||
}
|
||||
for strat, e in entries.items()
|
||||
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(),
|
||||
}
|
||||
|
||||
# dcy-018 / REQ-029: cash-estimated overlay (best-effort)
|
||||
@@ -347,43 +402,6 @@ def make_slots_rpc_handler(
|
||||
if strategy_filter is not None:
|
||||
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 = {
|
||||
"slots": slot_rows,
|
||||
"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).
|
||||
|
||||
Request: {slot_id, strategy_id, expected_version:int,
|
||||
expected_occupancy_seq:int, data:str}
|
||||
Response (ok): {ok:true, slot_id, strategy_id, version:int}
|
||||
Response (fail): {ok:false, slot_id, strategy_id, reason, version?|occupancy_seq?}
|
||||
Request: {slot_id, strategy_instance_id, expected_version:int, data:str}
|
||||
Response (ok): {ok:true, slot_id, strategy_instance_id, version:int}
|
||||
Response (fail): {ok:false, slot_id, strategy_instance_id, reason, version?}
|
||||
|
||||
reason ∈ {occupancy_changed, version_conflict, slot_inactive,
|
||||
state_too_large, bad_request:<detail>}. The store owns the CAS
|
||||
(REQ-079); the handler supplies the slot's current occupancy_seq and
|
||||
active-ness and never interprets `data` (opaque).
|
||||
The store owns entry-version CAS; the handler supplies active-ness and never
|
||||
interprets opaque data.
|
||||
"""
|
||||
_ACTIVE_STATES = {"pending_buy", "holding", "pending_sell"}
|
||||
|
||||
@@ -422,9 +437,10 @@ def make_strategy_state_set_handler(
|
||||
body = req.payload or {}
|
||||
try:
|
||||
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_occupancy_seq = int(body["expected_occupancy_seq"])
|
||||
data = body["data"]
|
||||
if not isinstance(data, str):
|
||||
raise ValueError("data must be a string")
|
||||
@@ -432,27 +448,23 @@ def make_strategy_state_set_handler(
|
||||
return RpcResponse(payload={
|
||||
"ok": False,
|
||||
"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}",
|
||||
})
|
||||
|
||||
slot = slot_repo.fetch_slot(slot_id)
|
||||
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(
|
||||
slot_id,
|
||||
strategy_id,
|
||||
expected_occupancy_seq=expected_occupancy_seq,
|
||||
strategy_instance_id,
|
||||
expected_version=expected_version,
|
||||
data=data,
|
||||
current_occupancy_seq=current_occupancy_seq,
|
||||
slot_active=slot_active,
|
||||
)
|
||||
payload = {
|
||||
"ok": result.ok,
|
||||
"slot_id": str(slot_id),
|
||||
"strategy_id": strategy_id,
|
||||
"strategy_instance_id": strategy_instance_id,
|
||||
}
|
||||
if result.ok:
|
||||
payload["version"] = result.version
|
||||
@@ -460,8 +472,6 @@ def make_strategy_state_set_handler(
|
||||
payload["reason"] = result.reason
|
||||
if result.version is not None:
|
||||
payload["version"] = result.version
|
||||
if result.occupancy_seq is not None:
|
||||
payload["occupancy_seq"] = result.occupancy_seq
|
||||
return RpcResponse(payload=payload)
|
||||
|
||||
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,
|
||||
"universe_detached": getattr(s, "retired_at", None) is not None,
|
||||
# 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),
|
||||
}
|
||||
|
||||
@@ -638,4 +638,3 @@ def _sanitize(message: str) -> str:
|
||||
break
|
||||
return out[:200]
|
||||
|
||||
|
||||
|
||||
@@ -1,27 +1,4 @@
|
||||
"""Slot strategy-state store (dcy-028 / REQ-079, BAT-042).
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Slot-scoped opaque strategy-instance state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -31,13 +8,8 @@ from datetime import datetime, timezone
|
||||
from typing import Optional, Protocol
|
||||
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"))
|
||||
|
||||
# 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_SLOT_INACTIVE = "slot_inactive"
|
||||
REASON_STATE_TOO_LARGE = "state_too_large"
|
||||
@@ -45,10 +17,8 @@ REASON_STATE_TOO_LARGE = "state_too_large"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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
|
||||
data: str
|
||||
updated_at: datetime
|
||||
@@ -56,64 +26,20 @@ class StrategyStateEntry:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetResult:
|
||||
"""Outcome of a CAS set. ok=True carries the new version; else `reason`."""
|
||||
|
||||
ok: bool
|
||||
version: Optional[int] = None
|
||||
reason: Optional[str] = None
|
||||
occupancy_seq: Optional[int] = None # current slot occupancy_seq on conflict
|
||||
|
||||
|
||||
def _byte_len(data: str) -> int:
|
||||
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(
|
||||
existing: Optional[StrategyStateEntry],
|
||||
*,
|
||||
expected_occupancy_seq: int,
|
||||
expected_version: int,
|
||||
data: str,
|
||||
current_occupancy_seq: int,
|
||||
slot_active: bool,
|
||||
existing: Optional[StrategyStateEntry], *, expected_version: int, data: str, slot_active: bool
|
||||
) -> SetResult:
|
||||
"""Pure CAS decision shared by all store implementations (dcy-028 FR-2)."""
|
||||
if not slot_active:
|
||||
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:
|
||||
return SetResult(ok=False, reason=REASON_STATE_TOO_LARGE)
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""Process-local strategy-state store (paper / tests / explicit double)."""
|
||||
"""Process-local slot-scoped state-map store."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# (slot_id, strategy_id) -> StrategyStateEntry
|
||||
self._store: dict[tuple[UUID, str], StrategyStateEntry] = {}
|
||||
self._store: dict[UUID, dict[str, StrategyStateEntry]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set(
|
||||
self,
|
||||
slot_id: UUID,
|
||||
strategy_id: str,
|
||||
strategy_instance_id: str,
|
||||
*,
|
||||
expected_occupancy_seq: int,
|
||||
expected_version: int,
|
||||
data: str,
|
||||
current_occupancy_seq: int,
|
||||
slot_active: bool,
|
||||
) -> SetResult:
|
||||
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(
|
||||
existing,
|
||||
expected_occupancy_seq=expected_occupancy_seq,
|
||||
expected_version=expected_version,
|
||||
data=data,
|
||||
current_occupancy_seq=current_occupancy_seq,
|
||||
slot_active=slot_active,
|
||||
existing, expected_version=expected_version, data=data, slot_active=slot_active
|
||||
)
|
||||
if not result.ok:
|
||||
if not entries:
|
||||
self._store.pop(slot_id, None)
|
||||
return result
|
||||
self._store[(slot_id, strategy_id)] = StrategyStateEntry(
|
||||
strategy_id=strategy_id,
|
||||
occupancy_seq=current_occupancy_seq,
|
||||
entries[strategy_instance_id] = StrategyStateEntry(
|
||||
version=result.version, # type: ignore[arg-type]
|
||||
data=data,
|
||||
updated_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
return result
|
||||
|
||||
def get_for_slots(
|
||||
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
|
||||
) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
||||
def get_for_slots(self, slot_ids: list[UUID]) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
||||
wanted = set(slot_ids)
|
||||
out: dict[UUID, dict[str, StrategyStateEntry]] = {}
|
||||
with self._lock:
|
||||
for (sid, strat), entry in self._store.items():
|
||||
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]
|
||||
return {sid: dict(entries) for sid, entries in self._store.items() if sid in wanted}
|
||||
|
||||
@@ -44,17 +44,12 @@ class SlotService:
|
||||
self,
|
||||
repository: SlotRepository,
|
||||
instance_key: str = "default",
|
||||
*,
|
||||
strategy_state_store: Any = None,
|
||||
) -> None:
|
||||
self._repo = repository
|
||||
# dcy-020/REQ-057: advisory lock is scoped per instance so a shared DB
|
||||
# does not serialize transitions across instances (NFR-1).
|
||||
self._instance_key = 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
|
||||
def instance_key(self) -> str:
|
||||
@@ -155,11 +150,6 @@ class SlotService:
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -182,13 +182,12 @@ class Daemon:
|
||||
# Persistence + services
|
||||
self.slot_repo = PostgreSQLSlotRepository(config.dsn)
|
||||
self.intent_store = PostgreSQLIntentStore(config.dsn)
|
||||
# dcy-028 / REQ-079·081 (BAT-042): opaque strategy-state store. Injected
|
||||
# into the slot service so close (→INACTIVE) deletes the slot's state.
|
||||
# dcy-028 / REQ-079·081 (BAT-042): opaque strategy-state store persists
|
||||
# independently of slot occupancy and is shared by both RPC handlers.
|
||||
self.strategy_state_store = PgStrategyStateStore(config.dsn)
|
||||
self.slot_service = SlotService(
|
||||
self.slot_repo,
|
||||
instance_key=config.instance_key,
|
||||
strategy_state_store=self.strategy_state_store,
|
||||
)
|
||||
|
||||
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
|
||||
@@ -458,7 +457,7 @@ class Daemon:
|
||||
topology.slots_queue(self.config.instance_key), handler, self.stop)
|
||||
|
||||
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)
|
||||
self._transports.append(transport)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user