Browse Source

feat: poll notifications from single tab (#9613)

* feat: useSharedExecutionFn composable

* feat: poll notifications only from single tab

* fix: allow propagate for stale lock acquire

* fix: avoid multiple resolve on timeout

* fix: release lock on timeout from any tab
pull/9053/merge
Mert E. 1 month ago committed by GitHub
parent
commit
4dc719aa9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 153
      packages/nc-gui/composables/useSharedExecutionFn.ts
  2. 21
      packages/nc-gui/store/notification.ts

153
packages/nc-gui/composables/useSharedExecutionFn.ts

@ -0,0 +1,153 @@
import { useStorage, useTimeoutFn } from '@vueuse/core'
interface SharedExecutionOptions {
timeout?: number // Maximum time a lock can be held before it's considered stale - default 5000ms
storageDelay?: number // Delay before reading from storage to allow for changes to propagate - default 50ms
debug?: boolean // Enable or disable debug logging
}
const tabId = `tab-${Math.random().toString(36).slice(2, 9)}`
/**
* Creates a composable that ensures a function is executed only once across all tabs
* @param key Unique key to identify the function
* @param fn Function to be executed
* @param options Optional configuration (timeout, storageDelay)
* @returns A wrapped function that ensures single execution across tabs
*/
export function useSharedExecutionFn<T>(key: string, fn: () => Promise<T> | T, options: SharedExecutionOptions = {}) {
const { timeout = 5000, storageDelay = 50, debug = false } = options
const storageResultKey = `nc-shared-execution-${key}-result`
const storageLockKey = `nc-shared-execution-${key}-lock`
const storageResultState = useStorage<{
status?: 'success' | 'error'
result?: T
error?: any
}>(storageResultKey, {})
const debugLog = (...args: any[]) => {
if (debug) console.log(`[${tabId}]`, ...args)
}
debugLog(`Tab initialized with ID: ${tabId}`)
const getLock = (): { timestamp: number; tabId: string } | null => {
try {
return JSON.parse(localStorage.getItem(storageLockKey) || 'null')
} catch (error) {
debugLog(`Error reading lock:`, error)
return null
}
}
const acquireLock = async (): Promise<boolean> => {
let currentLock = getLock()
const now = Date.now()
if (!currentLock) {
localStorage.setItem(storageLockKey, JSON.stringify({ timestamp: now, tabId }))
// Allow storage updates to propagate - which will determine strictness of lock
await new Promise((resolve) => setTimeout(resolve, storageDelay))
currentLock = getLock()
if (currentLock?.tabId === tabId) {
debugLog(`Lock acquired successfully`)
return true
}
debugLog(`Lock acquired by ${currentLock?.tabId}`)
return false
}
const lockIsStale = now - currentLock.timestamp > timeout
if (lockIsStale) {
localStorage.setItem(storageLockKey, JSON.stringify({ timestamp: now, tabId }))
// Allow storage updates to propagate - which will determine strictness of lock
await new Promise((resolve) => setTimeout(resolve, storageDelay))
currentLock = getLock()
if (currentLock?.tabId === tabId) {
debugLog(`Stale lock acquired successfully`)
return true
}
debugLog(`Stale lock acquired by ${currentLock?.tabId}`)
return false
}
debugLog(`Lock is held by ${currentLock?.tabId}`)
return false
}
const releaseLock = (): void => {
const currentLock = getLock()
if (currentLock?.tabId === tabId) {
debugLog(`Releasing lock.`)
localStorage.removeItem(storageLockKey)
}
}
const sharedExecutionFn = async (): Promise<T> => {
debugLog(`sharedExecutionFn called`)
if (!(await acquireLock())) {
const currentLock = getLock()
return new Promise((resolve, reject) => {
let timedOut = false
const { start: startTimeout, stop: stopTimeout } = useTimeoutFn(
() => {
timedOut = true
localStorage.removeItem(storageLockKey)
reject(new Error(`Timeout waiting for result on key ${key}`))
},
currentLock?.timestamp ? timeout - (Date.now() - currentLock.timestamp) : timeout,
)
startTimeout()
if (storageResultState.value.status) {
storageResultState.value = { ...storageResultState.value, status: undefined }
}
until(() => storageResultState.value)
.toMatch((v) => v.status === 'success' || v.status === 'error')
.then((res) => {
if (timedOut) return
stopTimeout()
const { result, error } = res
result ? resolve(result) : reject(error)
})
})
}
try {
storageResultState.value = { ...storageResultState.value, status: undefined }
const result = await fn()
storageResultState.value = { status: 'success', result }
return result
} catch (error) {
storageResultState.value = { status: 'error', error }
throw error
} finally {
releaseLock()
debugLog(`Function execution completed (success or failure).`)
}
}
// Make sure to release lock on page unload
onBeforeMount(() => {
window.addEventListener('beforeunload', releaseLock)
})
// Remove listener on component unmount to avoid leaks
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', releaseLock)
})
return sharedExecutionFn
}

21
packages/nc-gui/store/notification.ts

@ -1,9 +1,10 @@
import { defineStore } from 'pinia'
import type { NotificationType } from 'nocodb-sdk'
import axios, { type CancelTokenSource } from 'axios'
import { CancelToken } from 'axios'
import { useStorage } from '@vueuse/core'
const CancelToken = axios.CancelToken
export const useNotification = defineStore('notificationStore', () => {
const isTokenRefreshInProgress = useStorage(TOKEN_REFRESH_PROGRESS_KEY, false)
@ -27,16 +28,24 @@ export const useNotification = defineStore('notificationStore', () => {
let cancelTokenSource: CancelTokenSource | null
const pollNotifications = async () => {
try {
if (!token.value) return
const pollNotificationsApiCall = async () => {
// set up cancel token for polling to cancel when token changes/token is removed
cancelTokenSource = CancelToken.source()
const res = await api.notification.poll({
return await api.notification.poll({
cancelToken: cancelTokenSource.token,
})
}
const sharedExecutionPollNotificationsApiCall = useSharedExecutionFn('notification', pollNotificationsApiCall, {
timeout: 30000,
})
const pollNotifications = async () => {
try {
if (!token.value) return
const res = await sharedExecutionPollNotificationsApiCall()
if (res.status === 'success') {
if (notificationTab.value === 'unread') {

Loading…
Cancel
Save