Skip to content

feat(etl): replace replay on repaired cancellation stack - #148

Draft
seonghobae wants to merge 32 commits into
repair/durable-job-cancellation-9e4d69efrom
repair/durable-job-replay-0a07ca3
Draft

feat(etl): replace replay on repaired cancellation stack#148
seonghobae wants to merge 32 commits into
repair/durable-job-cancellation-9e4d69efrom
repair/durable-job-replay-0a07ca3

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Stack repair purpose

This Draft non-destructively replaces obsolete replay PR #135 from the exact current repaired cancellation predecessor #147. The old #135 branch and its complete fail-first history remain preserved; none of its checks, reviews, approvals, statuses, comments, or base snapshots transfer to this replacement.

Exact current boundary

  • immediate predecessor PR: feat(etl): replace cancellation on repaired conditional-status stack #147;
  • base branch: repair/durable-job-cancellation-9e4d69e;
  • exact live predecessor/base tip: 0a07ca3ba447ba696da2777d7d51eca373b38b2c;
  • replacement branch: repair/durable-job-replay-0a07ca3;
  • exact current head: 13a8c1a4d914bd52c6d46073c2032c26656e5ff6;
  • comparison to exact predecessor remains non-destructive and fast-forward-only;
  • GitHub mergeability: true;
  • Draft: true;
  • formal reviews: none at the latest exact-head refresh;
  • unresolved review threads: zero at the latest exact-head refresh.

No force push, destructive rebase, -X ours, -X theirs, temporary write-capable workflow, protection bypass, or cross-repository write is used. Branch publication uses a non-forced exact-parent ref update so concurrent branch movement fails instead of being silently incorporated.

Stack-divergence RCA

Old PR #135 targets obsolete cancellation branch feat/durable-job-cancellation@ee50859d654b89fdba5b14424fe7b8afc3b6c99d. The repaired stack reaches cancellation through #147 at exact head 0a07ca3ba447ba696da2777d7d51eca373b38b2c; merely retargeting the unchanged old replay head would not prove repaired-predecessor ancestry.

Material options:

TDD evidence

RED 1 — immutable replay result

Creation commit ddb45d7d1889ae3ea844b750ca49011381eb081c added the immutable replay-result contract before production EtlJobReplay existed. Literal-head CI reached test compilation and failed on that missing production type rather than setup, dependency, fixture, or runner failure.

Subsequent bounded commits restored the immutable result, replay-key/principal/JSON validation boundary, constructor contracts, and fail-closed placeholder service without importing obsolete #135 evidence.

RED 2 — first database-owned replay transition

Commit cb24c6d3526be7579c09a424990cfc128d5f022f added EtlJobReplayPersistenceIntegrationTest before production persistence. Literal-head CI run 31306398355 checked out that exact SHA and failed at the intended production persistence sentinel. Commit 97ba5563b9bc7d2af17d55a1140af3cd72189162 then made the persistence expectations explicit; its exact-head CI again failed only at the same absent persistence boundary.

Head 8e1978e50760c83d42f25fee20eeb8b7674af2ec implemented the smallest first-generation FAILED-source database transition. Fresh literal-head macOS CI from run 31306680248 completed the full reactor successfully with 436 etl-service tests, 106 CDC tests, the gateway suite, and configured JaCoCo coverage checks green; Windows also completed successfully. Ubuntu, Dependency Review, and SBOM remained queued at that refresh and were not counted as passing.

RED 3 — cancelled terminal sources were excluded by the source-row predicate

Commit 135d6e4b446e092e1e3ce48e30cb15f3448116e6 added only a database integration test proving that a first-generation replay from an owner-matched CANCELLED source must create a new PENDING child while leaving cancellation evidence and source timestamps unchanged.

