Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d8b6caf
always fetch from endpoint during poll
samgst-amazon Dec 3, 2024
02f335b
notification resource resolver
samgst-amazon Dec 3, 2024
54c7d02
ResourceResolver etag handling
samgst-amazon Dec 5, 2024
d96aa00
Merge branch 'main' into samgst/NotificationResourceResolver
samgst-amazon Dec 5, 2024
4231ba5
codescan
samgst-amazon Dec 5, 2024
ba0c57b
Merge branch 'main' into samgst/NotificationResourceResolver
samgst-amazon Dec 5, 2024
08c6001
remove redundant modifier
samgst-amazon Dec 5, 2024
de5c8be
Merge branch 'main' into samgst/NotificationResourceResolver
samgst-amazon Dec 5, 2024
3bcc9ea
Merge branch 'main' into samgst/NotificationResourceResolver
samgst-amazon Dec 6, 2024
4b80f54
move functionality to DefaultRemoteResourceResolver
samgst-amazon Dec 6, 2024
0aa2074
implement defaultRemoteResourceResolverProvider instead
samgst-amazon Dec 6, 2024
d18b689
url deprecated
samgst-amazon Dec 6, 2024
bc9396d
detekt
samgst-amazon Dec 9, 2024
ca8e967
Merge branch 'main' into samgst/NotificationResourceResolver
samgst-amazon Dec 9, 2024
5dcd476
detektTest fix mock functions
samgst-amazon Dec 9, 2024
c78a536
detekt
samgst-amazon Dec 9, 2024
12ffed4
Merge branch 'main' into samgst/NotificationResourceResolver
samgst-amazon Dec 9, 2024
0ffa31e
Merge branch 'main' into samgst/NotificationResourceResolver
samgst-amazon Dec 9, 2024
0d992c7
default function in interface
samgst-amazon Dec 9, 2024
9d4a1bb
re-implement HTTPRequest
samgst-amazon Dec 9, 2024
b2b9849
re-implement HTTPRequest
samgst-amazon Dec 9, 2024
af54b0f
ETag fix
samgst-amazon Dec 10, 2024
b289ce1
LazyLogRule
samgst-amazon Dec 10, 2024
794364d
Merge branch 'main' into samgst/NotificationResourceResolver
samgst-amazon Dec 10, 2024
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
Expand Up @@ -32,11 +32,9 @@ class DefaultRemoteResourceResolver(
private fun internalResolve(resource: RemoteResource): Path {
val expectedLocation = cacheBasePath.resolve(resource.name)
val current = expectedLocation.existsOrNull()
if (resource.name != "notifications.json") {
if ((current != null && !isExpired(current, resource))) {
LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." }
return current
}
if (current != null && !isExpired(current, resource)) {
LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." }
return current
}

LOG.debug { "Current file for ${resource.name} does not exist or is expired. Attempting to fetch from ${resource.urls}" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
testServiceImplementation="software.aws.toolkits.jetbrains.core.region.MockRegionProvider"/>
<applicationService serviceInterface="migration.software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider"
serviceImplementation="software.aws.toolkits.jetbrains.core.DefaultRemoteResourceResolverProvider"/>
<applicationService serviceInterface="software.aws.toolkits.jetbrains.core.notifications.NotificationResourceResolverProvider"
serviceImplementation="software.aws.toolkits.jetbrains.core.notifications.DefaultNotificationResourceResolverProvider"/>

<applicationService serviceInterface="migration.software.aws.toolkits.core.clients.SdkClientProvider"
serviceImplementation="software.aws.toolkits.jetbrains.core.AwsSdkClient"/>
<applicationService serviceInterface="migration.software.aws.toolkits.core.ToolkitClientManager"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,17 @@
import com.intellij.openapi.util.registry.Registry
import com.intellij.util.Alarm
import com.intellij.util.AlarmFactory
import com.intellij.util.io.HttpRequests
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import software.aws.toolkits.core.utils.RemoteResolveParser
import software.aws.toolkits.core.utils.RemoteResource
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.core.DefaultRemoteResourceResolverProvider
import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider
import software.aws.toolkits.telemetry.Component
import software.aws.toolkits.telemetry.ToolkitTelemetry
import java.io.InputStream
import java.time.Duration
import java.util.concurrent.atomic.AtomicBoolean

private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
Expand All @@ -47,20 +42,20 @@

@Service(Service.Level.APP)
internal final class NotificationPollingService : Disposable {
private val isFirstPoll = AtomicBoolean(true)
private val observers = mutableListOf<() -> Unit>()
private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this)
private val pollingIntervalMs = Duration.ofMinutes(10).toMillis()
private val resourceResolver: RemoteResourceResolverProvider = DefaultRemoteResourceResolverProvider()
private val resourceResolver: NotificationResourceResolverProvider = DefaultNotificationResourceResolverProvider()
private val notificationsResource = object : RemoteResource {
override val name: String = "notifications.json"
override val urls: List<String> = listOf(NotificationEndpoint.getEndpoint())
override val remoteResolveParser: RemoteResolveParser = NotificationFileValidator
override val ttl: Duration = Duration.ofMillis(1)
// ttl forces resolver to fetch from endpoint every time
}

fun startPolling() {
val newNotifications = runBlocking { pollForNotifications() }
isFirstPoll.set(false)
if (newNotifications) {
notifyObservers()
}
Expand All @@ -70,37 +65,33 @@
)
}

/**
* Main polling function that checks for updates and downloads if necessary
* Returns the parsed notifications if successful, null otherwise
*/
private suspend fun pollForNotifications(): Boolean {
var retryCount = 0
var lastException: Exception? = null

while (retryCount < MAX_RETRIES) {
LOG.info { "Polling for notifications" }
try {
val newETag = getNotificationETag()
if (newETag == NotificationEtagState.getInstance().etag) {
// for when we need to notify on first poll even when there's no new ETag
if (isFirstPoll.compareAndSet(true, false)) {
when (resourceResolver.get().checkForUpdates()) {
is UpdateCheckResult.HasUpdates -> {
resourceResolver.get()
.resolve(notificationsResource)
.toCompletableFuture()
.get()
LOG.info { "New notifications fetched" }
return true
}
is UpdateCheckResult.FirstPollCheck -> {
LOG.info { "No new notifications, checking cached notifications on first poll" }
return true
}
LOG.info { "No new notifications to fetch" }
return false
is UpdateCheckResult.NoUpdates -> {
LOG.info { "No new notifications to fetch" }
return false
}
}
resourceResolver.get()
.resolve(notificationsResource)
.toCompletableFuture()
.get()
NotificationEtagState.getInstance().etag = newETag
LOG.info { "New notifications fetched" }
return true
} catch (e: Exception) {
lastException = e
LOG.error(e) { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" }
LOG.warn { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" }

Check warning on line 94 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt

View check run for this annotation

Codecov / codecov/patch

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

Added line #L94 was not covered by tests
retryCount++
if (retryCount < MAX_RETRIES) {
val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1))
Expand All @@ -112,18 +103,6 @@
return false
}

private fun getNotificationETag(): String =
try {
HttpRequests.request(NotificationEndpoint.getEndpoint())
.userAgent("AWS Toolkit for JetBrains")
.connect { request ->
request.connection.headerFields["ETag"]?.firstOrNull().orEmpty()
}
} catch (e: Exception) {
LOG.warn { "Failed to fetch notification ETag: $e.message" }
throw e
}

private fun emitFailureMetric(e: Exception?) {
ToolkitTelemetry.showNotification(
project = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

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

import com.intellij.openapi.application.PathManager
import com.intellij.openapi.components.service
import com.intellij.util.io.HttpRequests
import com.intellij.util.io.createDirectories
import software.aws.toolkits.core.utils.DefaultRemoteResourceResolver
import software.aws.toolkits.core.utils.RemoteResource
import software.aws.toolkits.core.utils.RemoteResourceResolver
import software.aws.toolkits.core.utils.UrlFetcher
import software.aws.toolkits.core.utils.exists
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.core.saveFileFromUrl
import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread
import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.Callable
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionStage
import java.util.concurrent.atomic.AtomicBoolean

interface NotificationResourceResolverProvider {
fun get(): NotificationResourceResolver

companion object {
fun getInstance(): NotificationResourceResolverProvider = service()

Check warning on line 30 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View check run for this annotation

Codecov / codecov/patch

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

Added line #L30 was not covered by tests
}
}

internal final class DefaultNotificationResourceResolverProvider : NotificationResourceResolverProvider {

Check warning on line 34 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Redundant modality modifier

Redundant modality modifier
override fun get() = RESOLVER_INSTANCE

Check warning on line 35 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View check run for this annotation

Codecov / codecov/patch

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

Added line #L35 was not covered by tests

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

Check warning on line 39 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View check run for this annotation

Codecov / codecov/patch

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

Added line #L39 was not covered by tests

NotificationResourceResolver(HttpRequestUrlFetcher, cachePath) {
val future = CompletableFuture<Path>()
pluginAwareExecuteOnPooledThread {
try {
future.complete(it.call())
} catch (e: Exception) {
future.completeExceptionally(e)
}

Check warning on line 48 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L41 - L48 were not covered by tests
}
future
}

Check warning on line 51 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L50 - L51 were not covered by tests
}

object HttpRequestUrlFetcher : UrlFetcher {
override fun fetch(url: String, file: Path) {
saveFileFromUrl(url, file)
}

Check warning on line 57 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L56 - L57 were not covered by tests
}
}
}

sealed class UpdateCheckResult {
data object HasUpdates : UpdateCheckResult()
data object NoUpdates : UpdateCheckResult()
data object FirstPollCheck : UpdateCheckResult()
}

class NotificationResourceResolver(
Copy link
Contributor

Choose a reason for hiding this comment

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

lets fold this into DefaultRemoteResourceResolver because there is nothing special about etag handling

private val urlFetcher: UrlFetcher,

Check warning on line 69 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constructor parameter is never used as a property

Constructor parameter is never used as a property
private val cacheBasePath: Path,
private val executor: (Callable<Path>) -> CompletionStage<Path>,

Check warning on line 71 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Constructor parameter is never used as a property

Constructor parameter is never used as a property
) : RemoteResourceResolver {
private val delegate = DefaultRemoteResourceResolver(urlFetcher, cacheBasePath, executor)
private val etagState: NotificationEtagState = NotificationEtagState.getInstance()
private val isFirstPoll = AtomicBoolean(true)

override fun resolve(resource: RemoteResource): CompletionStage<Path> =
delegate.resolve(resource)

Check warning on line 78 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View check run for this annotation

Codecov / codecov/patch

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

Added line #L78 was not covered by tests

fun getLocalResourcePath(resourceName: String): Path? {
val expectedLocation = cacheBasePath.resolve(resourceName)
return expectedLocation.existsOrNull()
}

fun checkForUpdates(): UpdateCheckResult {
val hasETagUpdate = updateETags()

// for when we need to notify on first poll even when there's no new ETag
if (isFirstPoll.compareAndSet(true, false) && !hasETagUpdate) {
return UpdateCheckResult.FirstPollCheck
}

return if (hasETagUpdate) {
UpdateCheckResult.HasUpdates
} else {
UpdateCheckResult.NoUpdates
}
}

private fun updateETags(): Boolean {
val currentEtag = etagState.etag
val remoteEtag = getEndpointETag()
etagState.etag = remoteEtag
return currentEtag != remoteEtag
}

private fun getEndpointETag(): String =
try {
HttpRequests.request(NotificationEndpoint.getEndpoint())
Copy link
Contributor

Choose a reason for hiding this comment

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

use a HEAD request if all you are interested in is the headers of GET

.userAgent("AWS Toolkit for JetBrains")
.connect { request ->
request.connection.headerFields["ETag"]?.firstOrNull().orEmpty()
}
} catch (e: Exception) {
LOG.warn { "Failed to fetch notification ETag: $e.message" }

Check warning on line 115 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L114 - L115 were not covered by tests
throw e
}

companion object {
private val LOG = getLogger<NotificationResourceResolver>()
fun Path.existsOrNull() = if (this.exists()) {
this

Check warning on line 122 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationResourceResolver.kt

View check run for this annotation

Codecov / codecov/patch

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

Added line #L122 was not covered by tests
} else {
null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import com.fasterxml.jackson.module.kotlin.readValue
import com.intellij.notification.NotificationType
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
Expand All @@ -18,7 +17,6 @@
import software.aws.toolkits.core.utils.inputStream
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.utils.notifyStickyWithData
import java.nio.file.Paths
import java.util.concurrent.atomic.AtomicBoolean

object NotificationMapperUtil {
Expand All @@ -40,7 +38,14 @@

private fun getNotificationsFromFile(): NotificationsList? {
try {
val path = Paths.get(PathManager.getSystemPath(), NOTIFICATIONS_PATH)
val path = NotificationResourceResolverProvider
.getInstance()
.get()
.getLocalResourcePath("notifications.json")

Check warning on line 44 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L41 - L44 were not covered by tests
if (path == null) {
LOG.warn { "Notifications file not found" }
return null

Check warning on line 47 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L46 - L47 were not covered by tests
}
val content = path.inputStream().bufferedReader().use { it.readText() }
if (content.isEmpty()) {
return null
Expand Down Expand Up @@ -119,8 +124,6 @@
companion object {
private val LOG = getLogger<ProcessNotificationsBase>()
fun getInstance(project: Project): ProcessNotificationsBase = project.service()

private const val NOTIFICATIONS_PATH = "aws-static-resources/notifications.json"
}
}

Expand Down
Loading
Loading