Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions docs/rakuten/CONCURRENCY_LIMITS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Concurrency Limitations for /webhook-parallel

## Executive Summary

**Recommended Configuration:** `max_concurrent_reviews = 2-3`

**Primary Bottleneck:** Bitbucket Server API rate limits (token bucket: 15 tokens max, 3 tokens/sec refill)

## Resource Constraints

### 1. Hardware (Rakuten CaaS Deployment)
- **CPU:** 500m (0.5 cores)
- **Memory:** 1Gi
- **Impact:** Can support 5-8 concurrent I/O-bound reviews, not the limiting factor

### 2. Bitbucket Server API Rate Limit (PRIMARY BOTTLENECK)
- **Token bucket:** 15 tokens maximum
- **Refill rate:** 3 tokens/second (180 tokens/minute)
- **Cost per review:** ~9-13 API calls = 9-13 tokens
- Phase 1: Get PR metadata (1) + Get PR diff (1) = 2-3 tokens
- Phase 2: AI processing (0 tokens)
- Phase 3: Post comments (7-10 tokens)
- /describe: 1 token
- /review: 1 token
- /improve: 1 main comment + 4-7 inline code suggestions = 5-8 tokens

**Critical constraint:** API calls are bursty (most happen during Phase 3 comment posting). With 3 concurrent reviews finishing simultaneously:
- Required tokens: 3 × 10 = 30 tokens (average case)
- Available tokens: 15 (bucket) + 15 (refilled during 5s burst) = 30 tokens
- **Result:** At capacity limit, occasional rate limit errors expected (~2-5%)

**Note:** Bitbucket Server does not support persistent comments (`get_issue_comments()` not implemented). The `persistent_comment=true` setting has no effect and always creates new comments, which actually saves 2-3 API calls per review compared to other git providers.

### 3. Task Queue (No Hard Limit)
- **Implementation:** `asyncio.create_task()` with no queue size limit
- **Memory per waiting task:** ~5KB (task object + copied context)
- **Theoretical capacity:** ~200,000 tasks before memory exhaustion
- **Practical limit:** ~1,000 waiting tasks safe on 1Gi pod

## Concurrency Calculation

### Sustainable Throughput
```
Token refill rate: 180 tokens/minute
Tokens per review: 10
Theoretical max: 180 / 10 = 18 reviews/minute
```

### Safe Concurrent Reviews
```
Review phases (per PR):
- Phase 1 (0-5s): 2-3 API calls (get PR data, get diff)
- Phase 2 (5-25s): 0 API calls (AI processing - LLM calls, not Bitbucket API)
- Phase 3 (25-30s): 7-10 API calls (BURSTY - all comment posting)
- /describe: 1 call
- /review: 1 call
- /improve: 5-8 calls (1 main + 4-7 inline code suggestions)

Total per PR: 9-13 API calls (average: 10 calls)

Safe concurrent (avoiding burst collisions): 2-3 reviews
Risky concurrent (may hit rate limits): 4-5 reviews
```


### Wait Time Examples
| Concurrent | Burst Load | Wait Time for 10th PR | Rate Limit Risk |
|------------|------------|----------------------|-----------------|
| 2 | 10 PRs | ~150s (2.5 min) | Low (<1%) |
| 3 | 10 PRs | ~100s (1.7 min) | Medium (2-5%) |
| 5 | 10 PRs | ~60s (1 min) | High (10-20%) |

## Configuration Recommendations

### Conservative (Recommended)
```toml
[bitbucket_app]
enable_parallel_reviews = true
max_concurrent_reviews = 2
```
- **Throughput:** ~240 reviews/hour
- **Burst capacity:** 2 simultaneous PRs
- **Rate limit errors:** <1%

### Balanced
```toml
max_concurrent_reviews = 3
```
- **Throughput:** ~360 reviews/hour
- **Burst capacity:** 3 simultaneous PRs
- **Rate limit errors:** 2-5% during bursts

### Aggressive (Not Recommended)
```toml
max_concurrent_reviews = 5
```
- **Throughput:** ~10 reviews/minute (theoretical)
- **Rate limit errors:** 10-20%
- **Requires:** API call optimization or higher rate limits

## Optimization Options

### Immediate (No Code Changes)
1. Start with `max_concurrent_reviews = 2`
2. Monitor rate limit errors in logs
3. Gradually increase to 3 if error rate <2%

