-
Notifications
You must be signed in to change notification settings - Fork 274
Poll for new notifications #5119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 41 commits
754a269
bca91eb
d894d86
6beeadc
c233764
6f6dd6d
6bf7007
9d32c86
7952d9b
fd2d1fb
cff580f
2d2ffaf
a08194c
17b49fc
c5718f7
691d131
4e588bf
a4427ab
2ce16c3
c54884d
5f140d4
b8b6950
098bb78
ce834c8
50ef8fd
6c76ec4
cc02c33
f04da83
f81d96f
9375226
7add727
35a2b69
aacc8e8
d75d8e9
a14e6c1
cbc2f4d
580674e
fcd46a6
8fba3a3
84ed1ef
26caf31
8546d4a
22420b1
01bb9ef
7097fd9
49924d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| // 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.fasterxml.jackson.module.kotlin.readValue | ||
| import com.intellij.openapi.Disposable | ||
| import com.intellij.openapi.application.ApplicationManager | ||
| import com.intellij.openapi.components.Service | ||
| 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 org.jetbrains.annotations.VisibleForTesting | ||
| 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.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 | ||
Check warningCode scanning / QDJVMC Usage of redundant or deprecated syntax or deprecated symbols Warning
'ToolkitTelemetry' is deprecated. Use type-safe metric builders
|
||
| 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 | ||
|
|
||
| object NotificationFileValidator : RemoteResolveParser { | ||
| override fun canBeParsed(data: InputStream): Boolean = | ||
| try { | ||
| NotificationMapperUtil.mapper.readValue<NotificationsList>(data) | ||
| true | ||
| } catch (e: Exception) { | ||
| false | ||
| } | ||
| } | ||
|
|
||
| object NotificationEndpoint { | ||
| private var overriddenEndpoint: String? = null | ||
|
|
||
| fun getEndpoint(): String = overriddenEndpoint ?: DEFAULT_ENDPOINT | ||
|
|
||
| @VisibleForTesting | ||
| fun setTestEndpoint(endpoint: String) { | ||
|
||
| overriddenEndpoint = endpoint | ||
| } | ||
|
|
||
| @VisibleForTesting | ||
| fun resetEndpoint() { | ||
|
||
| overriddenEndpoint = null | ||
| } | ||
|
|
||
| private const val DEFAULT_ENDPOINT = "" // TODO: Replace with actual endpoint | ||
| } | ||
|
|
||
| @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 notificationsResource = object : RemoteResource { | ||
| override val name: String = "notifications.json" | ||
| override val urls: List<String> = listOf(NotificationEndpoint.getEndpoint()) | ||
| override val remoteResolveParser: RemoteResolveParser = NotificationFileValidator | ||
| } | ||
|
|
||
| fun startPolling() { | ||
| val newNotifications = runBlocking { pollForNotifications() } | ||
| if (newNotifications) { | ||
| notifyObservers() | ||
| } | ||
| alarm.addRequest( | ||
| { startPolling() }, | ||
| pollingIntervalMs | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * 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) { | ||
| 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)) { | ||
| notifyObservers() | ||
samgst-amazon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| return false | ||
| } | ||
| resourceResolver.get() | ||
| .resolve(notificationsResource) | ||
| .toCompletableFuture() | ||
| .get() | ||
Check warningCode scanning / QDJVMC Possibly blocking call in non-blocking context Warning
Possibly blocking call in non-blocking context could lead to thread starvation
|
||
| NotificationEtagState.getInstance().etag = newETag | ||
| return true | ||
| } catch (e: Exception) { | ||
| lastException = e | ||
| LOG.error(e) { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" } | ||
| retryCount++ | ||
| if (retryCount < MAX_RETRIES) { | ||
| val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1)) | ||
| delay(backoffDelay) | ||
| } | ||
| } | ||
| } | ||
| emitFailureMetric(lastException) | ||
| 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( | ||
Check warningCode scanning / QDJVMC Usage of redundant or deprecated syntax or deprecated symbols Warning
'ToolkitTelemetry' is deprecated. Use type-safe metric builders
|
||
| project = null, | ||
| component = Component.Filesystem, | ||
| id = "", | ||
| reason = "Failed to poll for notifications", | ||
| success = false, | ||
| reasonDesc = "${e?.javaClass?.simpleName ?: "Unknown"}: ${e?.message ?: "No message"}", | ||
| ) | ||
| } | ||
|
|
||
| fun addObserver(observer: () -> Unit) = observers.add(observer) | ||
|
|
||
| private fun notifyObservers() { | ||
| observers.forEach { observer -> | ||
| observer() | ||
| } | ||
| } | ||
|
|
||
| override fun dispose() { | ||
| alarm.dispose() | ||
| } | ||
|
|
||
| companion object { | ||
| private val LOG = getLogger<NotificationPollingService>() | ||
| fun getInstance(): NotificationPollingService = | ||
| ApplicationManager.getApplication().getService(NotificationPollingService::class.java) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| // 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.project.Project | ||
| import com.intellij.openapi.startup.ProjectActivity | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
|
|
||
| internal class NotificationServiceInitializer : ProjectActivity { | ||
|
|
||
| private val initialized = AtomicBoolean(false) | ||
|
|
||
| override suspend fun execute(project: Project) { | ||
| if (initialized.compareAndSet(false, true)) { | ||
| val service = NotificationPollingService.getInstance() | ||
| service.startPolling() | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| // 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.components.PersistentStateComponent | ||
| import com.intellij.openapi.components.RoamingType | ||
| import com.intellij.openapi.components.Service | ||
| import com.intellij.openapi.components.State | ||
| import com.intellij.openapi.components.Storage | ||
| import com.intellij.openapi.components.service | ||
|
|
||
| @Service | ||
| @State(name = "notificationDismissals", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) | ||
| class NotificationDismissalState : PersistentStateComponent<NotificationDismissalConfiguration> { | ||
| private val state = NotificationDismissalConfiguration() | ||
|
|
||
| override fun getState(): NotificationDismissalConfiguration = state | ||
|
|
||
| override fun loadState(state: NotificationDismissalConfiguration) { | ||
| this.state.dismissedNotificationIds.clear() | ||
| this.state.dismissedNotificationIds.addAll(state.dismissedNotificationIds) | ||
| } | ||
|
|
||
| fun isDismissed(notificationId: String): Boolean = | ||
| state.dismissedNotificationIds.contains(notificationId) | ||
|
|
||
| fun dismissNotification(notificationId: String) { | ||
| state.dismissedNotificationIds.add(notificationId) | ||
| } | ||
|
|
||
| companion object { | ||
| fun getInstance(): NotificationDismissalState = | ||
| service() | ||
| } | ||
| } | ||
|
|
||
| data class NotificationDismissalConfiguration( | ||
| var dismissedNotificationIds: MutableSet<String> = mutableSetOf(), | ||
| ) | ||
|
|
||
| @Service | ||
| @State(name = "notificationEtag", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) | ||
| class NotificationEtagState : PersistentStateComponent<NotificationEtagConfiguration> { | ||
| private val state = NotificationEtagConfiguration() | ||
|
|
||
| override fun getState(): NotificationEtagConfiguration = state | ||
|
|
||
| override fun loadState(state: NotificationEtagConfiguration) { | ||
| this.state.etag = state.etag | ||
| } | ||
|
|
||
| var etag: String? | ||
| get() = state.etag | ||
| set(value) { | ||
| state.etag = value | ||
| } | ||
|
|
||
| companion object { | ||
| fun getInstance(): NotificationEtagState = | ||
| service() | ||
| } | ||
| } | ||
|
|
||
| data class NotificationEtagConfiguration( | ||
| var etag: String? = null, | ||
| ) | ||
|
|
||
| @Service | ||
| class BannerNotificationService { | ||
| private val notifications = mutableMapOf<String, BannerContent>() | ||
|
|
||
| fun addNotification(id: String, content: BannerContent) { | ||
| notifications[id] = content | ||
| } | ||
|
|
||
| fun getNotifications(): Map<String, BannerContent> = notifications | ||
|
|
||
| fun removeNotification(id: String) { | ||
| notifications.remove(id) | ||
| } | ||
|
|
||
| companion object { | ||
| fun getInstance(): BannerNotificationService = | ||
| service() | ||
| } | ||
| } |
Check warning
Code scanning / QDJVMC
Usage of redundant or deprecated syntax or deprecated symbols Warning