Skip to content

Commit 54c7d02

Browse files
committed
ResourceResolver etag handling
1 parent 02f335b commit 54c7d02

File tree

6 files changed

+193
-137
lines changed

6 files changed

+193
-137
lines changed

plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
testServiceImplementation="software.aws.toolkits.jetbrains.core.region.MockRegionProvider"/>
3737
<applicationService serviceInterface="migration.software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider"
3838
serviceImplementation="software.aws.toolkits.jetbrains.core.DefaultRemoteResourceResolverProvider"/>
39+
<applicationService serviceInterface="software.aws.toolkits.jetbrains.core.notifications.NotificationResourceResolverProvider"
40+
serviceImplementation="software.aws.toolkits.jetbrains.core.notifications.DefaultNotificationResourceResolverProvider"/>
41+
3942
<applicationService serviceInterface="migration.software.aws.toolkits.core.clients.SdkClientProvider"
4043
serviceImplementation="software.aws.toolkits.jetbrains.core.AwsSdkClient"/>
4144
<applicationService serviceInterface="migration.software.aws.toolkits.core.ToolkitClientManager"

plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

Lines changed: 16 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,17 @@ import com.intellij.openapi.components.Service
1010
import com.intellij.openapi.util.registry.Registry
1111
import com.intellij.util.Alarm
1212
import com.intellij.util.AlarmFactory
13-
import com.intellij.util.io.HttpRequests
1413
import kotlinx.coroutines.delay
1514
import kotlinx.coroutines.runBlocking
1615
import software.aws.toolkits.core.utils.RemoteResolveParser
1716
import software.aws.toolkits.core.utils.RemoteResource
18-
import software.aws.toolkits.core.utils.error
1917
import software.aws.toolkits.core.utils.getLogger
2018
import software.aws.toolkits.core.utils.info
2119
import software.aws.toolkits.core.utils.warn
22-
import software.aws.toolkits.jetbrains.core.DefaultRemoteResourceResolverProvider
23-
import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider
2420
import software.aws.toolkits.telemetry.Component
2521
import software.aws.toolkits.telemetry.ToolkitTelemetry
2622
import java.io.InputStream
2723
import java.time.Duration
28-
import java.util.concurrent.atomic.AtomicBoolean
2924

