Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
21af372
wip cache resource implementation
Will-ShaoHua Apr 24, 2025
1daab83
nit
Will-ShaoHua Apr 24, 2025
7380422
refacotr resourceCache.ts not verified yet
Will-ShaoHua Apr 24, 2025
84609ec
verified
Will-ShaoHua Apr 24, 2025
ed782e2
patch not verified
Will-ShaoHua Apr 24, 2025
3e50145
merge 2 global states variable but might need to revert because it wi…
Will-ShaoHua Apr 24, 2025
0392707
Revert "merge 2 global states variable but might need to revert becau…
Will-ShaoHua Apr 24, 2025
95d5c9e
cl
Will-ShaoHua Apr 24, 2025
b7144f0
private
Will-ShaoHua Apr 24, 2025
3fec764
compile error
Will-ShaoHua Apr 24, 2025
57e81a1
some docstr
Will-ShaoHua Apr 24, 2025
d388b8f
try catch
Will-ShaoHua Apr 24, 2025
e50bfab
pass waitUntil option via ctor
Will-ShaoHua Apr 25, 2025
69a474e
docstr
Will-ShaoHua Apr 25, 2025
33dd9bf
docstr
Will-ShaoHua Apr 25, 2025
d52d659
docstr
Will-ShaoHua Apr 25, 2025
4c8a6b5
docstr
Will-ShaoHua Apr 25, 2025
a3514ff
rename
Will-ShaoHua Apr 25, 2025
9eb0954
log
Will-ShaoHua Apr 25, 2025
42c3451
docstr
Will-ShaoHua Apr 25, 2025
b06932b
rename updateResourceCache
Will-ShaoHua Apr 25, 2025
9b57d7b
docstr
Will-ShaoHua Apr 25, 2025
25b04d8
refactor and overload releaseLock instead of using updateResourceCache
Will-ShaoHua Apr 25, 2025
f6f1772
update log level / topic
Will-ShaoHua Apr 25, 2025
cdb9bc8
* update log string
Will-ShaoHua Apr 25, 2025
97a4482
add clearCache
Will-ShaoHua Apr 25, 2025
8466c95
docstr
Will-ShaoHua Apr 25, 2025
b7d5b73
newline
Will-ShaoHua Apr 25, 2025
6a0c54f
update log level
Will-ShaoHua Apr 25, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Bug Fix",
"description": "Fix users can not log in successfully with 2+ IDE instnaces open due to throttle error throw by the service"
}
34 changes: 32 additions & 2 deletions packages/core/src/codewhisperer/region/regionProfileManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { isAwsError, ToolkitError } from '../../shared/errors'
import { telemetry } from '../../shared/telemetry/telemetry'
import { localize } from '../../shared/utilities/vsCodeUtils'
import { Commands } from '../../shared/vscode/commands2'
import { CachedResource } from '../../shared/utilities/resourceCache'

