Skip to content
Draft
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
241 changes: 241 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# OVERVIEW.md

This file provides guidance AI coding tools when working with code in this repository.

## Project Overview

Bucketeer iOS SDK is a client-side SDK for iOS and tvOS that provides feature flag management with advanced capabilities like staged rollouts, user segmentation, and event tracking. The SDK is designed for mobile environments with intelligent caching, batch event processing, and adaptive retry logic.

**Platforms**: iOS 12.0+, tvOS 12.0+
**Language**: Swift 5.3+
**Xcode**: 13.1+
**Project Generation**: XcodeGen (project.yml)
**Library Management**: Mint

## Essential Commands

### Project Setup

```bash
# Install Mint (requires Homebrew)
make install-mint

# Install dependencies
make bootstrap-mint

# Setup environment config (API_ENDPOINT and API_KEY for E2E tests and Example app)
make environment-setup

# Generate Xcode project file (required after updating project.yml)
make generate-project-file
```

### Build & Test

```bash
# Build the SDK
make build

# Build example app
make build-example

# Build for testing with E2E credentials
make build-for-testing E2E_API_ENDPOINT=<YOUR_API_ENDPOINT> E2E_API_KEY=<YOUR_API_KEY>

# Run unit tests only (excludes E2E tests)
make test-without-building

# Run E2E tests only
make e2e-without-building

# Run all tests including E2E
make all-test-without-building

# Run linter
make run-lint

# Clean build artifacts
make clean
```

### Testing Individual Components

To run specific test classes or methods, use xcodebuild directly:

```bash
# Run a specific test class
xcodebuild test -project Bucketeer.xcodeproj -scheme Bucketeer \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:BucketeerTests/BucketeerTests

# Run a specific test method
xcodebuild test -project Bucketeer.xcodeproj -scheme Bucketeer \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:BucketeerTests/BucketeerTests/testMethodName
```

## Code Architecture

### High-Level Structure

The SDK uses a layered architecture with dependency injection:

```
BKTClient (Public API/Facade)
Component (DI Container)
├── EvaluationInteractor ← Manages feature flag lifecycle
├── EventInteractor ← Tracks events (evaluation, goal, metrics)
├── TaskScheduler ← Foreground/background task coordination
├── Storage Layer
│ ├── EvaluationStorage (3-tier: Memory → SQLite → UserDefaults)
│ └── EventSQLDao (SQLite event queue)
└── ApiClient ← Remote API communication with retry logic
```

### Key Components

**BKTClient** (`Bucketeer/Sources/Public/BKTClient.swift`)
- Singleton entry point initialized with `BKTConfig` and `BKTUser`
- Variation API: `boolVariation()`, `intVariation()`, `doubleVariation()`, `stringVariation()`, `objectVariation()`
- User management: `updateUserAttributes()`, `currentUser()`
- Event tracking: `track(goalId:value:)`
- Lifecycle: `fetchEvaluations()`, `flush()`, `addEvaluationUpdateListener()`

**EvaluationInteractor** (`Bucketeer/Sources/Internal/Evaluation/`)
- Fetches evaluations from remote API
- Manages 3-tier caching: Memory cache (fast) → SQLite (persistent) → UserDefaults (metadata)
- Tracks evaluation state changes (evaluatedAt, userAttributesUpdated)
- Notifies listeners on updates

**EventInteractor** (`Bucketeer/Sources/Internal/Event/`)
- Tracks three event types:
- Evaluation events: when a variation is used
- Goal events: user conversions
- Metrics events: API latency, errors, response sizes
- Batches events in SQLite queue
- Flushes based on threshold (queue size) or time interval
- Deduplicates metrics events by unique key

**TaskScheduler** (`Bucketeer/Sources/Internal/Scheduler/`)
- **Foreground tasks** (always active when app is visible):
- `EvaluationForegroundTask`: Polls evaluations at configured interval with retry logic
- `EventForegroundTask`: Flushes events periodically or on threshold
- **Background tasks** (iOS 13.0+ only):
- `EvaluationBackgroundTask`: Periodic updates in background
- `EventBackgroundTask`: Deferred event flushing