Literal-head CI run 31307842729, Windows job 93230908198, checked out and verified exact head 135d6e4b446e092e1e3ce48e30cb15f3448116e6, compiled production and tests, and ran 437 etl-service tests. Exactly the new cancelled-source test failed, with zero setup/import/fixture errors: EtlJobReplayService.replayOwned reached the database source selection and received EmptyResultDataAccessException: expected 1, actual 0. Existing replay, cancellation, worker, pagination, conditional-status, idempotency, and documentation tests were green before Maven stopped. The first failing boundary was therefore the production source-row predicate, which still required job_status = 'FAILED'.

GREEN candidate — failed or cancelled first-generation terminal replay

Exact current head 13a8c1a4d914bd52c6d46073c2032c26656e5ff6 applies the smallest root-cause repair proven by RED 3:

  • the locked owner/digest/source predicate now admits only FAILED or CANCELLED terminal states through job_status IN ('FAILED', 'CANCELLED');
  • first-generation lineage/null guards remain unchanged;
  • the terminal source is still selected FOR UPDATE and is never mutated;
  • cancellation evidence remains source-owned and is not copied or cleared;
  • the derived replay remains a new PENDING row with exact request digest, resupplied payload, and immediate/root lineage equal to the source;
  • public Javadoc now states the bounded failed-or-cancelled contract.

No idempotent replay lookup, replay-key concurrency, deeper generation policy, production replay migration, trigger/index authority, controller exposure, or release claim is introduced by this increment.

Current exact-head gates

For exact current head 13a8c1a4d914bd52c6d46073c2032c26656e5ff6:

  • CI 31307969270: triggered; not accepted until the complete exact-head matrix finishes successfully;
  • Dependency Review 31307969308: queued at the latest refresh and not passing;
  • CycloneDX SBOM 31307969312: queued at the latest refresh and not passing;
  • CodeRabbit exact-head commit status: must be revalidated after this head change and is not inferred from any predecessor;
  • SAST Semgrep and Security Scan: absent on this non-default stacked base and not passing;
  • independent non-author formal APPROVED review: absent;
  • unresolved review threads: zero at the latest refresh.

Queued, pending, skipped-required, neutral-required, absent, cancelled, failed, stale-head, predecessor-head, old-base, and synthetic-merge-only evidence is not passing.

Remaining replay boundary

This is deliberately not the full #135 contract. Stable owner-safe state errors, replay-key idempotent lookup/concurrency, deeper lineage generations, production replay migrations, database triggers/indexes, controller exposure, direct PostgreSQL rehearsal, authoritative operations/doctoring/changelog updates, and release evidence remain later test-first increments. The HTTP replay resource remains unexposed while those controls are absent.

Merge boundary

Keep this PR Draft. Do not close #135 as superseded and do not merge this replacement until the complete replay product/security/migration/documentation contract is rebuilt on this ancestry and every exact-head/base quality, dependency, SBOM, SAST, security, migration, coverage, review-thread, automated-review, and independent non-author approval gate is freshly satisfied.

Summary by CodeRabbit

  • 새로운 기능

    • 실패하거나 취소된 ETL 작업을 안전하게 다시 실행할 수 있습니다.
    • 동일한 재실행 요청은 중복 작업을 만들지 않고 기존 결과를 반환합니다.
    • 재실행 요청의 작업 소유권, 요청 키, JSON 형식 및 데이터 일치 여부를 검증합니다.
  • 오류 처리

    • 처리 중이거나 성공한 작업의 재실행을 제한하고, 잘못된 키·데이터·권한 요청에 대해 구체적인 오류 정보를 제공합니다.
    • 동시 재실행 요청을 조정해 중복 생성과 데이터 충돌을 방지합니다.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1a39eaa2-3f7c-45b3-95cb-482de9f355ac

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

EtlJobReplayService가 소유자 범위 replay 키와 JSON payload를 검증합니다. 종료된 FAILED 또는 CANCELLED 원본을 잠근 뒤 멱등 replay 작업을 반환하거나 새 PENDING 자식을 생성합니다. 경계 및 H2 통합 테스트가 오류 분류와 영속성 동작을 검증합니다.

Changes

내구성 작업 재생