### Short-term (Code Changes)
1. **Reduce inline code suggestions:** `num_code_suggestions=4` → `2`
- Savings: ~2 API calls per review
- New capacity: 4 concurrent reviews

2. **Add queue depth monitoring:** Track wait times and queue depth

3. **Add task queue limit:** Prevent unbounded memory growth

## Monitoring Metrics

Key metrics to track:
- `bitbucket_api_rate_limit_errors`: HTTP 429 responses
- `review_wait_time_seconds`: Time waiting for semaphore slot
- `review_duration_seconds`: Total review time
- `queue_depth`: Number of waiting tasks
- `active_reviews`: Current processing reviews

Alert thresholds:
- Rate limit error rate >5%: Reduce concurrency
- Average wait time >60s: Increase concurrency (if rate limits allow)
- Queue depth >50: Possible traffic spike or stuck reviews

## References

- Configuration file: `pr_agent/settings/configuration.toml:315-316`
- Implementation: `pr_agent/servers/bitbucket_server_webhook.py:208-317`
- Gunicorn config: `pr_agent/servers/gunicorn_config.py:74-80`

## Last Updated

2025-12-08 - Initial analysis based on Rakuten CaaS deployment constraints
20 changes: 20 additions & 0 deletions docs/rakuten/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Rakuten Custom Documentation

This directory contains documentation specific to Rakuten's deployment and customizations.

## Contents

- **[CONCURRENCY_LIMITS.md](CONCURRENCY_LIMITS.md)** - Concurrency analysis for Rakuten CaaS deployment
- Hardware constraints and Bitbucket Server API rate limits
- Recommended configuration for `/webhook-parallel` endpoint

- **[architecture/](architecture/)** - Architecture documentation
- System architecture overview
- Parallel review implementation details
- Class and sequence diagrams

## Upstream Documentation

For official pr-agent documentation, see:
- Main docs site: https://qodo-merge-docs.qodo.ai/
- Local docs: `docs/docs/` directory (upstream content)
File renamed without changes.
42 changes: 23 additions & 19 deletions pr_agent/git_providers/bitbucket_server_git_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ def get_files(self):
diffstat = [change["path"]['toString'] for change in changes]
return diffstat