// TODO: is there a better way to manage all endpoint strings in one place?
export const defaultServiceConfig: CodeWhispererConfig = {
Expand Down Expand Up @@ -59,6 +60,27 @@ export class RegionProfileManager {
// Store the last API results (for UI propuse) so we don't need to call service again if doesn't require "latest" result
private _profiles: RegionProfile[] = []

private readonly cache = new (class extends CachedResource<RegionProfile[]> {
constructor(private readonly profileProvider: () => Promise<RegionProfile[]>) {
super(
'aws.amazonq.regionProfiles.cache',
60000,
{
resource: {
locked: false,
timestamp: 0,
result: undefined,
},
},
{ timeout: 15000, interval: 1500, truthy: true }
)
}

override resourceProvider(): Promise<RegionProfile[]> {
return this.profileProvider()
}
})(this.listRegionProfile.bind(this))

get activeRegionProfile() {
const conn = this.connectionProvider()
if (isBuilderIdConnection(conn)) {
Expand Down Expand Up @@ -104,6 +126,14 @@ export class RegionProfileManager {

constructor(private readonly connectionProvider: () => Connection | undefined) {}

async resetCache() {
await this.cache.releaseLock()
}

async getProfiles(): Promise<RegionProfile[]> {
return this.cache.getResource()
}

async listRegionProfile(): Promise<RegionProfile[]> {
this._profiles = []

Expand Down Expand Up @@ -238,7 +268,7 @@ export class RegionProfileManager {
return
}
// cross-validation
this.listRegionProfile()
this.getProfiles()
.then(async (profiles) => {
const r = profiles.find((it) => it.arn === previousSelected.arn)
if (!r) {
Expand Down Expand Up @@ -300,7 +330,7 @@ export class RegionProfileManager {
const selected = this.activeRegionProfile
let profiles: RegionProfile[] = []
try {
profiles = await this.listRegionProfile()
profiles = await this.getProfiles()
} catch (e) {
return [
{
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/codewhisperer/util/authUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export class AuthUtil {

if (!this.isConnected()) {
await this.regionProfileManager.invalidateProfile(this.regionProfileManager.activeRegionProfile?.arn)
await this.regionProfileManager.resetCache()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should invalidateProfile do this internally?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is kinda redundant i think, and also its name is confusing for sure (should've named it releaseLock), but i will remove it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalidateProfile doesn't need this i think as the source of truth of profile invalidateProfile is also from this resourceCache. Originally i added this line is kinda for debugging purpose and to avoid a dead lock scenario when the code was problematic. However i think releaseLock will be called

  1. when getResource goes to pull real response and executes successfully
  2. when getResource goes to pull real response and executes exceptionally
  3. when getResource goes to return cached value
  4. if one process/ide instance waits to long (current it's set to 15s), it will go either (1) or (2) and eventually release the lock again

so I think it's redundant

Copy link
Contributor Author

@Will-ShaoHua Will-ShaoHua Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just reminds me that i need to add one clearCache onConnectionChanged. Cache expiration is only 60s atm tho, worth adding one in case ppl change connection and use the wrong resultset.

}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export class AmazonQLoginWebview extends CommonAuthWebview {
*/
override async listRegionProfiles(): Promise<RegionProfile[] | string> {
try {
return await AuthUtil.instance.regionProfileManager.listRegionProfile()
return await AuthUtil.instance.regionProfileManager.getProfiles()
} catch (e) {
const conn = AuthUtil.instance.conn as SsoConnection | undefined
telemetry.amazonq_didSelectProfile.emit({
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/shared/globalState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type globalKey =
| 'aws.toolkit.lsp.manifest'
| 'aws.amazonq.customization.overrideV2'
| 'aws.amazonq.regionProfiles'
| 'aws.amazonq.regionProfiles.cache'
// Deprecated/legacy names. New keys should start with "aws.".
| '#sessionCreationDates' // Legacy name from `ssoAccessTokenProvider.ts`.
| 'CODECATALYST_RECONNECT'
Expand Down
177 changes: 177 additions & 0 deletions packages/core/src/shared/utilities/resourceCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import globals from '../extensionGlobals'
import { globalKey } from '../globalState'
import { getLogger } from '../logger/logger'
import { waitUntil } from '../utilities/timeoutUtils'

/**
* args:
* @member result: the actual resource type callers want to use
* @member locked: readWriteLock, while the lock is acquired by one process, the other can't access to it until it's released by the previous
* @member timestamp: used for determining the resource is stale or not
*/
interface Resource<V> {
result: V | undefined
locked: boolean
timestamp: number
}

/**
* GlobalStates schema, which is used for vscode global states deserialization, [globals.globalState#tryGet<T>] etc.
* The purpose of it is to allow devs to overload the resource into existing global key and no need to create a specific key for only this purpose.
*/
export interface GlobalStateSchema<V> {
resource: Resource<V>
}

const logger = getLogger()

function now() {
return globals.clock.Date.now()
}

/**
* constructor:
* @param key: global state key, which is used for globals.globalState#update, #tryGet etc.
* @param expirationInMilli: cache expiration time in milli seconds
* @param defaultValue: default value for the cache if the cache doesn't pre-exist in users' FS
* @param waitUntilOption: waitUntil option for acquire lock
*
* methods:
* @method resourceProvider: implementation needs to implement this method to obtain the latest resource either via network calls or FS read
* @method getResource: obtain the resource from cache or pull the latest from the service if the cache either expires or doesn't exist
*/
export abstract class CachedResource<V> {
constructor(
private readonly key: globalKey,
private readonly expirationInMilli: number,
private readonly defaultValue: GlobalStateSchema<V>,
private readonly waitUntilOption: { timeout: number; interval: number; truthy: boolean }
) {}

abstract resourceProvider(): Promise<V>

async getResource(): Promise<V> {
const cachedValue = await this.tryLoadResourceAndLock()
const resource = cachedValue?.resource

// If cache is still fresh, return cached result, otherwise pull latest from the service
if (cachedValue && resource && resource.result) {
const duration = now() - resource.timestamp
if (duration < this.expirationInMilli) {
logger.info(`cache hit, duration(%sms) is less than expiration(%sms)`, duration, this.expirationInMilli)
// release the lock
await this.releaseLock(resource, cachedValue)
return resource.result
Copy link
Contributor

@justinmk3 justinmk3 Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, I think I get it: multiple vscode instances will all acquire the lock, but the N+1 instances will each return here.

I guess that's fine. But it means they all must acquire a lock just to do the read. Maybe that's necessary, to avoid reading during an update.

Copy link
Contributor Author

@Will-ShaoHua Will-ShaoHua Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea exactly, i should have mentioned it more explicitly in the doc string, it's like a read write lock? if i understand it correctly.

But not multiple vscode instances will all acquire the lock, only 1 can hold the lock and the rest of them need to wait until the first one finish pulling the real response and release the lock. Then the rest of them can start acquiring the lock 1 by 1 and return early here using the cached value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there're definitely room to improve i think in the future. Not have enough time to consider all the potential use cases and requirements so currently it only meets the needs for listProfile / listCustomization.

} else {
logger.info(
`cached value is stale, duration(%sms) is older than expiration(%sms), invoking service API to pull the latest response`,
duration,
this.expirationInMilli
)
}
}

/**
* Possible paths here
* 1. cache doesn't exist.
* 2. cache exists but expired.
* 3. lock is held by other process and the waiting time is greater than the specified waiting time
*/
logger.info(`cache miss, invoking service API to pull the latest response`)
try {
// Make the real network call / FS read to pull the resource
const latest = await this.resourceProvider()

// Update resource cache and release the lock
const r: Resource<V> = {
locked: false,
timestamp: now(),
result: latest,
}
logger.info(`doen loading the latest of resource(%s), updating resource cache`, this.key)
await this.releaseLock(r)
return latest
} catch (e) {
logger.info(
`encountered unexpected error while loading the latest of resource(%s), releasing resource lock`,
this.key
)
await this.releaseLock()
throw e
}
}

// This method will lock the resource so other callers have to wait until the lock is released, otherwise will return undefined if it times out
private async tryLoadResourceAndLock(): Promise<GlobalStateSchema<V> | undefined> {
const _acquireLock = async () => {
const cachedValue = this.readCacheOrDefault()

if (!cachedValue.resource.locked) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this (also) check resource.timestamp and skip lock-aquisition if the cached value is new enough? what will prevent multiple vscode instances from sequentially waiting and then (redundantly) making the service call?

await this.lockResource(cachedValue)
return cachedValue
}

return undefined
}

const lock = await waitUntil(async () => {
const lock = await _acquireLock()
logger.info(`try obtain resource cache read lock %s`, lock)
if (lock) {
return lock
}
}, this.waitUntilOption)

return lock
}

async lockResource(baseCache: GlobalStateSchema<V>): Promise<void> {
await this.updateResourceCache({ locked: true }, baseCache)
}

async releaseLock(): Promise<void>
async releaseLock(resource: Partial<Resource<V>>): Promise<void>
async releaseLock(resource: Partial<Resource<V>>, baseCache: GlobalStateSchema<V>): Promise<void>
async releaseLock(resource?: Partial<Resource<V>>, baseCache?: GlobalStateSchema<V>): Promise<void> {
if (!resource) {
await this.updateResourceCache({ locked: false }, undefined)
} else if (baseCache) {
await this.updateResourceCache(resource, baseCache)
} else {
await this.updateResourceCache(resource, undefined)
}
}

private async updateResourceCache(resource: Partial<Resource<any>>, cache: GlobalStateSchema<any> | undefined) {
const baseCache = cache ?? this.readCacheOrDefault()

const toUpdate: GlobalStateSchema<V> = {
...baseCache,
resource: {
...baseCache.resource,
...resource,
},
}

await globals.globalState.update(this.key, toUpdate)
}

private readCacheOrDefault(): GlobalStateSchema<V> {
const cachedValue = globals.globalState.tryGet<GlobalStateSchema<V>>(this.key, Object, {
...this.defaultValue,
resource: {
...this.defaultValue.resource,
locked: false,
result: undefined,
timestamp: 0,
},
})

return cachedValue
}
}
Loading