Layer / File(s) Summary
Replay 계약과 영속성 계약
etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobReplay.java, etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobReplayService.java, etl-service/src/main/java/com/xtrmetl/etl/service/EtlRequestError.java
EtlJobReplay 결과 record와 replay 전용 오류 분류를 추가합니다. replay 조회, 원본 잠금, 자식 삽입을 위한 SQL을 정의합니다.
Replay 검증과 생성 흐름
etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobReplayService.java
replay 키, principal, JSON payload, record 수와 record 형식을 검증합니다. 잠금과 멱등 조회 후 적합한 원본에 PENDING replay 자식을 생성합니다.
입력 경계 검증
etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayBoundaryTest.java
필수 값, 생성자 의존성, 키·principal·JSON·payload·batch·record 검증과 데이터베이스 호출 전 오류 처리를 테스트합니다.
상태 분류와 영속성 통합 검증
etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayPersistenceIntegrationTest.java, etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayStateClassificationIntegrationTest.java
실패·취소 원본의 replay 생성, 멱등성, 키 재사용 차단, 계보 보존 및 원본 상태별 오류 분류를 H2 환경에서 검증합니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant EtlJobReplayService
  participant EtlRequestLock
  participant JdbcTemplate
  participant ObjectMapper
  Client->>EtlJobReplayService: replayOwned(...)
  EtlJobReplayService->>ObjectMapper: JSON payload 검증
  EtlJobReplayService->>EtlRequestLock: principal 범위 잠금 획득
  EtlJobReplayService->>JdbcTemplate: 기존 replay 조회 및 원본 행 잠금
  EtlJobReplayService->>JdbcTemplate: PENDING replay 자식 삽입
  EtlJobReplayService-->>Client: EtlJobReplay 반환
Loading

Possibly related issues

  • mightyETL 134: 소유자 범위 durable replay, payload 검증, 멱등성, 상태 분류 및 동시성 제어 목표와 일치합니다.

Suggested labels: manual-merge

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목은 복구된 cancellation 스택에서 ETL replay를 대체하는 주요 변경을 간결하게 설명하며 변경 내용과 일치한다.
Linked Issues check ✅ Passed 직접 이슈 #135의 현재 범위인 소유자 검증, JSON·digest 검증, FAILED/CANCELLED 원본의 1세대 PENDING replay, 키 멱등성 및 동시성 처리를 구현했다.
Out of Scope Changes check ✅ Passed 변경은 replay 결과·서비스·오류 분류와 관련 경계 및 영속성 테스트로 한정되어 제공된 현재 범위를 벗어난 코드 변경이 없다.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch repair/durable-job-replay-0a07ca3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Collaborator Author

Fresh exact-head RCA on e935d5ebb4c9cca7e77fff5889b759eff00d4fcb over exact predecessor 0a07ca3ba447ba696da2777d7d51eca373b38b2c: the next deterministic replay boundary is now the production PostgreSQL schema, not the in-memory/service contract.

The live replay service already reads/writes replay_source_job_record_id, replay_root_job_record_id, and replay_generation_count. However the exact predecessor's latest replay-relevant production migration is still V6__add_etl_job_cancellation.sql, which adds only cancellation lifecycle columns/constraints, and a direct lookup of etl-service/src/main/resources/db/migration/V7__add_etl_job_replay_lineage.sql on the current #148 head returns 404. EtlJobMigrationDocumentationTest on this head asserts V2/V5/V6 only and has no replay-lineage migration contract. Meanwhile EtlJobReplayPersistenceIntegrationTest manually creates those replay columns in its H2 fixture, so the current green test matrix can exercise the service while masking a real upgraded-PostgreSQL failure: the application SQL would reference columns that Flyway has never added.

RCA: immediate cause = production service has advanced ahead of production schema evolution; test-control failure = the replay integration fixture self-provisions the missing columns instead of requiring the real migration path; systemic risk = a source-local GREEN candidate could be promoted while fresh/upgraded PostgreSQL cannot execute the same SQL. This is independent of the later full #135 trigger/index/generation contract and should be repaired before controller exposure.

