concurrency-guard 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- concurrency_guard-0.1.0/.github/workflows/ci.yml +42 -0
- concurrency_guard-0.1.0/.gitignore +10 -0
- concurrency_guard-0.1.0/PKG-INFO +191 -0
- concurrency_guard-0.1.0/README.md +171 -0
- concurrency_guard-0.1.0/action.yml +45 -0
- concurrency_guard-0.1.0/examples/real_world_ecommerce/__init__.py +2 -0
- concurrency_guard-0.1.0/examples/real_world_ecommerce/checkout_router.py +100 -0
- concurrency_guard-0.1.0/examples/real_world_ecommerce/models.py +55 -0
- concurrency_guard-0.1.0/examples/real_world_ecommerce/tasks.py +27 -0
- concurrency_guard-0.1.0/examples/vulnerable_inventory.py +97 -0
- concurrency_guard-0.1.0/examples/vulnerable_user_service.py +115 -0
- concurrency_guard-0.1.0/examples/vulnerable_wallet.py +91 -0
- concurrency_guard-0.1.0/pyproject.toml +52 -0
- concurrency_guard-0.1.0/src/concurrency_guard/__init__.py +4 -0
- concurrency_guard-0.1.0/src/concurrency_guard/cli.py +420 -0
- concurrency_guard-0.1.0/src/concurrency_guard/patcher/__init__.py +17 -0
- concurrency_guard-0.1.0/src/concurrency_guard/patcher/cst_fixer.py +324 -0
- concurrency_guard-0.1.0/src/concurrency_guard/reporter/__init__.py +6 -0
- concurrency_guard-0.1.0/src/concurrency_guard/reporter/github_pr.py +104 -0
- concurrency_guard-0.1.0/src/concurrency_guard/runner/__init__.py +16 -0
- concurrency_guard-0.1.0/src/concurrency_guard/runner/barrier_engine.py +421 -0
- concurrency_guard-0.1.0/src/concurrency_guard/runner/route_inspector.py +150 -0
- concurrency_guard-0.1.0/src/concurrency_guard/runner/schema_synthesizer.py +79 -0
- concurrency_guard-0.1.0/src/concurrency_guard/scanner/__init__.py +19 -0
- concurrency_guard-0.1.0/src/concurrency_guard/scanner/ast_rules.py +476 -0
- concurrency_guard-0.1.0/tests/test_phase1_ast.py +66 -0
- concurrency_guard-0.1.0/tests/test_phase2_ast.py +225 -0
- concurrency_guard-0.1.0/tests/test_phase3_barrier.py +165 -0
- concurrency_guard-0.1.0/tests/test_phase4_fixer.py +80 -0
- concurrency_guard-0.1.0/tests/test_phase5_multi_fixer.py +152 -0
- concurrency_guard-0.1.0/tests/test_phase6_reporter_and_cg005.py +124 -0
- concurrency_guard-0.1.0/tests/test_phase7_dynamic_runner.py +122 -0
- concurrency_guard-0.1.0/tests/test_phase8_production_hardening.py +100 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Lint, Type Check, and Test
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.11", "3.12"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout repository
|
|
19
|
+
uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
22
|
+
uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade pip
|
|
29
|
+
pip install .[dev]
|
|
30
|
+
|
|
31
|
+
- name: Run Ruff Linter
|
|
32
|
+
run: |
|
|
33
|
+
ruff check .
|
|
34
|
+
|
|
35
|
+
- name: Run Mypy Type Checker
|
|
36
|
+
run: |
|
|
37
|
+
mypy src/
|
|
38
|
+
|
|
39
|
+
- name: Run Pytest Suite
|
|
40
|
+
run: |
|
|
41
|
+
pytest -v
|
|
42
|
+
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: concurrency-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic AST + Runtime Concurrency & Idempotency Verifier for Python FastAPI/SQLAlchemy backends
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: aiosqlite>=0.20.0
|
|
7
|
+
Requires-Dist: fastapi>=0.111.0
|
|
8
|
+
Requires-Dist: greenlet>=3.0.0
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: libcst>=1.4.0
|
|
11
|
+
Requires-Dist: rich>=13.7.0
|
|
12
|
+
Requires-Dist: sqlalchemy>=2.0.30
|
|
13
|
+
Requires-Dist: typer>=0.12.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8.2.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# ConcurrencyGuard (`cguard`)
|
|
22
|
+
|
|
23
|
+
[](https://github.com/AyushkhatiDev/concurrency-guard/actions/workflows/ci.yml)
|
|
24
|
+
[](https://www.python.org/downloads/)
|
|
25
|
+
[](https://opensource.org/licenses/MIT)
|
|
26
|
+
|
|
27
|
+
**Deterministic AST + Runtime Concurrency & Idempotency Verifier for Python FastAPI, SQLAlchemy, and Celery Backends.**
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## ⚡ Why ConcurrencyGuard?
|
|
32
|
+
|
|
33
|
+
Modern async Python services frequently suffer from silent concurrency bugs, double-spend race conditions, lost updates, and duplicate background tasks. Traditional linters only check syntax and type hints, while external LLM tools hallucinate or miss non-local control flow races.
|
|
34
|
+
|
|
35
|
+
**ConcurrencyGuard (`cguard`)** solves this with a **100% deterministic pipeline**:
|
|
36
|
+
1. **Pure AST Static Scanning**: Catches race patterns, missing idempotency keys, N+1 queries, uniqueness races, and unsafe Celery retry tasks.
|
|
37
|
+
2. **Runtime `asyncio.Barrier` Exploit Engine**: Proves race conditions empirically by synchronizing concurrent coroutines to hit endpoints at the exact same microsecond.
|
|
38
|
+
3. **Deterministic `LibCST` Auto-Patcher**: Safely rewrites code to apply database row locks (`.with_for_update()`), inject FastAPI `Idempotency-Key` headers, and wrap transactions in `IntegrityError` guards.
|
|
39
|
+
4. **Automated GitHub PR Reporter**: Emits rich Markdown comments with exploit proofs and diff previews for pull requests.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 📋 Rule Catalog
|
|
44
|
+
|
|
45
|
+
| Rule ID | Name | Target / Trigger Condition | Automated Fix |
|
|
46
|
+
|:---|:---|:---|:---|
|
|
47
|
+
| **`CG-001`** | **Unlocked Read-Modify-Write Race** | Async route reads database state with `select()`, mutates an attribute, and calls `commit()` without `.with_for_update()` locking. | Appends `.with_for_update()` row-level locks via LibCST. |
|
|
48
|
+
| **`CG-002`** | **Missing Idempotency Guard** | Async `@app.post` or `@router.post` route mutates state without an `idempotency_key` parameter. | Injects `idempotency_key: str = Header(..., alias="Idempotency-Key")` into route parameters. |
|
|
49
|
+
| **`CG-003`** | **Async Loop N+1 Query Explode** | `db.execute(...)` or `session.execute(...)` call located directly inside a `for` or `async for` loop body. | Flags query explosion; suggests batching or eager loading (`joinedload`/`selectinload`). |
|
|
50
|
+
| **`CG-004`** | **Check-Then-Act Uniqueness Race** | Async route checks existence with `select()`, calls `add()`, and commits without catching `sqlalchemy.exc.IntegrityError`. | Wraps commit statements in `try...except IntegrityError` with session rollback. |
|
|
51
|
+
| **`CG-005`** | **Non-Idempotent Celery Retry Task** | Celery task (`@app.task`, `@shared_task`) with `autoretry_for` or `max_retries` executes mutations (`.add()`, `.commit()`, `.post()`) without `acks_late=True` or an idempotency lock. | Recommends `acks_late=True` and distributed deduplication lock before side-effects. |
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 🚀 Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Clone and install in virtual environment
|
|
59
|
+
git clone https://github.com/AyushkhatiDev/concurrency-guard.git
|
|
60
|
+
cd concurrency-guard
|
|
61
|
+
python -m venv .venv
|
|
62
|
+
source .venv/bin/activate
|
|
63
|
+
pip install -e .
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
To install with development and testing dependencies:
|
|
67
|
+
```bash
|
|
68
|
+
pip install -e ".[dev]"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 🛠️ CLI Usage
|
|
74
|
+
|
|
75
|
+
### 1. `cguard scan` — Static AST Analysis
|
|
76
|
+
Scan files or entire directories for concurrency vulnerabilities:
|
|
77
|
+
```bash
|
|
78
|
+
# Scan a single file
|
|
79
|
+
cguard scan examples/vulnerable_wallet.py
|
|
80
|
+
|
|
81
|
+
# Scan an entire codebase
|
|
82
|
+
cguard scan src/
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 2. `cguard verify` — Runtime `asyncio.Barrier` Verification
|
|
86
|
+
Simulate high-concurrency traffic using synchronized barrier workers to prove whether an invariant holds or breaks under load:
|
|
87
|
+
```bash
|
|
88
|
+
cguard verify examples/vulnerable_wallet.py --workers 20
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Sample exploit proof output:
|
|
92
|
+
```
|
|
93
|
+
❌ CONCURRENCY INVARIANT VIOLATED (RACE PROVEN)
|
|
94
|
+
Target File: examples/vulnerable_wallet.py
|
|
95
|
+
Target Endpoint: POST /wallet/withdraw
|
|
96
|
+
Concurrent Barrier Workers: 20
|
|
97
|
+
Initial Wallet Balance: $50
|
|
98
|
+
Successful Withdrawals ($50 each): 20
|
|
99
|
+
Final Resulting Balance: $-950
|
|
100
|
+
|
|
101
|
+
INVARIANT VIOLATION PROVEN:
|
|
102
|
+
Multiple concurrent withdrawals succeeded against a $50 balance due to an unlocked
|
|
103
|
+
Read-Modify-Write race condition (CG-001).
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### 3. `cguard fix` — Deterministic LibCST Auto-Patcher
|
|
107
|
+
Inspect and apply deterministic code patches:
|
|
108
|
+
```bash
|
|
109
|
+
# Preview auto-fixes (dry-run)
|
|
110
|
+
cguard fix examples/vulnerable_wallet.py
|
|
111
|
+
|
|
112
|
+
# Apply specific rule fix
|
|
113
|
+
cguard fix examples/vulnerable_wallet.py --rule CG-001 --write
|
|
114
|
+
|
|
115
|
+
# Apply all available auto-fixes directly to disk
|
|
116
|
+
cguard fix examples/vulnerable_wallet.py --write
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 4. `cguard report` — GitHub Pull Request Markdown Reporter
|
|
120
|
+
Generate comprehensive Markdown summaries for CI/CD:
|
|
121
|
+
```bash
|
|
122
|
+
# Print report to stdout
|
|
123
|
+
cguard report examples/vulnerable_wallet.py
|
|
124
|
+
|
|
125
|
+
# Write report to markdown file for GitHub Actions PR comment
|
|
126
|
+
cguard report examples/vulnerable_wallet.py --output pr_report.md
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 🤖 GitHub Action Integration
|
|
132
|
+
|
|
133
|
+
Add ConcurrencyGuard to your pull request workflows:
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
name: Concurrency Guard CI
|
|
137
|
+
|
|
138
|
+
on:
|
|
139
|
+
pull_request:
|
|
140
|
+
branches: [main]
|
|
141
|
+
|
|
142
|
+
jobs:
|
|
143
|
+
concurrency-audit:
|
|
144
|
+
runs-on: ubuntu-latest
|
|
145
|
+
steps:
|
|
146
|
+
- uses: actions/checkout@v4
|
|
147
|
+
|
|
148
|
+
- name: Run ConcurrencyGuard Action
|
|
149
|
+
uses: AyushkhatiDev/concurrency-guard@main
|
|
150
|
+
with:
|
|
151
|
+
path: "app"
|
|
152
|
+
report-file: "app/main.py"
|
|
153
|
+
output: "pr-report.md"
|
|
154
|
+
|
|
155
|
+
- name: Post PR Comment
|
|
156
|
+
if: failure() || success()
|
|
157
|
+
uses: actions/github-script@v7
|
|
158
|
+
with:
|
|
159
|
+
script: |
|
|
160
|
+
const fs = require('fs');
|
|
161
|
+
if (fs.existsSync('pr-report.md')) {
|
|
162
|
+
const body = fs.readFileSync('pr-report.md', 'utf8');
|
|
163
|
+
github.rest.issues.createComment({
|
|
164
|
+
issue_number: context.issue.number,
|
|
165
|
+
owner: context.repo.owner,
|
|
166
|
+
repo: context.repo.repo,
|
|
167
|
+
body: body
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## 🧪 Testing
|
|
175
|
+
|
|
176
|
+
ConcurrencyGuard is thoroughly tested with unit tests and runtime stress verifications:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
# Run test suite
|
|
180
|
+
pytest -v
|
|
181
|
+
|
|
182
|
+
# Run lint and type checking
|
|
183
|
+
ruff check .
|
|
184
|
+
mypy src/
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 📄 License
|
|
190
|
+
|
|
191
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# ConcurrencyGuard (`cguard`)
|
|
2
|
+
|
|
3
|
+
[](https://github.com/AyushkhatiDev/concurrency-guard/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
**Deterministic AST + Runtime Concurrency & Idempotency Verifier for Python FastAPI, SQLAlchemy, and Celery Backends.**
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## ⚡ Why ConcurrencyGuard?
|
|
12
|
+
|
|
13
|
+
Modern async Python services frequently suffer from silent concurrency bugs, double-spend race conditions, lost updates, and duplicate background tasks. Traditional linters only check syntax and type hints, while external LLM tools hallucinate or miss non-local control flow races.
|
|
14
|
+
|
|
15
|
+
**ConcurrencyGuard (`cguard`)** solves this with a **100% deterministic pipeline**:
|
|
16
|
+
1. **Pure AST Static Scanning**: Catches race patterns, missing idempotency keys, N+1 queries, uniqueness races, and unsafe Celery retry tasks.
|
|
17
|
+
2. **Runtime `asyncio.Barrier` Exploit Engine**: Proves race conditions empirically by synchronizing concurrent coroutines to hit endpoints at the exact same microsecond.
|
|
18
|
+
3. **Deterministic `LibCST` Auto-Patcher**: Safely rewrites code to apply database row locks (`.with_for_update()`), inject FastAPI `Idempotency-Key` headers, and wrap transactions in `IntegrityError` guards.
|
|
19
|
+
4. **Automated GitHub PR Reporter**: Emits rich Markdown comments with exploit proofs and diff previews for pull requests.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 📋 Rule Catalog
|
|
24
|
+
|
|
25
|
+
| Rule ID | Name | Target / Trigger Condition | Automated Fix |
|
|
26
|
+
|:---|:---|:---|:---|
|
|
27
|
+
| **`CG-001`** | **Unlocked Read-Modify-Write Race** | Async route reads database state with `select()`, mutates an attribute, and calls `commit()` without `.with_for_update()` locking. | Appends `.with_for_update()` row-level locks via LibCST. |
|
|
28
|
+
| **`CG-002`** | **Missing Idempotency Guard** | Async `@app.post` or `@router.post` route mutates state without an `idempotency_key` parameter. | Injects `idempotency_key: str = Header(..., alias="Idempotency-Key")` into route parameters. |
|
|
29
|
+
| **`CG-003`** | **Async Loop N+1 Query Explode** | `db.execute(...)` or `session.execute(...)` call located directly inside a `for` or `async for` loop body. | Flags query explosion; suggests batching or eager loading (`joinedload`/`selectinload`). |
|
|
30
|
+
| **`CG-004`** | **Check-Then-Act Uniqueness Race** | Async route checks existence with `select()`, calls `add()`, and commits without catching `sqlalchemy.exc.IntegrityError`. | Wraps commit statements in `try...except IntegrityError` with session rollback. |
|
|
31
|
+
| **`CG-005`** | **Non-Idempotent Celery Retry Task** | Celery task (`@app.task`, `@shared_task`) with `autoretry_for` or `max_retries` executes mutations (`.add()`, `.commit()`, `.post()`) without `acks_late=True` or an idempotency lock. | Recommends `acks_late=True` and distributed deduplication lock before side-effects. |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 🚀 Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Clone and install in virtual environment
|
|
39
|
+
git clone https://github.com/AyushkhatiDev/concurrency-guard.git
|
|
40
|
+
cd concurrency-guard
|
|
41
|
+
python -m venv .venv
|
|
42
|
+
source .venv/bin/activate
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
To install with development and testing dependencies:
|
|
47
|
+
```bash
|
|
48
|
+
pip install -e ".[dev]"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 🛠️ CLI Usage
|
|
54
|
+
|
|
55
|
+
### 1. `cguard scan` — Static AST Analysis
|
|
56
|
+
Scan files or entire directories for concurrency vulnerabilities:
|
|
57
|
+
```bash
|
|
58
|
+
# Scan a single file
|
|
59
|
+
cguard scan examples/vulnerable_wallet.py
|
|
60
|
+
|
|
61
|
+
# Scan an entire codebase
|
|
62
|
+
cguard scan src/
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 2. `cguard verify` — Runtime `asyncio.Barrier` Verification
|
|
66
|
+
Simulate high-concurrency traffic using synchronized barrier workers to prove whether an invariant holds or breaks under load:
|
|
67
|
+
```bash
|
|
68
|
+
cguard verify examples/vulnerable_wallet.py --workers 20
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Sample exploit proof output:
|
|
72
|
+
```
|
|
73
|
+
❌ CONCURRENCY INVARIANT VIOLATED (RACE PROVEN)
|
|
74
|
+
Target File: examples/vulnerable_wallet.py
|
|
75
|
+
Target Endpoint: POST /wallet/withdraw
|
|
76
|
+
Concurrent Barrier Workers: 20
|
|
77
|
+
Initial Wallet Balance: $50
|
|
78
|
+
Successful Withdrawals ($50 each): 20
|
|
79
|
+
Final Resulting Balance: $-950
|
|
80
|
+
|
|
81
|
+
INVARIANT VIOLATION PROVEN:
|
|
82
|
+
Multiple concurrent withdrawals succeeded against a $50 balance due to an unlocked
|
|
83
|
+
Read-Modify-Write race condition (CG-001).
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 3. `cguard fix` — Deterministic LibCST Auto-Patcher
|
|
87
|
+
Inspect and apply deterministic code patches:
|
|
88
|
+
```bash
|
|
89
|
+
# Preview auto-fixes (dry-run)
|
|
90
|
+
cguard fix examples/vulnerable_wallet.py
|
|
91
|
+
|
|
92
|
+
# Apply specific rule fix
|
|
93
|
+
cguard fix examples/vulnerable_wallet.py --rule CG-001 --write
|
|
94
|
+
|
|
95
|
+
# Apply all available auto-fixes directly to disk
|
|
96
|
+
cguard fix examples/vulnerable_wallet.py --write
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 4. `cguard report` — GitHub Pull Request Markdown Reporter
|
|
100
|
+
Generate comprehensive Markdown summaries for CI/CD:
|
|
101
|
+
```bash
|
|
102
|
+
# Print report to stdout
|
|
103
|
+
cguard report examples/vulnerable_wallet.py
|
|
104
|
+
|
|
105
|
+
# Write report to markdown file for GitHub Actions PR comment
|
|
106
|
+
cguard report examples/vulnerable_wallet.py --output pr_report.md
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 🤖 GitHub Action Integration
|
|
112
|
+
|
|
113
|
+
Add ConcurrencyGuard to your pull request workflows:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
name: Concurrency Guard CI
|
|
117
|
+
|
|
118
|
+
on:
|
|
119
|
+
pull_request:
|
|
120
|
+
branches: [main]
|
|
121
|
+
|
|
122
|
+
jobs:
|
|
123
|
+
concurrency-audit:
|
|
124
|
+
runs-on: ubuntu-latest
|
|
125
|
+
steps:
|
|
126
|
+
- uses: actions/checkout@v4
|
|
127
|
+
|
|
128
|
+
- name: Run ConcurrencyGuard Action
|
|
129
|
+
uses: AyushkhatiDev/concurrency-guard@main
|
|
130
|
+
with:
|
|
131
|
+
path: "app"
|
|
132
|
+
report-file: "app/main.py"
|
|
133
|
+
output: "pr-report.md"
|
|
134
|
+
|
|
135
|
+
- name: Post PR Comment
|
|
136
|
+
if: failure() || success()
|
|
137
|
+
uses: actions/github-script@v7
|
|
138
|
+
with:
|
|
139
|
+
script: |
|
|
140
|
+
const fs = require('fs');
|
|
141
|
+
if (fs.existsSync('pr-report.md')) {
|
|
142
|
+
const body = fs.readFileSync('pr-report.md', 'utf8');
|
|
143
|
+
github.rest.issues.createComment({
|
|
144
|
+
issue_number: context.issue.number,
|
|
145
|
+
owner: context.repo.owner,
|
|
146
|
+
repo: context.repo.repo,
|
|
147
|
+
body: body
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 🧪 Testing
|
|
155
|
+
|
|
156
|
+
ConcurrencyGuard is thoroughly tested with unit tests and runtime stress verifications:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Run test suite
|
|
160
|
+
pytest -v
|
|
161
|
+
|
|
162
|
+
# Run lint and type checking
|
|
163
|
+
ruff check .
|
|
164
|
+
mypy src/
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 📄 License
|
|
170
|
+
|
|
171
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: "ConcurrencyGuard Action"
|
|
2
|
+
description: "Deterministic AST scanner and runtime race condition verifier for Python FastAPI & SQLAlchemy"
|
|
3
|
+
author: "AyushkhatiDev"
|
|
4
|
+
branding:
|
|
5
|
+
icon: "shield"
|
|
6
|
+
color: "blue"
|
|
7
|
+
|
|
8
|
+
inputs:
|
|
9
|
+
path:
|
|
10
|
+
description: "Path to file or directory to scan"
|
|
11
|
+
required: false
|
|
12
|
+
default: "."
|
|
13
|
+
report-file:
|
|
14
|
+
description: "File to generate GitHub PR Markdown report for"
|
|
15
|
+
required: false
|
|
16
|
+
default: ""
|
|
17
|
+
output:
|
|
18
|
+
description: "Output path for the Markdown report"
|
|
19
|
+
required: false
|
|
20
|
+
default: "concurrency-guard-report.md"
|
|
21
|
+
|
|
22
|
+
runs:
|
|
23
|
+
using: "composite"
|
|
24
|
+
steps:
|
|
25
|
+
- name: Set up Python
|
|
26
|
+
uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
|
|
30
|
+
- name: Install ConcurrencyGuard
|
|
31
|
+
shell: bash
|
|
32
|
+
run: |
|
|
33
|
+
pip install .
|
|
34
|
+
|
|
35
|
+
- name: Run ConcurrencyGuard Scan
|
|
36
|
+
shell: bash
|
|
37
|
+
run: |
|
|
38
|
+
cguard scan ${{ inputs.path }}
|
|
39
|
+
|
|
40
|
+
- name: Run ConcurrencyGuard Report
|
|
41
|
+
if: ${{ inputs.report-file != '' }}
|
|
42
|
+
shell: bash
|
|
43
|
+
run: |
|
|
44
|
+
cguard report ${{ inputs.report-file }} --output ${{ inputs.output }} || true
|
|
45
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""FastAPI checkout and payment router demonstrating real-world concurrency issues."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from sqlalchemy import select
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
12
|
+
|
|
13
|
+
from examples.real_world_ecommerce.models import Coupon, Order, OrderItem, WebhookEvent
|
|
14
|
+
|
|
15
|
+
DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
|
16
|
+
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
17
|
+
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
21
|
+
async with async_session() as session:
|
|
22
|
+
yield session
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RedeemCouponRequest(BaseModel):
|
|
29
|
+
code: str
|
|
30
|
+
user_id: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.post("/coupons/redeem")
|
|
34
|
+
async def redeem_coupon(
|
|
35
|
+
request: RedeemCouponRequest,
|
|
36
|
+
db: AsyncSession = Depends(get_db),
|
|
37
|
+
) -> dict[str, str | int]:
|
|
38
|
+
"""Redeem coupon using SQLAlchemy 2.0 await db.scalar without row-locking or idempotency."""
|
|
39
|
+
stmt = select(Coupon).where(Coupon.code == request.code)
|
|
40
|
+
coupon = await db.scalar(stmt)
|
|
41
|
+
|
|
42
|
+
if coupon is None:
|
|
43
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Coupon not found")
|
|
44
|
+
|
|
45
|
+
if coupon.remaining_uses > 0:
|
|
46
|
+
# Race condition: Unlocked Read-Modify-Write via db.scalar (CG-001)
|
|
47
|
+
# Missing Idempotency-Key guard (CG-002)
|
|
48
|
+
coupon.remaining_uses -= 1
|
|
49
|
+
await db.commit()
|
|
50
|
+
return {"status": "redeemed", "remaining": coupon.remaining_uses}
|
|
51
|
+
|
|
52
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Coupon exhausted")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/orders/with-items")
|
|
56
|
+
async def get_orders_with_items(
|
|
57
|
+
db: AsyncSession = Depends(get_db),
|
|
58
|
+
) -> list[dict[str, Any]]:
|
|
59
|
+
"""Fetch orders and query items in a loop: Async Loop N+1 Query Explode (CG-003)."""
|
|
60
|
+
result = await db.execute(select(Order))
|
|
61
|
+
orders = result.scalars().all()
|
|
62
|
+
orders_data: list[dict[str, Any]] = []
|
|
63
|
+
|
|
64
|
+
for order in orders:
|
|
65
|
+
stmt = select(OrderItem).where(OrderItem.order_id == order.id)
|
|
66
|
+
# N+1 query: executes inside loop
|
|
67
|
+
items_result = await db.scalars(stmt)
|
|
68
|
+
items = items_result.all()
|
|
69
|
+
orders_data.append({"order_id": order.id, "items": len(items)})
|
|
70
|
+
|
|
71
|
+
return orders_data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class StripeWebhookPayload(BaseModel):
|
|
75
|
+
event_id: str
|
|
76
|
+
event_type: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.post("/webhooks/stripe")
|
|
80
|
+
async def handle_stripe_webhook(
|
|
81
|
+
payload: StripeWebhookPayload,
|
|
82
|
+
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
|
83
|
+
db: AsyncSession = Depends(get_db),
|
|
84
|
+
) -> dict[str, str]:
|
|
85
|
+
"""Check-then-act uniqueness race on webhook event ingestion (CG-004)."""
|
|
86
|
+
stmt = select(WebhookEvent).where(WebhookEvent.event_id == payload.event_id)
|
|
87
|
+
existing = await db.scalar(stmt)
|
|
88
|
+
if existing is not None:
|
|
89
|
+
return {"status": "already_processed"}
|
|
90
|
+
|
|
91
|
+
event = WebhookEvent(
|
|
92
|
+
event_id=payload.event_id,
|
|
93
|
+
event_type=payload.event_type,
|
|
94
|
+
processed=True,
|
|
95
|
+
)
|
|
96
|
+
db.add(event)
|
|
97
|
+
# Unprotected commit: violates uniqueness under race conditions
|
|
98
|
+
await db.commit()
|
|
99
|
+
return {"status": "processed"}
|
|
100
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""SQLAlchemy 2.0 declarative models for e-commerce benchmark."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import Float, Integer, String
|
|
6
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Base(DeclarativeBase):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Coupon(Base):
|
|
14
|
+
__tablename__ = "coupons"
|
|
15
|
+
|
|
16
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
17
|
+
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
|
18
|
+
remaining_uses: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
|
19
|
+
discount_percent: Mapped[float] = mapped_column(Float, default=10.0, nullable=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Order(Base):
|
|
23
|
+
__tablename__ = "orders"
|
|
24
|
+
|
|
25
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
26
|
+
user_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
27
|
+
total_amount: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
|
28
|
+
status: Mapped[str] = mapped_column(String(50), default="PENDING", nullable=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OrderItem(Base):
|
|
32
|
+
__tablename__ = "order_items"
|
|
33
|
+
|
|
34
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
35
|
+
order_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
36
|
+
product_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
37
|
+
price: Mapped[float] = mapped_column(Float, nullable=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WebhookEvent(Base):
|
|
41
|
+
__tablename__ = "webhook_events"
|
|
42
|
+
|
|
43
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
44
|
+
event_id: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
45
|
+
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
46
|
+
processed: Mapped[bool] = mapped_column(default=False, nullable=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Product(Base):
|
|
50
|
+
__tablename__ = "products"
|
|
51
|
+
|
|
52
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
53
|
+
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
54
|
+
price: Mapped[float] = mapped_column(Float, nullable=False)
|
|
55
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Celery background tasks demonstrating retry non-idempotency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from celery import shared_task
|
|
6
|
+
from sqlalchemy import create_engine
|
|
7
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
8
|
+
|
|
9
|
+
from examples.real_world_ecommerce.models import Order
|
|
10
|
+
|
|
11
|
+
sync_engine = create_engine("sqlite:///:memory:")
|
|
12
|
+
SyncSessionLocal = sessionmaker(bind=sync_engine, class_=Session)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@shared_task(autoretry_for=(Exception,), max_retries=5)
|
|
16
|
+
def process_refund(order_id: int) -> dict[str, str]:
|
|
17
|
+
"""Process order refund with retries but without idempotency protection (CG-005)."""
|
|
18
|
+
session = SyncSessionLocal()
|
|
19
|
+
try:
|
|
20
|
+
order = session.get(Order, order_id)
|
|
21
|
+
if order is not None:
|
|
22
|
+
order.status = "REFUNDED"
|
|
23
|
+
session.commit()
|
|
24
|
+
finally:
|
|
25
|
+
session.close()
|
|
26
|
+
return {"status": "refunded"}
|
|
27
|
+
|