Skip to content

Commit 3841778

Browse files
use RemoteResourceResolver in notification polling for etag and PATH handling (#5165)
* always fetch from endpoint during poll * notification resource resolver * ResourceResolver etag handling * codescan * remove redundant modifier * move functionality to DefaultRemoteResourceResolver * implement defaultRemoteResourceResolverProvider instead * url deprecated * detekt * detektTest fix mock functions * detekt * default function in interface * re-implement HTTPRequest * re-implement HTTPRequest * ETag fix * LazyLogRule
1 parent 538b591 commit 3841778

File tree

7 files changed

+187
-84
lines changed

7 files changed

+187
-84
lines changed

plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,62 @@ import java.time.Instant
1313
import java.util.UUID
1414
import java.util.concurrent.Callable
1515
import java.util.concurrent.CompletionStage
16+
import java.util.concurrent.atomic.AtomicBoolean
1617

1718
interface RemoteResourceResolver {
1819
fun resolve(resource: RemoteResource): CompletionStage<Path>
20+
fun checkForUpdates(endpoint: String, eTagProvider: ETagProvider): UpdateCheckResult = UpdateCheckResult.NoUpdates
21+
fun getLocalResourcePath(filename: String): Path? = null
1922
}
2023
interface RemoteResolveParser {
2124
fun canBeParsed(data: InputStream): Boolean
2225
}
2326

27+
interface ETagProvider {
28+
var etag: String?
29+
fun updateETag(newETag: String?)
30+
}
31+
32+
sealed class UpdateCheckResult {
33+
data object HasUpdates : UpdateCheckResult()
34+
data object NoUpdates : UpdateCheckResult()
35+
data object FirstPollCheck : UpdateCheckResult()
36+
}
37+
2438
class DefaultRemoteResourceResolver(
2539
private val urlFetcher: UrlFetcher,
2640
private val cacheBasePath: Path,
2741
private val executor: (Callable<Path>) -> CompletionStage<Path>,
2842
) : RemoteResourceResolver {
43+
private val isFirstPoll = AtomicBoolean(true)
2944

3045
override fun resolve(resource: RemoteResource): CompletionStage<Path> = executor(Callable { internalResolve(resource) })
3146

47+
override fun getLocalResourcePath(filename: String): Path? {
48+
val expectedLocation = cacheBasePath.resolve(filename)
49+
return expectedLocation.existsOrNull()
50+
}
51+
52+
override fun checkForUpdates(endpoint: String, eTagProvider: ETagProvider): UpdateCheckResult {
53+
val hasETagUpdate = updateETags(eTagProvider, endpoint)
54+
// for when we need to notify on first poll even when there's no new ETag
55+
if (isFirstPoll.compareAndSet(true, false) && !hasETagUpdate) {
56+
return UpdateCheckResult.FirstPollCheck
57+
}
58+
59+
return if (hasETagUpdate) {
60+
UpdateCheckResult.HasUpdates
61+
} else {
62+
UpdateCheckResult.NoUpdates
63+
}
64+
}
65+
3266
private fun internalResolve(resource: RemoteResource): Path {
3367
val expectedLocation = cacheBasePath.resolve(resource.name)
3468
val current = expectedLocation.existsOrNull()
35-
if (resource.name != "notifications.json") {
36-
if ((current != null && !isExpired(current, resource))) {
37-
LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." }
38-
return current
39-
}
69+
if (current != null && !isExpired(current, resource)) {
70+
LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." }
71+
return current
4072
}
4173

4274
LOG.debug { "Current file for ${resource.name} does not exist or is expired. Attempting to fetch from ${resource.urls}" }
@@ -84,6 +116,21 @@ class DefaultRemoteResourceResolver(
84116
return expectedLocation
85117
}
86118

119+
private fun updateETags(eTagProvider: ETagProvider, endpoint: String): Boolean {
120+
val currentEtag = eTagProvider.etag
121+
val remoteEtag = getEndpointETag(endpoint)
122+
eTagProvider.etag = remoteEtag
123+
return currentEtag != remoteEtag
124+
}
125+
126+
private fun getEndpointETag(endpoint: String): String =
127+
try {
128+
urlFetcher.getETag(endpoint)
129+
} catch (e: Exception) {
130+
LOG.warn { "Failed to fetch ETag: $e.message" }
131+
throw e
132+
}
133+
87134
private companion object {
88135
val LOG = getLogger<RemoteResourceResolver>()
89136
fun Path.existsOrNull() = if (this.exists()) {
@@ -105,6 +152,7 @@ class DefaultRemoteResourceResolver(
105152

106153
interface UrlFetcher {
107154
fun fetch(url: String, file: Path)
155+
fun getETag(url: String): String
108156
}
109157

110158
/**

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package software.aws.toolkits.jetbrains.core
55

66
import com.intellij.openapi.application.PathManager
7+
import com.intellij.util.io.HttpRequests
78
import com.intellij.util.io.createDirectories
89
import software.aws.toolkits.core.utils.DefaultRemoteResourceResolver
910
import software.aws.toolkits.core.utils.UrlFetcher
@@ -38,6 +39,13 @@ class DefaultRemoteResourceResolverProvider : RemoteResourceResolverProvider {
3839
override fun fetch(url: String, file: Path) {
3940
saveFileFromUrl(url, file)
4041
}
42+
43+
override fun getETag(url: String): String =
44+
HttpRequests.head(url)
45+
.userAgent("AWS Toolkit for JetBrains")
46+
.connect { request ->
47+
request.connection.headerFields["ETag"]?.firstOrNull().orEmpty()
48+
}
4149
}
4250
}
4351
}

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

Lines changed: 25 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@ 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
17+
import software.aws.toolkits.core.utils.UpdateCheckResult
1918
import software.aws.toolkits.core.utils.getLogger
2019
import software.aws.toolkits.core.utils.info
2120
import software.aws.toolkits.core.utils.warn
@@ -25,10 +24,10 @@ import software.aws.toolkits.telemetry.Component
2524
import software.aws.toolkits.telemetry.ToolkitTelemetry
2625
import java.io.InputStream
2726
import java.time.Duration
28-
import java.util.concurrent.atomic.AtomicBoolean
2927

3028
private const val MAX_RETRIES = 3
3129
private const val RETRY_DELAY_MS = 1000L
30+
internal const val FILENAME = "notifications.json"
3231

3332
object NotificationFileValidator : RemoteResolveParser {
3433
override fun canBeParsed(data: InputStream): Boolean =
@@ -47,20 +46,20 @@ object NotificationEndpoint {
4746

4847
@Service(Service.Level.APP)
4948
internal final class NotificationPollingService : Disposable {
50-
private val isFirstPoll = AtomicBoolean(true)
5149
private val observers = mutableListOf<() -> Unit>()
5250
private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this)
5351
private val pollingIntervalMs = Duration.ofMinutes(10).toMillis()
5452
private val resourceResolver: RemoteResourceResolverProvider = DefaultRemoteResourceResolverProvider()
5553
private val notificationsResource = object : RemoteResource {
56-
override val name: String = "notifications.json"
54+
override val name: String = FILENAME
5755
override val urls: List<String> = listOf(NotificationEndpoint.getEndpoint())
5856
override val remoteResolveParser: RemoteResolveParser = NotificationFileValidator
57+
override val ttl: Duration = Duration.ofMillis(1)
58+
// ttl forces resolver to fetch from endpoint every time
5959
}
6060

6161
fun startPolling() {
6262
val newNotifications = runBlocking { pollForNotifications() }
63-
isFirstPoll.set(false)
6463
if (newNotifications) {
6564
notifyObservers()
6665
}
@@ -70,37 +69,38 @@ internal final class NotificationPollingService : Disposable {
7069
)
7170
}
7271

73-
/**
74-
* Main polling function that checks for updates and downloads if necessary
75-
* Returns the parsed notifications if successful, null otherwise
76-
*/
7772
private suspend fun pollForNotifications(): Boolean {
7873
var retryCount = 0
7974
var lastException: Exception? = null
80-
8175
while (retryCount < MAX_RETRIES) {
8276
LOG.info { "Polling for notifications" }
8377
try {
84-
val newETag = getNotificationETag()
85-
if (newETag == NotificationEtagState.getInstance().etag) {
86-
// for when we need to notify on first poll even when there's no new ETag
87-
if (isFirstPoll.compareAndSet(true, false)) {
78+
when (
79+
resourceResolver.get().checkForUpdates(
80+
NotificationEndpoint.getEndpoint(),
81+
NotificationEtagState.getInstance()
82+
)
83+
) {
84+
is UpdateCheckResult.HasUpdates -> {
85+
resourceResolver.get()
86+
.resolve(notificationsResource)
87+
.toCompletableFuture()
88+
.get()
89+
LOG.info { "New notifications fetched" }
90+
return true
91+
}
92+
is UpdateCheckResult.FirstPollCheck -> {
8893
LOG.info { "No new notifications, checking cached notifications on first poll" }
8994
return true
9095
}
91-
LOG.info { "No new notifications to fetch" }
92-
return false
96+
is UpdateCheckResult.NoUpdates -> {
97+
LOG.info { "No new notifications to fetch" }
98+
return false
99+
}
93100
}
94-
resourceResolver.get()
95-
.resolve(notificationsResource)
96-
.toCompletableFuture()
97-
.get()
98-
NotificationEtagState.getInstance().etag = newETag
99-
LOG.info { "New notifications fetched" }
100-
return true
101101
} catch (e: Exception) {
102102
lastException = e
103-
LOG.error(e) { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" }
103+
LOG.warn { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" }
104104
retryCount++
105105
if (retryCount < MAX_RETRIES) {
106106
val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1))
@@ -112,18 +112,6 @@ internal final class NotificationPollingService : Disposable {
112112
return false
113113
}
114114

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

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.intellij.openapi.components.Service
99
import com.intellij.openapi.components.State
1010
import com.intellij.openapi.components.Storage
1111
import com.intellij.openapi.components.service
12+
import software.aws.toolkits.core.utils.ETagProvider
1213

1314
@Service
1415
@State(name = "notificationDismissals", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)])
@@ -41,16 +42,20 @@ data class NotificationDismissalConfiguration(
4142

4243
@Service
4344
@State(name = "notificationEtag", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)])
44-
class NotificationEtagState : PersistentStateComponent<NotificationEtagConfiguration> {
45+
class NotificationEtagState : PersistentStateComponent<NotificationEtagConfiguration>, ETagProvider {
4546
private val state = NotificationEtagConfiguration()
4647

48+
override fun updateETag(newETag: String?) {
49+
etag = newETag
50+
}
51+
4752
override fun getState(): NotificationEtagConfiguration = state
4853

4954
override fun loadState(state: NotificationEtagConfiguration) {
5055
this.state.etag = state.etag
5156
}
5257

53-
var etag: String?
58+
override var etag: String?
5459
get() = state.etag
5560
set(value) {
5661
state.etag = value

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,18 @@ 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
1918
import software.aws.toolkits.core.utils.warn
19+
import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider
2020
import software.aws.toolkits.jetbrains.utils.notifyStickyWithData
2121
import software.aws.toolkits.telemetry.Component
2222
import software.aws.toolkits.telemetry.Result
2323
import software.aws.toolkits.telemetry.ToolkitTelemetry
24-
import java.nio.file.Paths
2524
import java.util.concurrent.atomic.AtomicBoolean
2625

2726
object NotificationMapperUtil {
@@ -43,7 +42,14 @@ class ProcessNotificationsBase(
4342

4443
private fun getNotificationsFromFile(): NotificationsList? {
4544
try {
46-
val path = Paths.get(PathManager.getSystemPath(), NOTIFICATIONS_PATH)
45+
val path = RemoteResourceResolverProvider
46+
.getInstance()
47+
.get()
48+
.getLocalResourcePath(FILENAME)
49+
if (path == null) {
50+
LOG.warn { "Notifications file not found" }
51+
return null
52+
}
4753
val content = path.inputStream().bufferedReader().use { it.readText() }
4854
if (content.isEmpty()) {
4955
return null
@@ -127,8 +133,6 @@ class ProcessNotificationsBase(
127133
companion object {
128134
private val LOG = getLogger<ProcessNotificationsBase>()
129135
fun getInstance(project: Project): ProcessNotificationsBase = project.service()
130-
131-
private const val NOTIFICATIONS_PATH = "aws-static-resources/notifications.json"
132136
}
133137
}
134138

0 commit comments

Comments
 (0)