Bounded remedies: execute_now = add a fail-first production migration contract on this exact repaired ancestry, then the smallest auditable replay-lineage migration (descriptive multi-word snake_case columns plus owner/lineage integrity needed by the currently implemented first-generation service), and prove upgrade behavior against the production migration path; defer = deeper-generation triggers/indexes/controller/ops until their own REDs exist; reject = treating the manual H2 table as migration evidence, weakening the persistence assertions, or copying old #135 migration evidence without rebuilding it on this ancestry. Keep #135 open until every unique migration/trigger/index/controller/operations obligation is proven transferred.

Acceptance for the next slice: RED must fail because the production replay migration is absent; GREEN must make the real migration artifact authoritative, preserve V6 cancellation invariants, keep source/root owner-safe and non-self-referential, preserve rollback/forward-recovery evidence, and then rerun exact-head CI/Dependency/SBOM plus applicable security/review gates. No old-head evidence transfers.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobReplayService.java (1)

273-298: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

사용하지 않는 반환값을 정리하십시오.

validateReplayPayloadJsonNode를 반환합니다. Line 167의 유일한 호출자는 그 값을 사용하지 않습니다. 반환 타입을 void로 바꾸면 계약이 명확해집니다. 향후 파싱 결과가 필요해지면 그때 반환값을 다시 도입하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobReplayService.java`
around lines 273 - 298, Change validateReplayPayload to return void because its
sole caller does not use the parsed JsonNode; retain all existing validation and
exception behavior, and remove the returned root value while preserving parsing
and record validation.
etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayPersistenceIntegrationTest.java (1)

112-114: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

원본 불변 검증을 강화하십시오.

Line 114는 원본의 updated_at이 변하지 않았음을 확인합니다. 그러나 이 테스트 스키마에는 갱신 트리거가 없습니다. updated_atDEFAULT CURRENT_TIMESTAMP만 가집니다. 따라서 서비스가 원본 행의 다른 컬럼을 수정해도 이 단언은 통과합니다.

원본 불변성을 직접 증명하려면 attempt_count, failure_code, submission_key_hash, request_digest도 함께 확인하십시오. 또는 replay 호출 전후로 원본 행 전체를 스냅샷하여 비교하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayPersistenceIntegrationTest.java`
around lines 112 - 114, 강화된 원본 불변성 검증을 위해
EtlJobReplayPersistenceIntegrationTest의 replay 전후 원본 행 비교에 attempt_count,
failure_code, submission_key_hash, request_digest 컬럼 단언을 추가하십시오. 기존 job_status,
request_payload, updated_at 검증은 유지하고, replay가 원본 행의 어떤 컬럼도 변경하지 않았음을 확인하도록
sourceJobRecordId 조회 결과를 검증하십시오.
etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayStateClassificationIntegrationTest.java (1)

51-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

중복된 테스트 스키마와 Spring 설정을 공유 픽스처로 추출하십시오.

두 통합 테스트가 같은 etl_job_records 테이블을 각각 인라인으로 정의합니다. 두 정의는 이미 서로 다릅니다. 한쪽에는 failure_code, lease_*, cancellation_* 컬럼이 있고 다른 쪽에는 없습니다. 두 테스트의 TestConfiguration도 문자 단위로 동일합니다. 공유 픽스처가 없다는 것이 공통 원인입니다. 이 상태에서는 운영 스키마가 바뀔 때 두 곳을 따로 수정해야 하고, 한쪽만 갱신되는 누락이 발생합니다.

  • etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayStateClassificationIntegrationTest.java#L51-L72: 인라인 CREATE TABLE을 제거하고 공유 스키마 리소스(예: 테스트 클래스패스의 단일 .sql 파일)를 실행하십시오.
  • etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayPersistenceIntegrationTest.java#L60-L84: 같은 공유 스키마 리소스를 실행하도록 바꾸십시오.
  • etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayStateClassificationIntegrationTest.java#L156-L208: TestConfiguration을 공용 테스트 설정 클래스로 옮기고 @SpringJUnitConfig에서 참조하십시오.
  • etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayPersistenceIntegrationTest.java#L335-L387: 중복 정의를 삭제하고 같은 공용 테스트 설정 클래스를 참조하십시오.