**ApiClient** (`Bucketeer/Sources/Internal/Remote/`)
- Two main endpoints: `getEvaluations()`, `registerEvents()`
- Automatic retry with exponential backoff (1s, 2s, 4s) for HTTP 499
- Tracks request latency and response size
- Configurable timeout (default: 30s)

**SQLite Layer** (`Bucketeer/Sources/Internal/Database/SQLite/`)
- Custom ORM-like abstraction with type-safe columns
- Two tables: `evaluations`, `events`
- Database version 2 with migration support
- Storage location: Library dir (iOS), Cache dir (tvOS)

### Concurrency Model

- **SDK Queue**: Serial dispatch queue for all operations except specific read paths
- API calls, database writes, event tracking, evaluation fetches
- Prevents race conditions
- **Main Thread**: Listener callbacks dispatched here
- **UI Thread Safe Reads**: `getBy(featureId)` reads from memory cache without locks
- **Memory Cache**: Uses internal concurrent queue for thread safety
- **Semaphore-based Sync**: API client blocks SDK queue until response arrives
- **User Attribute Updates**: Protected by `NSLock` with version counter

### Data Flow

**Evaluation Request Flow:**
1. User calls `boolVariation(featureId, defaultValue)`
2. Check memory cache first (fast path)
3. If found: Return cached evaluation + track evaluation event
4. If not found: Return default value + track default event
5. Event queued in SQLite by EventInteractor
6. Poller triggers send when threshold/interval reached
7. API batches events, server responds with status per event
8. SDK deletes successfully sent events

**Evaluation Refresh Flow:**
1. `EvaluationForegroundTask` calls `fetch()` at polling interval
2. ApiClient sends `getEvaluations` with current evaluation ID, user attributes state
3. Server responds with new evaluations, archived feature IDs, force update flag
4. Storage updates: full replace if `forceUpdate=true`, merge+delete archived otherwise
5. Notifies evaluation listeners on main thread
6. Tracks metrics event with response latency/size

## Development Patterns

### Testing

- Unit tests use Mock objects (see `BucketeerTests/Mock/`)
- E2E tests require `E2E_API_ENDPOINT` and `E2E_API_KEY` environment variables
- Tests are separated: unit tests skip E2E tests by default
- Mock implementations: `MockApiClient`, `MockClock`, `MockIdGenerator`, `MockDevice`, etc.

### Commit Messages

Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/):

```
<type>[(optional scope)]: <description>

[optional body]

[optional footer(s)]
```

