Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.components.Service
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 @@ object NotificationEndpoint {

@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 @@ internal final class NotificationPollingService : Disposable {
)
}

/**
* 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)" }
retryCount++
if (retryCount < MAX_RETRIES) {
val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1))
Expand All @@ -112,18 +103,6 @@ internal final class NotificationPollingService : Disposable {
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,128 @@
// 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()
}
}

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

Extension class should be final and non-public

Service implementation should not be public. If a service is supposed to be used outside its module, extract an interface from it and specify it as serviceInterface in plugin.xml.
override fun get() = RESOLVER_INSTANCE

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

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

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

sealed class UpdateCheckResult {
object HasUpdates : UpdateCheckResult()

Check notice on line 63 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

Convert 'object' to 'data object'

'sealed' sub-object can be converted to 'data object'
object NoUpdates : UpdateCheckResult()

Check notice on line 64 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

Convert 'object' to 'data object'

'sealed' sub-object can be converted to 'data object'
object FirstPollCheck : UpdateCheckResult()

Check notice on line 65 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

Convert 'object' to 'data object'

'sealed' sub-object can be converted to 'data object'
}

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)

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
}
}

fun updateETags(): Boolean {

Check notice on line 97 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

Class member can have 'private' visibility

Function 'updateETags' could be private
val currentEtag = etagState.etag
val remoteEtag = getEndpointETag()
etagState.etag = remoteEtag
return currentEtag != remoteEtag
}

override fun resolve(resource: RemoteResource): CompletionStage<Path> {
return delegate.resolve(resource)
}

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" }
throw e
}

companion object {
private val LOG = getLogger<NotificationResourceResolver>()
fun Path.existsOrNull() = if (this.exists()) {
this
} else {
null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
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.info
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 @@ class ProcessNotificationsBase(

private fun getNotificationsFromFile(): NotificationsList? {
try {
val path = Paths.get(PathManager.getSystemPath(), NOTIFICATIONS_PATH)
val path = NotificationResourceResolverProvider
.getInstance()
.get()
.getLocalResourcePath("notifications.json")
if (path == null) {
LOG.warn { "Notifications file not found" }
return null
}
val content = path.inputStream().bufferedReader().use { it.readText() }
if (content.isEmpty()) {
return null
Expand Down Expand Up @@ -119,8 +124,6 @@ class ProcessNotificationsBase(
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