3025
private const val MAX_RETRIES = 3
3126
private const val RETRY_DELAY_MS = 1000L
@@ -47,11 +42,10 @@ object NotificationEndpoint {
4742

4843
@Service(Service.Level.APP)
4944
internal final class NotificationPollingService : Disposable {
50-
private val isFirstPoll = AtomicBoolean(true)
5145
private val observers = mutableListOf<() -> Unit>()
5246
private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this)
5347
private val pollingIntervalMs = Duration.ofMinutes(10).toMillis()
54-
private val resourceResolver: RemoteResourceResolverProvider = DefaultRemoteResourceResolverProvider()
48+
private val resourceResolver: NotificationResourceResolverProvider = DefaultNotificationResourceResolverProvider()
5549
private val notificationsResource = object : RemoteResource {
5650
override val name: String = "notifications.json"
5751
override val urls: List<String> = listOf(NotificationEndpoint.getEndpoint())
@@ -62,7 +56,6 @@ internal final class NotificationPollingService : Disposable {
6256

6357
fun startPolling() {
6458
val newNotifications = runBlocking { pollForNotifications() }
65-
isFirstPoll.set(false)
6659
if (newNotifications) {
6760
notifyObservers()
6861
}
@@ -72,37 +65,33 @@ internal final class NotificationPollingService : Disposable {
7265
)
7366
}
7467

75-
/**
76-
* Main polling function that checks for updates and downloads if necessary
77-
* Returns the parsed notifications if successful, null otherwise
78-
*/
7968
private suspend fun pollForNotifications(): Boolean {
8069
var retryCount = 0
8170
var lastException: Exception? = null
82-
8371
while (retryCount < MAX_RETRIES) {
8472
LOG.info { "Polling for notifications" }
8573
try {
86-
val newETag = getNotificationETag()
87-
if (newETag == NotificationEtagState.getInstance().etag) {
88-
// for when we need to notify on first poll even when there's no new ETag
89-
if (isFirstPoll.compareAndSet(true, false)) {
74+
when (resourceResolver.get().checkForUpdates()) {
75+
is UpdateCheckResult.HasUpdates -> {
76+
resourceResolver.get()
77+
.resolve(notificationsResource)
78+
.toCompletableFuture()
79+
.get()
80+
LOG.info { "New notifications fetched" }
81+
return true
82+
}
83+
is UpdateCheckResult.FirstPollCheck -> {
9084
LOG.info { "No new notifications, checking cached notifications on first poll" }
9185
return true
9286
}
93-
LOG.info { "No new notifications to fetch" }
94-
return false
87+
is UpdateCheckResult.NoUpdates -> {
88+
LOG.info { "No new notifications to fetch" }
89+
return false
90+
}
9591
}
96-
resourceResolver.get()
97-
.resolve(notificationsResource)
98-
.toCompletableFuture()
99-
.get()
100-
NotificationEtagState.getInstance().etag = newETag
101-
LOG.info { "New notifications fetched" }
102-
return true
10392
} catch (e: Exception) {
10493
lastException = e
105-
LOG.error(e) { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" }
94+
LOG.warn { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" }
10695
retryCount++
10796
if (retryCount < MAX_RETRIES) {
10897
val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1))
@@ -114,18 +103,6 @@ internal final class NotificationPollingService : Disposable {
114103
return false
115104
}
116105

117-
private fun getNotificationETag(): String =
118-
try {
119-
HttpRequests.request(NotificationEndpoint.getEndpoint())
120-
.userAgent("AWS Toolkit for JetBrains")
121-
.connect { request ->
122-
request.connection.headerFields["ETag"]?.firstOrNull().orEmpty()
123-
}
124-
} catch (e: Exception) {
125-
LOG.warn { "Failed to fetch notification ETag: $e.message" }
126-
throw e
127-
}
128-
129106
private fun emitFailureMetric(e: Exception?) {
130107
ToolkitTelemetry.showNotification(
131108
project = null,

plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,52 @@
33

44
package software.aws.toolkits.jetbrains.core.notifications
55

6+
import com.intellij.openapi.application.PathManager
7+
import com.intellij.openapi.components.service
8+
import com.intellij.util.io.HttpRequests
9+
import com.intellij.util.io.createDirectories
610
import software.aws.toolkits.core.utils.DefaultRemoteResourceResolver
711
import software.aws.toolkits.core.utils.RemoteResource
812
import software.aws.toolkits.core.utils.RemoteResourceResolver
9-
import software.aws.toolkits.jetbrains.core.saveFileFromUrl
13+
import software.aws.toolkits.core.utils.UrlFetcher
1014
import software.aws.toolkits.core.utils.exists
11-
import java.nio.file.Path
12-
import java.util.concurrent.Callable
13-
import java.util.concurrent.CompletionStage
1415
import software.aws.toolkits.core.utils.getLogger
15-
import software.aws.toolkits.core.utils.info
1616
import software.aws.toolkits.core.utils.warn
17-
import com.intellij.util.io.HttpRequests
18-
import com.intellij.openapi.application.PathManager
19-
import com.intellij.util.io.createDirectories
20-
import software.aws.toolkits.core.utils.UrlFetcher
21-
import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider
17+
import software.aws.toolkits.jetbrains.core.saveFileFromUrl
2218
import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread
19+
import java.nio.file.Path
2320
import java.nio.file.Paths
21+
import java.util.concurrent.Callable
2422
import java.util.concurrent.CompletableFuture
23+
import java.util.concurrent.CompletionStage
24+
import java.util.concurrent.atomic.AtomicBoolean
2525

26-
interface NotificationRemoteResourceResolverProvider {
26+
interface NotificationResourceResolverProvider {
2727
fun get(): NotificationResourceResolver
2828

2929
companion object {
30-
fun getInstance(): NotificationRemoteResourceResolverProvider = service()
30+
fun getInstance(): NotificationResourceResolverProvider = service()
3131
}
3232
}
3333

34-
class DefaultNotificationRemoteResourceResolverProvider {
34+
class DefaultNotificationResourceResolverProvider : NotificationResourceResolverProvider {
3535
override fun get() = RESOLVER_INSTANCE
3636

3737
companion object {
3838
private val RESOLVER_INSTANCE by lazy {
3939
val cachePath = Paths.get(PathManager.getSystemPath(), "aws-notifications").createDirectories()
4040

41-
NotificationResourceResolver(
42-
urlFetcher = HttpRequestUrlFetcher,
43-
cacheBasePath = cachePath,
44-
executor = { callable ->
45-
val future = CompletableFuture<Path>()
46-
pluginAwareExecuteOnPooledThread {
47-
try {
48-
future.complete(callable.call())
49-
} catch (e: Exception) {
50-
future.completeExceptionally(e)
51-
}
41+
NotificationResourceResolver(HttpRequestUrlFetcher, cachePath) {
42+
val future = CompletableFuture<Path>()
43+
pluginAwareExecuteOnPooledThread {
44+
try {
45+
future.complete(it.call())
46+
} catch (e: Exception) {
47+
future.completeExceptionally(e)
5248
}
53-
future
5449
}
55-
)
50+
future
51+
}
5652
}
5753

5854
object HttpRequestUrlFetcher : UrlFetcher {
@@ -63,44 +59,50 @@ class DefaultNotificationRemoteResourceResolverProvider {
6359
}
6460
}
6561

62+
sealed class UpdateCheckResult {
63+
object HasUpdates : UpdateCheckResult()
64+
object NoUpdates : UpdateCheckResult()
65+
object FirstPollCheck : UpdateCheckResult()
66+
}
6667

6768
class NotificationResourceResolver(
6869
private val urlFetcher: UrlFetcher,
6970
private val cacheBasePath: Path,
7071
private val executor: (Callable<Path>) -> CompletionStage<Path>,
71-
private val etagState: NotificationEtagState = NotificationEtagState.getInstance()
7272
) : RemoteResourceResolver {
7373
private val delegate = DefaultRemoteResourceResolver(urlFetcher, cacheBasePath, executor)
74+
private val etagState: NotificationEtagState = NotificationEtagState.getInstance()
75+
private val isFirstPoll = AtomicBoolean(true)
7476

7577
fun getLocalResourcePath(resourceName: String): Path? {
7678
val expectedLocation = cacheBasePath.resolve(resourceName)
7779
return expectedLocation.existsOrNull()
7880
}
7981

80-
override fun resolve(resource: RemoteResource): CompletionStage<Path> {
81-
return executor(Callable { internalResolve(resource) })
82-
}
82+
fun checkForUpdates(): UpdateCheckResult {
83+
val hasETagUpdate = updateETags()
8384

84-
private fun internalResolve(resource: RemoteResource): Path {
85-
val expectedLocation = cacheBasePath.resolve(resource.name)
86-
val current = expectedLocation.existsOrNull()
87-
88-
if (current != null) {
89-
val currentEtag = etagState.etag
90-
try {
91-
val remoteEtag = getEndpointETag()
92-
if (currentEtag == remoteEtag) {
93-
LOG.info { "Existing file ($current) matches remote etag - using cached version" }
94-
return current
95-
}
96-
} catch (e: Exception) {
97-
LOG.warn(e) { "Failed to check remote etag, using cached version if available" }
98-
return current
99-
}
85+
// for when we need to notify on first poll even when there's no new ETag
86+
if (isFirstPoll.compareAndSet(true, false) && !hasETagUpdate) {
87+
return UpdateCheckResult.FirstPollCheck
10088
}
10189

102-
// Use delegate for download logic
103-
return delegate.resolve(resource).toCompletableFuture().get()
90+
return if (hasETagUpdate) {
91+
UpdateCheckResult.HasUpdates
92+
} else {
93+
UpdateCheckResult.NoUpdates
94+
}
95+
}
96+
97+
fun updateETags(): Boolean {
98+
val currentEtag = etagState.etag
99+
val remoteEtag = getEndpointETag()
100+
etagState.etag = remoteEtag
101+
return currentEtag != remoteEtag
102+
}
103+
104+
override fun resolve(resource: RemoteResource): CompletionStage<Path> {
105+
return delegate.resolve(resource)
104106
}
105107

106108
private fun getEndpointETag(): String =
@@ -122,6 +124,5 @@ class NotificationResourceResolver(
122124
} else {
123125
null
124126
}
125-
126127
}
127128
}

plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
99
import com.fasterxml.jackson.module.kotlin.readValue
1010
import com.intellij.notification.NotificationType
1111
import com.intellij.openapi.actionSystem.AnAction
12-
import com.intellij.openapi.application.PathManager
1312
import com.intellij.openapi.components.Service
1413
import com.intellij.openapi.components.service
1514
import com.intellij.openapi.project.Project
1615
import software.aws.toolkits.core.utils.getLogger
1716
import software.aws.toolkits.core.utils.info
1817
import software.aws.toolkits.core.utils.inputStream
18+
import software.aws.toolkits.core.utils.warn
1919
import software.aws.toolkits.jetbrains.utils.notifyStickyWithData
20-
import java.nio.file.Paths
2120
import java.util.concurrent.atomic.AtomicBoolean
2221

2322
object NotificationMapperUtil {
@@ -38,12 +37,24 @@ class ProcessNotificationsBase(
3837
}
3938

4039
private fun getNotificationsFromFile(): NotificationsList? {
41-
val path = Paths.get(PathManager.getSystemPath(), NOTIFICATIONS_PATH)
42-
val content = path.inputStream().bufferedReader().use { it.readText() }
43-
if (content.isEmpty()) {
40+
try {
41+
val path = NotificationResourceResolverProvider
42+
.getInstance()
43+
.get()
44+
.getLocalResourcePath("notifications.json")
45+
if (path == null) {
46+
LOG.warn { "Notifications file not found" }
47+
return null
48+
}
49+
val content = path.inputStream().bufferedReader().use { it.readText() }
50+
if (content.isEmpty()) {
51+
return null
52+
}
53+
return NotificationMapperUtil.mapper.readValue(content)
54+
} catch (e: Exception) {
55+
LOG.warn { "Error reading notifications file: $e" }
4456
return null
4557
}
46-
return NotificationMapperUtil.mapper.readValue(content)
4758
}
4859

4960
fun retrieveStartupAndEmergencyNotifications() {
@@ -113,8 +124,6 @@ class ProcessNotificationsBase(
113124
companion object {
114125
private val LOG = getLogger<ProcessNotificationsBase>()
115126
fun getInstance(project: Project): ProcessNotificationsBase = project.service()
116-
117-
private const val NOTIFICATIONS_PATH = "aws-static-resources/notifications.json"
118127
}
119128
}
120129

0 commit comments

Comments
 (0)