**Types**: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`

Examples:
- `feat(event): implement event flush worker`
- `fix(evaluation): handle race condition in cache update`
- `test: add E2E tests for forced evaluation updates`

### XcodeGen Project Management

The Xcode project file is generated from `project.yml`. When modifying project structure:

1. Edit `project.yml` (targets, settings, dependencies)
2. Run `make generate-project-file`
3. Never manually edit `Bucketeer.xcodeproj` as changes will be overwritten

### Code Locations

- Public API: `Bucketeer/Sources/Public/`
- Internal implementation: `Bucketeer/Sources/Internal/`
- Tests: `BucketeerTests/`
- Example apps: `Example/`, `ExampleSwiftUI/`, `ExampleTVOS/`
- Project config: `project.yml`, `Makefile`
- Build scripts: `hack/` directory

### Important Configuration

Key defaults in `Constant.swift`:
- `DEFAULT_FLUSH_INTERVAL_MILLIS`: ~60 seconds
- `DEFAULT_MAX_QUEUE_SIZE`: 100 events before flush
- `DEFAULT_POLLING_INTERVAL_MILLIS`: ~5 minutes
- `MINIMUM_POLLING_INTERVAL_MILLIS`: 1 minute floor
- `RETRY_POLLING_INTERVAL`: Fast retry during failures

### Dependency Injection

The SDK uses `Component` protocol for DI:
- `ComponentImpl` assembles all dependencies
- `DataModule` provides shared resources (config, storage, API client, etc.)
- All dependencies injected through protocol interfaces for testability

### Thread Safety Considerations

When modifying code:
- Database operations must run on SDK queue
- Memory cache reads can be lock-free (accepts momentary staleness)
- Listener notifications must dispatch to main thread
- User attribute updates need lock protection
- Use semaphores for blocking operations on SDK queue
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ import BackgroundTasks
final class EvaluationBackgroundTask {
private weak var component: Component?
private let queue: DispatchQueue
private var isTaskEnabled: Bool

init(component: Component, queue: DispatchQueue) {
init(component: Component, queue: DispatchQueue, enabled: Bool = true) {
self.component = component
self.queue = queue
self.isTaskEnabled = enabled
}

func enable() {
isTaskEnabled = true
}

func scheduleAppRefresh() {
Expand All @@ -28,6 +34,11 @@ final class EvaluationBackgroundTask {
}

private func handleAppRefresh(_ task: BGTask) {
guard isTaskEnabled else {
component?.config.logger?.debug(message: "[EvaluationBackgroundTask] Task not enabled, skipping")
task.setTaskCompleted(success: true)
return
}
component?.config.logger?.debug(message: "[EvaluationBackgroundTask] handleAppRefresh")
// Schedule a new refresh task.
scheduleAppRefresh()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,23 @@ final class EvaluationForegroundTask: ScheduledTask {
private var maxRetryCount: Int

private var retryCount: Int = 0
private var isTaskEnabled: Bool

init(component: Component,
queue: DispatchQueue,
retryPollingInterval: Int64 = Constant.RETRY_POLLING_INTERVAL,
maxRetryCount: Int = Constant.MAX_RETRY_COUNT) {
maxRetryCount: Int = Constant.MAX_RETRY_COUNT,
enabled: Bool = true) {

self.component = component
self.queue = queue
self.retryPollingInterval = retryPollingInterval
self.maxRetryCount = maxRetryCount
self.isTaskEnabled = enabled
}

func enable() {
isTaskEnabled = true
}

private func reschedule(interval: Int64) {
Expand Down Expand Up @@ -45,6 +52,9 @@ final class EvaluationForegroundTask: ScheduledTask {
}

private func fetchEvaluations() {
guard isTaskEnabled else {
return
}
let eventInteractor = component.eventInteractor
let retryCount = self.retryCount
let maxRetryCount = self.maxRetryCount
Expand Down
16 changes: 15 additions & 1 deletion Bucketeer/Sources/Internal/Scheduler/TaskScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ final class TaskScheduler {

private lazy var foregroundSchedulers: [ScheduledTask] = [
EvaluationForegroundTask(component: component, queue: dispatchQueue),
EvaluationForegroundTask(component: component, queue: dispatchQueue, enabled: false),
EventForegroundTask(component: component, queue: dispatchQueue)
]

Expand All @@ -14,7 +15,7 @@ final class TaskScheduler {
return []
}
let tasks : [BackgroundTask] = [
EvaluationBackgroundTask(component: component, queue: dispatchQueue),
EvaluationBackgroundTask(component: component, queue: dispatchQueue, enabled: false),
EventBackgroundTask(component: component, queue: dispatchQueue)
]
// Register background task handler when init
Expand Down Expand Up @@ -89,4 +90,17 @@ final class TaskScheduler {
foregroundSchedulers.removeAll()
backgroundSchedulers.removeAll()
}

func enableEvaluationTask() {
foregroundSchedulers
.compactMap { $0 as? EvaluationForegroundTask }
.first?
.enable()
if #available(iOS 13.0, tvOS 13.0, *) {
backgroundSchedulers
.compactMap { $0 as? EvaluationBackgroundTask }
.first?
.enable()
}
}
}
9 changes: 7 additions & 2 deletions Bucketeer/Sources/Public/BKTClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public class BKTClient {
)
}

fileprivate func scheduleTasks() {
func scheduleTasks() {
self.taskScheduler = TaskScheduler(component: component, dispatchQueue: dispatchQueue)
}

Expand Down Expand Up @@ -111,7 +111,12 @@ extension BKTClient {
client.scheduleTasks()
client.execute { [weak client] in
client?.refreshCache()
client?.fetchEvaluations(timeoutMillis: timeoutMillis, completion: initializeCompletion)
client?.fetchEvaluations(timeoutMillis: timeoutMillis, completion: { error in
// Enable evaluation task after initial fetch completes (success or failure)
// This prevents the background poller from cancelling the initialization request
client?.taskScheduler?.enableEvaluationTask()
initializeCompletion(error)
})
}
BKTClient.default = client
} catch let error {
Expand Down
Loading
Loading