diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..e34c7849 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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= E2E_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/): + +``` +[(optional scope)]: + +[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 diff --git a/Bucketeer/Sources/Internal/Scheduler/EvaluationBackgroundTask.swift b/Bucketeer/Sources/Internal/Scheduler/EvaluationBackgroundTask.swift index d39ba4bc..8f589d2c 100644 --- a/Bucketeer/Sources/Internal/Scheduler/EvaluationBackgroundTask.swift +++ b/Bucketeer/Sources/Internal/Scheduler/EvaluationBackgroundTask.swift @@ -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() { @@ -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() diff --git a/Bucketeer/Sources/Internal/Scheduler/EvaluationForegroundTask.swift b/Bucketeer/Sources/Internal/Scheduler/EvaluationForegroundTask.swift index 10dc3d68..ce4f08e2 100644 --- a/Bucketeer/Sources/Internal/Scheduler/EvaluationForegroundTask.swift +++ b/Bucketeer/Sources/Internal/Scheduler/EvaluationForegroundTask.swift @@ -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) { @@ -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 diff --git a/Bucketeer/Sources/Internal/Scheduler/TaskScheduler.swift b/Bucketeer/Sources/Internal/Scheduler/TaskScheduler.swift index f28feaea..1deb8173 100644 --- a/Bucketeer/Sources/Internal/Scheduler/TaskScheduler.swift +++ b/Bucketeer/Sources/Internal/Scheduler/TaskScheduler.swift @@ -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) ] @@ -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 @@ -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() + } + } } diff --git a/Bucketeer/Sources/Public/BKTClient.swift b/Bucketeer/Sources/Public/BKTClient.swift index dd133372..7205673a 100644 --- a/Bucketeer/Sources/Public/BKTClient.swift +++ b/Bucketeer/Sources/Public/BKTClient.swift @@ -56,7 +56,7 @@ public class BKTClient { ) } - fileprivate func scheduleTasks() { + func scheduleTasks() { self.taskScheduler = TaskScheduler(component: component, dispatchQueue: dispatchQueue) } @@ -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 { diff --git a/BucketeerTests/EvaluationTaskEnabledTests.swift b/BucketeerTests/EvaluationTaskEnabledTests.swift new file mode 100644 index 00000000..c3986105 --- /dev/null +++ b/BucketeerTests/EvaluationTaskEnabledTests.swift @@ -0,0 +1,245 @@ +import XCTest +@testable import Bucketeer + +final class EvaluationTaskEnabledTests: XCTestCase { + + // MARK: - Test 1: Enable/Disable Functionality + + func testForegroundTaskStartsDisabledWhenCreatedByTaskScheduler() { + let dispatchQueue = DispatchQueue(label: "test") + let component = MockComponent() + + // Create task with enabled: false (as TaskScheduler does) + let task = EvaluationForegroundTask( + component: component, + queue: dispatchQueue, + enabled: false + ) + + let expectation = self.expectation(description: "Should not execute when disabled") + expectation.isInverted = true + + let evaluationInteractor = MockEvaluationInteractor( + fetchHandler: { _, _, _ in + expectation.fulfill() + } + ) + component.evaluationInteractor = evaluationInteractor + + task.start() + + // Wait briefly - task should NOT execute + wait(for: [expectation], timeout: 0.5) + } + + func testForegroundTaskExecutesAfterEnabled() { + let dispatchQueue = DispatchQueue(label: "test") + let component = MockComponent() + + let expectation = self.expectation(description: "Should execute after enabled") + expectation.expectedFulfillmentCount = 1 + + let evaluationInteractor = MockEvaluationInteractor( + fetchHandler: { _, _, completion in + completion?(.success(.init( + evaluations: .mock1, + userEvaluationsId: "test", + seconds: 1, + sizeByte: 100, + featureTag: "test" + ))) + expectation.fulfill() + } + ) + component.evaluationInteractor = evaluationInteractor + + // Create disabled task + let task = EvaluationForegroundTask( + component: component, + queue: dispatchQueue, + retryPollingInterval: 100, + maxRetryCount: 1, + enabled: false + ) + + task.start() + + // Enable the task + task.enable() + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Test 2: No Cancellation After Init + + func testNoRequestCancelledErrorDuringInitialization() { + let expectation = self.expectation(description: "Init completes without cancellation") + expectation.expectedFulfillmentCount = 1 + + var requestCount = 0 + let config = BKTConfig.mock( + eventsFlushInterval: 50, + eventsMaxQueueSize: 3, + pollingInterval: 100, // Very short interval to trigger race condition + backgroundPollingInterval: 1000 + ) + + let dataModule = MockDataModule( + userHolder: .init(user: .mock1), + apiClient: MockApiClient(getEvaluationsHandler: { _, _, _, _, handler in + requestCount += 1 + // Simulate slow initial request + if requestCount == 1 { + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + handler?(.success(.init( + evaluations: .mock1, + userEvaluationsId: "id", + seconds: 2, + sizeByte: 3, + featureTag: "feature" + ))) + } + } else { + // Subsequent requests should not happen during init + XCTFail("Should not trigger additional requests during initialization") + } + }) + ) + + let client = BKTClient(dataModule: dataModule, dispatchQueue: .global()) + client.scheduleTasks() + + client.fetchEvaluations(timeoutMillis: 5000) { error in + // Should complete without "Request cancelled by newer execution" error + XCTAssertNil(error, "Should not have cancellation error") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 2.0) + } + + // MARK: - Test 3: Tasks Stay Enabled After Init + + func testTasksRemainEnabledAfterSuccessfulInit() { + let expectation = self.expectation(description: "Tasks remain enabled") + expectation.expectedFulfillmentCount = 2 // Initial fetch + one poller execution + + var fetchCount = 0 + let config = BKTConfig.mock( + eventsFlushInterval: 50, + eventsMaxQueueSize: 3, + pollingInterval: 200, // Short interval to verify task stays enabled + backgroundPollingInterval: 1000 + ) + + let dataModule = MockDataModule( + userHolder: .init(user: .mock1), + apiClient: MockApiClient(getEvaluationsHandler: { _, _, _, _, handler in + fetchCount += 1 + handler?(.success(.init( + evaluations: .mock1, + userEvaluationsId: "id", + seconds: 2, + sizeByte: 3, + featureTag: "feature" + ))) + expectation.fulfill() + }) + ) + + let client = BKTClient(dataModule: dataModule, dispatchQueue: .global()) + client.scheduleTasks() + + client.fetchEvaluations(timeoutMillis: 5000) { error in + XCTAssertNil(error) + // After init completes, task should be enabled and continue polling + client.taskScheduler?.enableEvaluationTask() + } + + wait(for: [expectation], timeout: 1.5) + XCTAssertGreaterThanOrEqual(fetchCount, 2, "Task should remain enabled and execute multiple times") + } + + // MARK: - Test 4: Tasks Enabled Even After Init Failure + + func testTasksEnabledAfterFailedInit() { + let expectation = self.expectation(description: "Tasks enabled after failed init") + expectation.expectedFulfillmentCount = 2 // Failed init + one successful poller execution + + var fetchCount = 0 + let config = BKTConfig.mock( + eventsFlushInterval: 50, + eventsMaxQueueSize: 3, + pollingInterval: 200, + backgroundPollingInterval: 1000 + ) + + let dataModule = MockDataModule( + userHolder: .init(user: .mock1), + apiClient: MockApiClient(getEvaluationsHandler: { _, _, _, _, handler in + fetchCount += 1 + if fetchCount == 1 { + // First request fails + handler?(.failure( + error: .timeout(message: "timeout", error: NSError(), timeoutMillis: 5000), + featureTag: "feature" + )) + } else { + // Subsequent requests succeed + handler?(.success(.init( + evaluations: .mock1, + userEvaluationsId: "id", + seconds: 2, + sizeByte: 3, + featureTag: "feature" + ))) + } + expectation.fulfill() + }) + ) + + let client = BKTClient(dataModule: dataModule, dispatchQueue: .global()) + client.scheduleTasks() + + client.fetchEvaluations(timeoutMillis: 5000) { error in + XCTAssertNotNil(error, "First request should fail") + // Even after failure, task should be enabled + client.taskScheduler?.enableEvaluationTask() + } + + wait(for: [expectation], timeout: 1.5) + XCTAssertGreaterThanOrEqual(fetchCount, 2, "Task should be enabled even after init failure") + } + + // MARK: - Test 5: TaskScheduler Integration + + func testTaskSchedulerEnablesEvaluationTask() { + let dispatchQueue = DispatchQueue(label: "test") + let component = MockComponent() + + let scheduler = TaskScheduler(component: component, dispatchQueue: dispatchQueue) + + // Tasks should start disabled + // Enable them + scheduler.enableEvaluationTask() + + // After enabling, tasks should execute + let expectation = self.expectation(description: "Task executes after scheduler enables it") + + let evaluationInteractor = MockEvaluationInteractor( + fetchHandler: { _, _, completion in + completion?(.success(.init( + evaluations: .mock1, + userEvaluationsId: "test", + seconds: 1, + sizeByte: 100, + featureTag: "test" + ))) + expectation.fulfill() + } + ) + component.evaluationInteractor = evaluationInteractor + + wait(for: [expectation], timeout: 1.0) + } +}