def get_file_from_git(self, path: str, commit_id: str, repo_path: str) -> str:
async def get_file_from_git(self, path: str, commit_id: str, repo_path: str) -> str:
"""
Read file content from local git repository using git show command.
This bypasses REST API calls and avoids rate limiting.
Expand All @@ -252,17 +252,19 @@ def get_file_from_git(self, path: str, commit_id: str, repo_path: str) -> str:
File content as string, empty string if file not found
"""
try:
result = subprocess.run(
from pr_agent.git_providers.git_provider import run_subprocess_with_output

result = await run_subprocess_with_output(
['git', 'show', f'{commit_id}:{path}'],
cwd=repo_path,
capture_output=True,
text=True,
check=False,
timeout=30
)
if result.returncode == 0:
return result.stdout
return result.stdout.decode('utf-8') if isinstance(result.stdout, bytes) else result.stdout
else:
get_logger().debug(f"File {path} not found at commit {commit_id}: {result.stderr}")
stderr_str = result.stderr.decode('utf-8') if isinstance(result.stderr, bytes) else result.stderr
get_logger().debug(f"File {path} not found at commit {commit_id}: {stderr_str}")
return ""
except subprocess.TimeoutExpired:
get_logger().warning(f"Timeout reading {path} from git at commit {commit_id}")
Expand All @@ -283,13 +285,13 @@ def get_best_common_ancestor(source_commits_list, destination_commits_list, guar

return guaranteed_common_ancestor

def get_diff_files(self) -> list[FilePatchInfo]:
async def get_diff_files(self) -> list[FilePatchInfo]:
if self.diff_files:
return self.diff_files

# Custom: Use git clone mode if enabled (bypasses rate limits)
if get_settings().get("bitbucket_server.use_git_clone_for_files", False):
return self._get_diff_files_with_git_clone()
return await self._get_diff_files_with_git_clone()

# Original code continues below (unchanged for easier upstream merges)
head_sha = self.pr.fromRef['latestCommit']
Expand Down Expand Up @@ -368,7 +370,7 @@ def get_diff_files(self) -> list[FilePatchInfo]:
self.diff_files = diff_files
return diff_files

def _get_diff_files_with_git_clone(self) -> list[FilePatchInfo]:
async def _get_diff_files_with_git_clone(self) -> list[FilePatchInfo]:
"""
Hybrid approach: REST API for metadata + Git clone for file contents.

Expand Down Expand Up @@ -398,7 +400,7 @@ def _get_diff_files_with_git_clone(self) -> list[FilePatchInfo]:
get_logger().info(f"Cloning repository: {repo_url}")

with TemporaryDirectory() as tmp_dir:
cloned_repo = self.clone(repo_url, tmp_dir, remove_dest_folder=False)
cloned_repo = await self.clone(repo_url, tmp_dir, remove_dest_folder=False)
if not cloned_repo:
get_logger().error("Git clone failed")
raise RuntimeError(f"Git clone mode enabled but clone failed for repo: {repo_url}")
Expand Down Expand Up @@ -459,12 +461,13 @@ def _get_diff_files_with_git_clone(self) -> list[FilePatchInfo]:
get_logger().info(f"Fetching commits for file access: {base_sha[:8]} and {head_sha[:8]}")
ssl_env = get_git_ssl_env()
try:
from pr_agent.git_providers.git_provider import run_subprocess_with_output

# Fetch both commits in one batch for efficiency
subprocess.run(
await run_subprocess_with_output(
['git', 'fetch', '--depth=1', 'origin', head_sha, base_sha],
cwd=repo_path,
env=ssl_env,
capture_output=True,
check=True,
timeout=60
)
Expand Down Expand Up @@ -496,21 +499,21 @@ def _get_diff_files_with_git_clone(self) -> list[FilePatchInfo]:
match change['type']:
case 'ADD':
edit_type = EDIT_TYPE.ADDED
new_file_content_str = self.get_file_from_git(file_path, head_sha, repo_path)
new_file_content_str = await self.get_file_from_git(file_path, head_sha, repo_path)
new_file_content_str = decode_if_bytes(new_file_content_str)
original_file_content_str = ""
case 'DELETE':
edit_type = EDIT_TYPE.DELETED
new_file_content_str = ""
original_file_content_str = self.get_file_from_git(file_path, base_sha, repo_path)
original_file_content_str = await self.get_file_from_git(file_path, base_sha, repo_path)
original_file_content_str = decode_if_bytes(original_file_content_str)
case 'RENAME':
edit_type = EDIT_TYPE.RENAMED
case _:
edit_type = EDIT_TYPE.MODIFIED
original_file_content_str = self.get_file_from_git(file_path, base_sha, repo_path)
original_file_content_str = await self.get_file_from_git(file_path, base_sha, repo_path)
original_file_content_str = decode_if_bytes(original_file_content_str)
new_file_content_str = self.get_file_from_git(file_path, head_sha, repo_path)
new_file_content_str = await self.get_file_from_git(file_path, head_sha, repo_path)
new_file_content_str = decode_if_bytes(new_file_content_str)

patch = load_large_diff(file_path, new_file_content_str, original_file_content_str, show_warning=False)
Expand Down Expand Up @@ -775,7 +778,9 @@ def _prepare_clone_url_with_token(self, repo_url_to_clone: str) -> str | None:

#Overriding the shell command, since for some reason usage of x-token-auth doesn't work, as mentioned here:
# https://stackoverflow.com/questions/56760396/cloning-bitbucket-server-repo-with-access-tokens
def _clone_inner(self, repo_url: str, dest_folder: str, operation_timeout_in_seconds: int=None):
async def _clone_inner(self, repo_url: str, dest_folder: str, operation_timeout_in_seconds: int=None):
from pr_agent.git_providers.git_provider import run_subprocess_async

bearer_token = self.bearer_token
if not bearer_token:
#Shouldn't happen since this is checked in _prepare_clone, therefore - throwing an exception.
Expand All @@ -786,5 +791,4 @@ def _clone_inner(self, repo_url: str, dest_folder: str, operation_timeout_in_sec

ssl_env = get_git_ssl_env()

subprocess.run(cli_args, env=ssl_env, check=True, # check=True will raise an exception if the command fails
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=operation_timeout_in_seconds)
await run_subprocess_async(cli_args, env=ssl_env, check=True, timeout=operation_timeout_in_seconds)
Loading