# Native Sync Implementation via Unasync

**Date:** 2026-02-02
**Status:** Approved
**Author:** Brainstorming session

## Problem

The current `SyncRateLimiter` wraps the async `RateLimiter` using `run_until_complete()`. This fails in environments with their own async runtime (Gevent, Eventlet) because:

- Gevent/Eventlet use monkey-patching for cooperative concurrency
- asyncio's event loop model fundamentally conflicts with greenlet-based concurrency
- `run_until_complete()` blocks the entire greenlet pool or fails outright

## Solution

Use the **unasync** pattern to generate native sync code from async source:

1. Async code (aioboto3) is the source of truth
2. At build time, AST transformation generates sync code (boto3)
3. Generated sync code is committed to git
4. Both versions are optimal for their runtime — no wrapper overhead

This pattern is battle-tested by `httpx`, `httpcore`, and `urllib3`.

## Transformation Scope

### Source Files

| Source (async) | Generated (sync) |
|----------------|------------------|
| `repository.py` | `sync_repository.py` |
| `limiter.py` | `sync_limiter.py` |
| `lease.py` | `sync_lease.py` |
| `config_cache.py` | `sync_config_cache.py` |
| `infra/stack_manager.py` | `infra/sync_stack_manager.py` |
| `infra/discovery.py` | `infra/sync_discovery.py` |

### Test Files

| Source (async) | Generated (sync) |
|----------------|------------------|
| `tests/unit/test_limiter.py` | `tests/unit/test_sync_limiter.py` |
| `tests/unit/test_repository.py` | `tests/unit/test_sync_repository.py` |
| `tests/unit/test_lease.py` | `tests/unit/test_sync_lease.py` |
| `tests/integration/test_repository.py` | `tests/integration/test_sync_repository.py` |
| `tests/e2e/test_localstack.py` | `tests/e2e/test_sync_localstack.py` |

### Shared Files (Not Transformed)

- `models.py` — Pure dataclasses, no I/O
- `schema.py` — Key builders, pure functions
- `bucket.py` — Token bucket math, pure functions
- `exceptions.py` — Exception classes
- `naming.py`, `version.py` — Pure utilities

### Deletions

- `SyncRateLimiter` class in `limiter.py` (replaced by generated)
- `SyncLease` class in `lease.py` (replaced by generated)

## Transformation Rules

### AST Transformations

| Async Pattern | Sync Replacement |
|---------------|------------------|
| `async def` | `def` |
| `await expr` | `expr` |
| `async with` | `with` |
| `async for` | `for` |
| `AsyncIterator` | `Iterator` |
| `AsyncContextManager` | `ContextManager` |
| `__aenter__` | `__enter__` |
| `__aexit__` | `__exit__` |
| `aioboto3` | `boto3` |
| `@pytest.mark.asyncio` | (removed) |

### Class Renames

| Async Class | Sync Class |
|-------------|------------|
| `RateLimiter` | `SyncRateLimiter` |
| `Repository` | `SyncRepository` |
| `Lease` | `SyncLease` |
| `ConfigCache` | `SyncConfigCache` |
| `StackManager` | `SyncStackManager` |
| `InfrastructureDiscovery` | `SyncInfrastructureDiscovery` |

### Import Rewrites

| Async Import | Sync Import |
|--------------|-------------|
| `from .repository import Repository` | `from .sync_repository import SyncRepository` |
| `from .lease import Lease` | `from .sync_lease import SyncLease` |
| `from .config_cache import ConfigCache` | `from .sync_config_cache import SyncConfigCache` |
| `import aioboto3` | `import boto3` |

### Special Cases

- `asyncio.wait_for(coro, timeout)` → inline call or boto3 timeout parameter
- `asyncio.new_event_loop()` → deleted
- `asyncio.run_until_complete()` → deleted

## File Structure

```
scripts/
└── generate_sync.py      # AST transformer script

hatch_build.py            # Hatch build hook

src/zae_limiter/
├── repository.py         # Async (source of truth)
├── sync_repository.py    # Generated, committed
├── limiter.py            # Async RateLimiter
├── sync_limiter.py       # Generated, committed
├── lease.py              # Async Lease
├── sync_lease.py         # Generated, committed
├── config_cache.py       # Async ConfigCache
├── sync_config_cache.py  # Generated, committed
└── infra/
    ├── stack_manager.py
    ├── sync_stack_manager.py    # Generated
    ├── discovery.py
    └── sync_discovery.py        # Generated
```

## Build Integration

### pyproject.toml

```toml
[tool.hatch.build.hooks.custom]
path = "hatch_build.py"

[tool.hatch.envs.default.scripts]
generate-sync = "python scripts/generate_sync.py"
```

### Developer Workflow

```bash
# After modifying async code
hatch run generate-sync

# Run tests (covers both async and sync)
hatch run test

# Pre-commit hook verifies sync is up-to-date
git commit
```

### CI Verification

```yaml
- name: Verify generated sync code
  run: |
    hatch run generate-sync
    git diff --exit-code src/zae_limiter/sync_*.py
```

### Generated File Header

```python
# AUTO-GENERATED by scripts/generate_sync.py
# DO NOT EDIT - changes will be overwritten
# Source: {source_file}
```

## Package Exports

```python
# __init__.py

# Async (source)
from .limiter import RateLimiter
from .repository import Repository
from .lease import Lease
from .config_cache import ConfigCache

# Sync (generated)
from .sync_limiter import SyncRateLimiter
from .sync_repository import SyncRepository
from .sync_lease import SyncLease
from .sync_config_cache import SyncConfigCache
```

User-facing usage unchanged:

```python
# Async users
from zae_limiter import RateLimiter, Repository

# Sync users
from zae_limiter import SyncRateLimiter, SyncRepository
```

## Implementation Order

### Phase 1: Transformation Infrastructure
1. Create `scripts/generate_sync.py` with AST transformer
2. Add `hatch_build.py` build hook
3. Update `pyproject.toml` with scripts and hook config
4. Add pre-commit hook AND CI job to verify generated code is up-to-date

### Phase 2: Source Preparation
1. Remove `SyncRateLimiter` class from `limiter.py`
2. Remove `SyncLease` class from `lease.py`
3. Ensure async code has no sync-specific workarounds

### Phase 3: Generate Sync Code
1. Run `hatch run generate-sync`
2. Verify generated files compile and import
3. Commit generated files

### Phase 4: Update Exports
1. Update `__init__.py` to import from generated modules
2. Verify public API unchanged

### Phase 5: Tests
1. Add test files to transformation scope
2. Generate sync tests
3. Run full test suite (async + sync)
4. Add Gevent/Eventlet compatibility tests

### Phase 6: Documentation
1. API docs pick up both versions automatically
2. Update CLAUDE.md with new architecture
3. Add note in README about sync vs async
4. Create ADR documenting the unasync architecture decision

## Benefits

- **Single source of truth** — Maintain async code only
- **Native performance** — No event loop overhead in sync contexts
- **Gevent/Eventlet compatible** — boto3 blocking I/O becomes cooperative via monkey-patching
- **Correct types** — Both versions have proper type hints
- **Transparent** — Generated code is committed, readable, debuggable