이 변경은 테스트 코드에 한정되며 운영 코드에 영향을 주지 않습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayStateClassificationIntegrationTest.java`
around lines 51 - 72, Extract the duplicated test schema and Spring
configuration into shared test fixtures. In
etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayStateClassificationIntegrationTest.java:51-72
and
etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayPersistenceIntegrationTest.java:60-84,
remove the inline etl_job_records DDL and execute one shared classpath SQL
schema containing all required columns. Move the duplicate TestConfiguration
definitions from EtlJobReplayStateClassificationIntegrationTest.java:156-208 and
EtlJobReplayPersistenceIntegrationTest.java:335-387 into a common test
configuration class, and update both `@SpringJUnitConfig` declarations to
reference it; keep changes limited to test code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobReplayService.java`:
- Around line 220-230: Update the EtlJobStatus switch in EtlJobReplayService to
add a default branch that explicitly rejects any unrecognized status with the
appropriate EtlRequestException/error, preserving the existing handling for
PENDING, RUNNING, SUCCEEDED, FAILED, and CANCELLED.

---

Nitpick comments:
In `@etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobReplayService.java`:
- Around line 273-298: Change validateReplayPayload to return void because its
sole caller does not use the parsed JsonNode; retain all existing validation and
exception behavior, and remove the returned root value while preserving parsing
and record validation.

In
`@etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayPersistenceIntegrationTest.java`:
- Around line 112-114: 강화된 원본 불변성 검증을 위해 EtlJobReplayPersistenceIntegrationTest의
replay 전후 원본 행 비교에 attempt_count, failure_code, submission_key_hash,
request_digest 컬럼 단언을 추가하십시오. 기존 job_status, request_payload, updated_at 검증은
유지하고, replay가 원본 행의 어떤 컬럼도 변경하지 않았음을 확인하도록 sourceJobRecordId 조회 결과를 검증하십시오.

In
`@etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayStateClassificationIntegrationTest.java`:
- Around line 51-72: Extract the duplicated test schema and Spring configuration
into shared test fixtures. In
etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayStateClassificationIntegrationTest.java:51-72
and
etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayPersistenceIntegrationTest.java:60-84,
remove the inline etl_job_records DDL and execute one shared classpath SQL
schema containing all required columns. Move the duplicate TestConfiguration
definitions from EtlJobReplayStateClassificationIntegrationTest.java:156-208 and
EtlJobReplayPersistenceIntegrationTest.java:335-387 into a common test
configuration class, and update both `@SpringJUnitConfig` declarations to
reference it; keep changes limited to test code.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a577583d-2f72-47a1-998a-38035db72506

📥 Commits

Reviewing files that changed from the base of the PR and between 0a07ca3 and e935d5e.

📒 Files selected for processing (6)
  • etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobReplay.java
  • etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobReplayService.java
  • etl-service/src/main/java/com/xtrmetl/etl/service/EtlRequestError.java
  • etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayBoundaryTest.java
  • etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayPersistenceIntegrationTest.java
  • etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobReplayStateClassificationIntegrationTest.java

@opencode-agent opencode-agent Bot added area: data Database, schema, migration, ETL, or lineage priority: medium Normal-priority or P2 work status: draft Draft pull request type: feature New or expanded product capability labels Aug 22, 2026
@seonghobae seonghobae added the enhancement New feature or request label Sep 7, 2026 — with ChatGPT Codex Connector
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: data Database, schema, migration, ETL, or lineage enhancement New feature or request priority: medium Normal-priority or P2 work status: draft Draft pull request type: feature New or expanded product capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant