-
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 37 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,199 @@ | ||
| // 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.PersistentStateComponent | ||
| import com.intellij.openapi.components.Service | ||
| import com.intellij.openapi.components.State | ||
| import com.intellij.openapi.components.Storage | ||
| import com.intellij.util.Alarm | ||
| import com.intellij.util.AlarmFactory | ||
| import com.intellij.util.io.HttpRequests | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.cancel | ||
| import kotlinx.coroutines.delay | ||
| import kotlinx.coroutines.launch | ||
| 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.jetbrains.core.coroutines.getCoroutineBgContext | ||
| import software.aws.toolkits.telemetry.Component | ||
| import software.aws.toolkits.telemetry.ToolkitTelemetry | ||
|
Check warning on line 30 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt
|
||
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 | ||
| } | ||
|
|
||
| @State(name = "notificationEtag", storages = [Storage("aws.xml")]) | ||
| class NotificationEtagState : PersistentStateComponent<NotificationEtagConfiguration> { | ||
|
Check warning on line 67 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt
|
||
|
||
| private var state = NotificationEtagConfiguration() | ||
|
|
||
| override fun getState(): NotificationEtagConfiguration = state | ||
|
|
||
| override fun loadState(state: NotificationEtagConfiguration) { | ||
| this.state = state | ||
| } | ||
|
|
||
| var etag: String? | ||
| get() = state.etag | ||
| set(value) { | ||
| state.etag = value | ||
| } | ||
|
|
||
| companion object { | ||
| fun getInstance(): NotificationEtagState = | ||
| ApplicationManager.getApplication().getService(NotificationEtagState::class.java) | ||
| } | ||
| } | ||
|
|
||
| data class NotificationEtagConfiguration( | ||
| var etag: String? = null, | ||
| ) | ||
|
|
||
| @Service(Service.Level.APP) | ||
| internal final class NotificationPollingService : Disposable { | ||
|
||
| private val isFirstPoll = AtomicBoolean(true) | ||
| private val isStartup = AtomicBoolean(true) | ||
| private val observers = mutableListOf<(Boolean) -> Unit>() | ||
| private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this) | ||
| private val scope = CoroutineScope(getCoroutineBgContext()) | ||
|
||
| 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 = 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 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)) | ||
| scope.launch { | ||
|
||
| 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 warning on line 171 in plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt
|
||
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: (Boolean) -> Unit) = observers.add(observer) | ||
|
|
||
| private fun notifyObservers() { | ||
| observers.forEach { observer -> | ||
| observer(isStartup.getAndSet(false)) | ||
| } | ||
| } | ||
|
|
||
| override fun dispose() { | ||
| alarm.dispose() | ||
| scope.cancel() | ||
| } | ||
|
|
||
| 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,21 @@ | ||
| // 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() | ||
| ProcessNotificationsBase() | ||
|
||
| service.startPolling() | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| // 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.testFramework.ApplicationExtension | ||
| import com.intellij.util.io.HttpRequests | ||
| import io.mockk.Runs | ||
| import io.mockk.every | ||
| import io.mockk.just | ||
| import io.mockk.mockk | ||
| import io.mockk.mockkStatic | ||
| import io.mockk.verify | ||
| import org.junit.jupiter.api.AfterEach | ||
| import org.junit.jupiter.api.BeforeEach | ||
| import org.junit.jupiter.api.Test | ||
| import org.junit.jupiter.api.extension.ExtendWith | ||
| import software.aws.toolkits.core.utils.RemoteResourceResolver | ||
| import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider | ||
| import java.nio.file.Path | ||
| import java.util.concurrent.CompletableFuture | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
|
|
||
| @ExtendWith(ApplicationExtension::class) | ||
| class NotificationPollingServiceTest { | ||
| private lateinit var sut: NotificationPollingService | ||
| private lateinit var mockResolver: RemoteResourceResolver | ||
| private lateinit var mockProvider: RemoteResourceResolverProvider | ||
| private lateinit var observer: (Boolean) -> Unit | ||
| private val testPath = Path.of("/test/path") | ||
|
|
||
| @BeforeEach | ||
| fun setUp() { | ||
| sut = NotificationPollingService() | ||
|
|
||
| mockResolver = mockk<RemoteResourceResolver> { | ||
| every { resolve(any()) } returns CompletableFuture.completedFuture(testPath) | ||
| } | ||
|
|
||
| mockProvider = mockk<RemoteResourceResolverProvider> { | ||
| every { get() } returns mockResolver | ||
| } | ||
|
|
||
| val providerField = NotificationPollingService::class.java | ||
| .getDeclaredField("resourceResolver") | ||
| providerField.isAccessible = true | ||
| providerField.set(sut, mockProvider) | ||
|
|
||
| // Create mock observers | ||
| observer = mockk<(Boolean) -> Unit>() | ||
| every { observer.invoke(any()) } just Runs | ||
|
|
||
| val observersField = NotificationPollingService::class.java | ||
| .getDeclaredField("observers") | ||
| .apply { isAccessible = true } | ||
|
|
||
| observersField.set(sut, mutableListOf(observer)) | ||
| } | ||
|
|
||
| @AfterEach | ||
| fun tearDown() { | ||
| sut.dispose() | ||
| } | ||
|
|
||
| @Test | ||
| fun `test pollForNotifications when ETag matches - no new notifications`() { | ||
| NotificationEtagState.getInstance().etag = "same" | ||
| val firstPollField = NotificationPollingService::class.java | ||
| .getDeclaredField("isFirstPoll") | ||
| .apply { isAccessible = true } | ||
| firstPollField.set(sut, AtomicBoolean(false)) | ||
|
|
||
| mockkStatic(HttpRequests::class) { | ||
| every { | ||
| HttpRequests.request(any<String>()) | ||
| .userAgent(any()) | ||
| .connect<String>(any()) | ||
| } returns "same" | ||
| sut.startPolling() | ||
| } | ||
| verify(exactly = 0) { observer.invoke(any()) } | ||
| } | ||
|
|
||
| @Test | ||
| fun `test pollForNotifications when ETag matches on startup - notify observers`() { | ||
| NotificationEtagState.getInstance().etag = "same" | ||
| mockkStatic(HttpRequests::class) { | ||
| every { | ||
| HttpRequests.request(any<String>()) | ||
| .userAgent(any()) | ||
| .connect<String>(any()) | ||
| } returns "same" | ||
| sut.startPolling() | ||
| } | ||
| verify(exactly = 1) { observer.invoke(any()) } | ||
| } | ||
|
|
||
| @Test | ||
| fun `test pollForNotifications when ETag different - notify observers`() { | ||
| NotificationEtagState.getInstance().etag = "oldETag" | ||
| mockkStatic(HttpRequests::class) { | ||
| every { | ||
| HttpRequests.request(any<String>()) | ||
| .userAgent(any()) | ||
| .connect<String>(any()) | ||
| } returns "newEtag" | ||
| sut.startPolling() | ||
| } | ||
| verify(exactly = 1) { observer.invoke(any()) } | ||
| } | ||
| } |
Check warning
Code scanning / QDJVMC
Usage of redundant or deprecated syntax or deprecated symbols Warning