-
Notifications
You must be signed in to change notification settings - Fork 0
Add architecture files for cds-feature-ai-core, cds-feature-recommendations #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lisajulia
wants to merge
9
commits into
main
Choose a base branch
from
add-architecture-files
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+478
−0
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
415193e
Add architecture files for ai-core and recommendations, document ai a…
lisajulia 95b1fc2
Update cds-feature-ai-core/docs/architecture.md
lisajulia dadd80c
Merge branch 'main' into add-architecture-files
lisajulia 1947f56
Incorporate Marvins comments
lisajulia 5b60823
Update links to CI checks
lisajulia a4a982b
Incorporate Adrians comments
lisajulia d7ec813
Add Architecture Decisions section in cds-feature-recommendations
lisajulia e812586
Add Architecture Decisions section in cds-feature-ai-core plus furthe…
lisajulia 9c77df0
Merge branch 'main' into add-architecture-files
lisajulia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,282 @@ | ||
| # Architecture: `cds-feature-ai-core` | ||
|
|
||
| ## Table of Contents | ||
|
|
||
| - [Purpose](#purpose) | ||
| - [Dependencies](#dependencies) | ||
| - [Feature](#feature) | ||
| - [CDS Model](#cds-model) | ||
| - [Public API](#public-api) | ||
| - [Key Infrastructure Classes](#key-infrastructure-classes) | ||
| - [Multi-Tenancy](#multi-tenancy) | ||
| - [Key Flows](#key-flows) | ||
| - [Tenant Subscribe](#tenant-subscribe) | ||
| - [Tenant Unsubscribe](#tenant-unsubscribe) | ||
| - [Inference Client Resolution](#inference-client-resolution) | ||
| - [Tests](#tests) | ||
| - [Quality Tools](#quality-tools) | ||
| - [Architecture Decisions](#architecture-decisions) | ||
|
|
||
| --- | ||
|
|
||
| ## Purpose | ||
|
|
||
| Bridges CAP Java to SAP AI Core's management and inference REST APIs, providing resource group management, deployment lifecycle, and inference client resolution as a CAP service. At the time of writing, `com.sap.ai.sdk:ai-core` offered no CAP integration — only raw REST API clients — so this plugin fills that gap. | ||
|
|
||
| → [README](../README.md) | ||
|
|
||
| --- | ||
|
|
||
| ## Dependencies | ||
|
|
||
| | Dependency | Why | | ||
| |---|---| | ||
| | `com.sap.ai.sdk:ai-core` (SAP AI SDK) | Provides the generated `DeploymentApi`, `ConfigurationApi`, `ResourceGroupApi`, and `ApiClient` types used to call the AI Core REST API. The plugin wraps these behind CDS events so callers never deal with the AI SDK directly. | | ||
| | `com.github.ben-manes.caffeine:caffeine` | Thread-safe in-process caching for `tenantId → resourceGroupId` and `resourceGroupId::configName → deploymentId` mappings (1 h TTL, 10k max per cache). | | ||
| | `io.github.resilience4j:resilience4j-retry` | Exponential backoff (initial 300 ms, doubling, max 30 s, up to 10 attempts) on 403/404/412 responses from AI Core - needed because resource group creation is asyncronous. | | ||
| | `com.sap.cds:cds-services-api/-impl/-utils` | CAP Java integration — used to integrate the plugin into the CAP runtime. | | ||
| | CAP Java `DeploymentService` | MTX lifecycle hook: `AICoreSetupHandler` subscribes to `SubscribeEvent` (`@After LATE`) and `UnsubscribeEvent` (`@Before EARLY`) to create/delete per-tenant resource groups automatically. | | ||
|
|
||
| --- | ||
|
|
||
| ## Feature | ||
|
|
||
| ### CDS Model | ||
|
|
||
| Defined in `src/main/resources/cds/`: `AICore.cds` | ||
|
|
||
| ```cds | ||
| // @protocol: 'none' — programmatic access only, never exposed as OData/REST | ||
| service AICore { | ||
| @cds.persistence.skip entity resourceGroups { ... } // AI Core resource group | ||
| @cds.persistence.skip entity deployments { ... } // AI Core deployment + action stop() | ||
| @cds.persistence.skip entity configurations { ... } // AI Core configuration | ||
| } | ||
| ``` | ||
|
|
||
| Entities are `@cds.persistence.skip` — they have no database tables and are backed entirely by the AI Core REST API at runtime. | ||
|
|
||
| The three custom events are **not declared in the CDS model** — they are Java-only `@EventName`-annotated `EventContext` interfaces in the `api` package: | ||
|
|
||
| | Java interface | Event name | In → Out | | ||
| |---|---|---| | ||
| | `ResourceGroupContext` | `resourceGroup` | `tenantId?` → `resourceGroupId` | | ||
| | `DeploymentIdContext` | `deploymentId` | `resourceGroupId + ModelDeploymentSpec` → `deploymentId` | | ||
| | `InferenceClientContext` | `inferenceClient` | `resourceGroupId + deploymentId` → `ApiClient` | | ||
|
|
||
| --- | ||
|
|
||
| ### Public API | ||
|
|
||
| Three custom CDS events are the only stable contract. All other classes are internal. | ||
|
|
||
| ```java | ||
| // Resolve resource group for the current tenant | ||
| String rgId = aiCoreService.resourceGroup(); | ||
|
|
||
| // Resolve (or lazily create) a deployment matching the given model spec | ||
| String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1()); | ||
|
|
||
| // Obtain a pre-configured ApiClient for inference | ||
| ApiClient client = aiCoreService.inferenceClient(rgId, deploymentId); | ||
| ``` | ||
|
|
||
| See also [Programmatic Usage in README](../README.md#programmatic-usage). | ||
|
|
||
| --- | ||
|
|
||
| ### Key Infrastructure Classes | ||
|
|
||
| | Class | Role | | ||
| |---|---| | ||
| | `AICoreServiceConfiguration` | extends `CdsRuntimeConfiguration` — wires all handlers, clients, and caches at startup; detects AI Core binding | | ||
| | `AICoreConfig` | Immutable config record populated from `cds.ai.core.*` YAML properties | | ||
| | `AICoreClients` | Holds `DeploymentApi`, `ConfigurationApi`, `ResourceGroupApi`, and the raw `AiCoreService` from the AI SDK | | ||
| | `DeploymentResolver` | Thread-safe resolver with two Caffeine caches (`tenantId → rgId`, `rgId::configName → deploymentId`) and `ConcurrentHashMap` per-key locks (prevents duplicate deployments under concurrency); Resilience4j backoff on 403/404/412 | | ||
| | `AICoreApiHandler` | `@On` handler for the three custom events: `resourceGroup`, `deploymentId`, `inferenceClient` | | ||
| | `AICoreSetupHandler` | `@After(LATE) SubscribeEvent` / `@Before(EARLY) UnsubscribeEvent` — creates/deletes resource groups during MTX tenant lifecycle | | ||
| | `AbstractCrudHandler` | Base for all entity CRUD handlers; provides `resolveResourceGroup()` and `ensureResourceGroupAccessible()` (tenant isolation guard) | | ||
|
|
||
| --- | ||
|
|
||
| ### Multi-Tenancy | ||
|
|
||
| → [Multi-Tenancy in README](../README.md#multi-tenancy) | ||
|
|
||
| MT mode is detected automatically at startup: if `cds.multiTenancy.sidecar.url` is set or a `DeploymentService` bean is present in the CAP service catalog, MT mode is active. Resource groups are named `{resourceGroupPrefix}{tenantId}` (default prefix: `cds-`) and labelled `ext.ai.sap.com/CDS_TENANT_ID = <tenantId>` so they can be looked up by tenant. | ||
|
|
||
| --- | ||
|
|
||
| ### Key Flows | ||
|
|
||
| #### Tenant Subscribe | ||
|
|
||
| ```mermaid | ||
| %%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground': '#ffffff00', 'fontSize': '14px', 'primaryColor': '#fff', 'primaryBorderColor': '#000', 'primaryTextColor': '#000'}}}%% | ||
| flowchart TD | ||
| classDef process fill:#fff,stroke:#000,color:#000 | ||
| classDef skip fill:#f4f4f4,stroke:#000,color:#000 | ||
|
|
||
| START@{ shape: sm-circ, label: "start" } --> A(["CAP MTX DeploymentService fires SubscribeEvent @After(LATE)"]):::process | ||
| A --> B(["AICoreSetupHandler: resolveResourceGroup(tenantId)"]):::process | ||
| B --> C(["GET /v2/admin/resourceGroups?labelFilter=CDS_TENANT_ID=tenantId"]):::process | ||
| C --> D{resource group found?} | ||
| D -->|yes| E(["cache resourceGroupId (expireAfterAccess 1h)"]):::process | ||
| D -->|no| F(["POST /v2/admin/resourceGroups"]):::process | ||
| F --> E | ||
| E --> END1@{ shape: framed-circle, label: "stop" } | ||
|
|
||
| style START fill:#000,stroke:#000,color:#000 | ||
| style END1 fill:#000,stroke:#000,stroke-width:3px,color:#000 | ||
| ``` | ||
|
|
||
| #### Inference Client Resolution | ||
|
|
||
| Every prediction request or inference call requires a fully-configured `ApiClient` that is scoped to a specific AI Core deployment. The challenge is that creating this client requires three sequential steps | ||
| — tenant → resource group | ||
| - resource group + model spec (e.g. RPT-1) → deployment | ||
| - deployment → ApiClient | ||
| Each of these involves a remote API call to AI Core (resource groups and deployments are created asynchronously and may not be immediately available). The plugin solves this with per-step Caffeine caches (1 h TTL) to avoid redundant AI Core API calls, `ConcurrentHashMap` per-key locks to prevent duplicate deployment creation under concurrent requests, and Resilience4j exponential backoff to handle the window between a resource group or deployment being created and it becoming usable. | ||
|
|
||
| The three steps map directly to the three `EventContext` interfaces and are executed in sequence by `AICoreServiceImpl`: | ||
|
|
||
| ##### Event 1: resourceGroup | ||
|
lisajulia marked this conversation as resolved.
|
||
|
|
||
| Emitted by any caller (e.g. `cds-feature-recommendations`) via `AICoreService.resourceGroup()` to resolve the AI Core resource group ID for the current tenant. In single-tenant mode, the configured default resource group is returned directly. In multi-tenant mode, `DeploymentResolver` looks up the resource group by the `ext.ai.sap.com/CDS_TENANT_ID` label with a `GET` to `/v2/admin/resourceGroups`. The result is cached for 1 h (expire-after-access) so the AI Core management API is not called on every request. If no resource group is found, it creates one via `POST` to `/v2/admin/resourceGroups` (tolerating 409 conflicts), caches the result, and returns the resource group ID. | ||
| Unlike the deployment cache, there is **no validation on cache hits** — if a resource group is deleted externally during that window, the stale ID stays cached and subsequent prediction requests will fail until the entry expires or the app restarts. | ||
|
|
||
|
|
||
| ##### Event 2: deploymentId — invoked with `resourceGroupId` | ||
|
|
||
| Emitted by callers via `AICoreService.deploymentId(rgId, spec)` to resolve (or lazily create) a running deployment matching the given `ModelDeploymentSpec` inside the resource group. `DeploymentResolver` first checks its deployment cache; on a cache miss or invalid cached entry it queries AI Core for an existing RUNNING/PENDING deployment, and — if none exists — creates the configuration and deployment, then polls until RUNNING with exponential backoff: Resilience4j exponential backoff (300 ms initial, doubling, capped at 30 s, max 10 attempts) on: 403/412 during deployment creation (`POST /v2/lm/deployments`); 403/404/412 during deployment polling (`GET /v2/lm/deployments`). A `ConcurrentHashMap` per-key lock prevents duplicate deployments from being created under concurrent first-use requests. The resolved deployment ID is cached for 1 h. | ||
|
|
||
|
|
||
| ##### Event 3: inferenceClient — invoked with `resourceGroupId` and `deploymentId` | ||
|
|
||
| Emitted by callers via `AICoreService.inferenceClient(rgId, deploymentId)` to obtain a pre-configured `ApiClient` ready to make prediction requests against a specific deployment. The handler delegates to the SAP AI SDK's `AiCoreService` to build an inference destination scoped to the resource group and deployment, then wraps it in an `ApiClient`. No caching — the client is lightweight to construct and callers are expected to obtain it once per request. | ||
|
|
||
| #### Full flow | ||
|
|
||
| ```mermaid | ||
| %%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground': '#ffffff00', 'fontSize': '14px', 'primaryColor': '#fff', 'primaryBorderColor': '#000', 'primaryTextColor': '#000'}}}%% | ||
| flowchart TD | ||
| classDef process fill:#fff,stroke:#000,color:#000 | ||
| classDef skip fill:#f4f4f4,stroke:#000,color:#000 | ||
|
|
||
| START@{ shape: sm-circ, label: "start" } --> A(["Consumer: getAICoreService() → RemoteService"]):::process | ||
|
|
||
| A --> A1(["emit ResourceGroupContext (tenantId)"]):::process | ||
| A1 --> A2{multiTenancy enabled AND tenantId != null?} | ||
| A2 -->|no| A3(["return config.defaultResourceGroup()"]):::skip | ||
| A2 -->|yes| A4{tenantResourceGroupCache hit?} | ||
| A4 -->|yes| A5(["return cached resourceGroupId"]):::skip | ||
| A4 -->|no| A6(["GET /v2/admin/resourceGroups?labelSelector=...tenant={tenantId}"]):::process | ||
| A6 --> A7{found?} | ||
| A7 -->|yes| A8(["cache result (expireAfterAccess 1h)"]):::process | ||
| A7 -->|no| A9(["POST /v2/admin/resourceGroups (409 = already exists → ok)"]):::process | ||
| A9 --> A8 | ||
| A8 --> A5 | ||
|
|
||
| A3 --> B1 | ||
| A5 --> B1 | ||
|
|
||
| B1(["emit DeploymentIdContext (resourceGroupId, ModelDeploymentSpec)"]):::process | ||
| B1 --> B2(["acquire per-key lock (ConcurrentHashMap)"]):::process | ||
| B2 --> B3{deploymentCache hit?} | ||
| B3 -->|yes| B4(["GET /v2/lm/deployments/{id} — validate cached entry"]):::process | ||
| B4 --> B5{status RUNNING or PENDING?} | ||
| B5 -->|yes| B6(["return cached deploymentId"]):::skip | ||
| B5 -->|no / 404| B7(["invalidate cache entry"]):::process | ||
| B7 --> B8 | ||
| B3 -->|no| B8(["GET /v2/lm/deployments?scenarioId=... — query existing deployments"]):::process | ||
| B8 --> B10{match by configName + RUNNING/PENDING?} | ||
| B10 -->|yes| B11(["cache deploymentId (expireAfterAccess 1h)"]):::process | ||
| B10 -->|no| B12(["GET /v2/lm/configurations?scenarioId=... — find or create config"]):::process | ||
| B12 --> B13{config with matching name exists?} | ||
| B13 -->|yes| B14(["reuse existing configId"]):::process | ||
| B13 -->|no| B15(["POST /v2/lm/configurations"]):::process | ||
| B15 --> B14 | ||
| B14 --> B16(["POST /v2/lm/deployments (retry on 403/412)"]):::process | ||
| B16 --> B17(["poll GET /v2/lm/deployments/{id} until RUNNING (exponential backoff)"]):::process | ||
| B17 --> B11 | ||
| B11 --> B6 | ||
|
lisajulia marked this conversation as resolved.
|
||
|
|
||
| B6 --> C1(["emit InferenceClientContext (resourceGroupId, deploymentId)"]):::process | ||
| C1 --> C2(["getInferenceDestination(rgId).usingDeploymentId(depId)"]):::process | ||
| C2 --> C3(["return ApiClient.create(destination)"]):::process | ||
| C3 --> END1@{ shape: framed-circle, label: "stop" } | ||
|
|
||
| style START fill:#000,stroke:#000,color:#000 | ||
| style END1 fill:#000,stroke:#000,stroke-width:3px,color:#000 | ||
| ``` | ||
|
|
||
| #### Tenant Unsubscribe | ||
|
|
||
| ```mermaid | ||
| %%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground': '#ffffff00', 'fontSize': '14px', 'primaryColor': '#fff', 'primaryBorderColor': '#000', 'primaryTextColor': '#000'}}}%% | ||
| flowchart TD | ||
| classDef process fill:#fff,stroke:#000,color:#000 | ||
|
|
||
| START@{ shape: sm-circ, label: "start" } --> A(["CAP MTX DeploymentService fires UnsubscribeEvent @Before(EARLY)"]):::process | ||
| A --> B(["AICoreSetupHandler: DELETE /v2/admin/resourceGroups/{id}"]):::process | ||
| B --> C(["invalidateTenant(tenantId) — evicts tenantResourceGroupCache and deploymentCache entries"]):::process | ||
| C --> END1@{ shape: framed-circle, label: "stop" } | ||
|
|
||
| style START fill:#000,stroke:#000,color:#000 | ||
| style END1 fill:#000,stroke:#000,stroke-width:3px,color:#000 | ||
| ``` | ||
| --- | ||
|
|
||
| ## Tests | ||
|
|
||
| Unit tests for `AICoreServiceConfiguration`, `AICoreServiceImpl`, `AICoreSetupHandler`, and the CRUD handlers live in `src/test/` within this module (`mvn test`). | ||
|
|
||
| End-to-end integration tests against a real AI Core instance live in [`integration-tests/spring/`](../../integration-tests/README.md) (`AICoreServiceTest`, `DeploymentTest`, `ResourceGroupTest`, `MultiTenancyTest`, and others). MTX lifecycle tests (subscribe/unsubscribe/tenant isolation) live in [`integration-tests/mtx-local/`](../../integration-tests/README.md). | ||
|
|
||
| --- | ||
|
|
||
| ## Quality Tools | ||
|
|
||
| → [CI Checks and static analysis](../../CONTRIBUTING.md#ci-checks) | ||
|
|
||
| --- | ||
|
|
||
| ## Architecture Decisions | ||
|
|
||
| ### Wrapping the AI SDK in a CAP service for multi-tenant isolation | ||
|
|
||
| **Context:** The SAP AI SDK (`com.sap.ai.sdk:ai-core`) provides API clients with no CAP integration. Plugins like `cds-feature-recommendations` need to resolve a resource group, a deployment, and an inference client on every request — but should not need to know about AI Core internals, tenant routing, or caching. | ||
|
|
||
| **Key boundary condition:** The AI SDK's [`DestinationResolver`](https://github.com/SAP/ai-sdk-java/blob/main/core/src/main/java/com/sap/ai/sdk/core/DestinationResolver.java) always connects using `OnBehalfOf.TECHNICAL_USER_PROVIDER` — the provider tenant's service binding. There is no per-subscriber credential mechanism in the SDK. Tenant isolation is the caller's responsibility and is achieved entirely by setting the `AI-Resource-Group` HTTP header on each request to a resource group that belongs to the subscriber tenant. This is a hard constraint imposed by the SDK: any CAP integration on top of it must manage per-tenant resource group resolution itself — there is no way to "just pass a tenant ID" to the SDK and have it route correctly. | ||
| Beyond billing, proper per-tenant resource groups are also important for call history separation in AI Core: without them, all tenants' inference calls would appear under the same resource group in the AI Core audit log. | ||
|
|
||
| **Decision:** Expose the three resolution steps (`resourceGroup`, `deploymentId`, `inferenceClient`) as custom CDS events on the `AICore` service. The `resourceGroup` event resolves the correct per-tenant resource group ID from the current CAP request context (`UserInfo.getTenant()`), which is then threaded through to `deploymentId` and `inferenceClient`. Every AI Core call made by `AICoreApiHandler` carries this resource group ID as the `AI-Resource-Group` header, satisfying the SDK's constraint. | ||
| Callers invoke the `AICoreService` Java API (`resourceGroup()`, `deploymentId()`, `inferenceClient()`), which emits the corresponding CDS events; `AICoreApiHandler` handles them. This keeps the AI SDK entirely internal and gives application code a standard `@On`/`@After` handler hook to override or observe each step if needed. | ||
|
|
||
| --- | ||
|
|
||
| ### Caching resource group ids and deployment ids | ||
|
|
||
| **Context:** Resolving an inference-ready `ApiClient` requires three sequential remote calls to AI Core (resource group lookup, deployment lookup/creation, client construction). AI Core deployments may not exist yet on first use and take minutes to reach RUNNING state. Calling the management API on every OData read would be prohibitively slow. | ||
|
|
||
| **Solutions considered:** | ||
| - **`TenantAwareCache` (CAP built-in)** — `com.sap.cds.services.utils.TenantAwareCache` provides tenant-scoped invalidation natively and is already on the classpath via `cds-services-utils`. Not used yet but a candidate to replace the current manual Caffeine caches; see [#129](https://github.com/cap-java/cds-ai/issues/129). | ||
| - **In-process Caffeine cache per step** — zero additional infrastructure, thread-safe, configurable TTL. Accepted: the worst case (cache miss on restart or TTL expiry) is a single slow request; subsequent requests are fast. | ||
|
|
||
| **Decision:** Cache `tenantId → resourceGroupId` and `resourceGroupId::configName → deploymentId` in two separate Caffeine caches with 1 h expire-after-access TTL. The deployment cache entry is validated on each cache hit (a `GET` to verify RUNNING/PENDING status) to detect externally stopped deployments. The resource group cache has no hit-validation — a stale entry causes failures until the TTL expires; this was accepted as an acceptable trade-off given that resource groups are rarely deleted externally. | ||
|
|
||
| --- | ||
|
|
||
| ### Preventing duplicate AI deployments | ||
|
|
||
| **Context:** Multiple requests may arrive concurrently before any deployment exists (e.g. on cold start of a new tenant). Without coordination, each request would independently discover the absence of a deployment and try to create one, resulting in duplicate deployments. | ||
|
|
||
| **Decision:** Use a `ConcurrentHashMap<String, Object>` as a lock registry, synchronized on the value for the specific key being resolved. Only the first thread for a given key enters `findOrCreateDeployment`; subsequent threads wait and then find the deployment already in the cache. | ||
|
|
||
| --- | ||
|
|
||
| ### Resilience4j exponential backoff for asynchronous AI Core operations | ||
|
|
||
| **Context:** AI Core resource group and deployment creation is asynchronous. After a `POST /v2/admin/resourceGroups` or `POST /v2/lm/deployments`, subsequent calls may return 403 or 412 (precondition failed) until the resource is fully provisioned. Polling is necessary to wait for a deployment to reach RUNNING status. | ||
|
|
||
| **Decision:** Use Resilience4j retry with exponential backoff (300 ms initial delay, doubling each attempt, capped at 30 s, max 10 attempts) on 403/404/412 responses from: `POST /v2/lm/deployments` (creation) and `GET /v2/lm/deployments/{id}` (polling until RUNNING). The same retry strategy covers both the "resource not yet available" and "deployment not yet running" cases. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.