diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index 3eea0595a..89b57bcf1 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -47,6 +47,7 @@ export interface ConfigState { publicTeamsEnabled: boolean, allowIconChanges: boolean, enabledProFeatures: string[], + concurrentWrites: boolean, } export const useConfigStore = defineStore('config', () => { @@ -88,6 +89,7 @@ export const useConfigStore = defineStore('config', () => { publicTeamsEnabled: false, allowIconChanges: true, enabledProFeatures: [], + concurrentWrites: false, }) const migratorsEnabled = computed(() => state.availableMigrators?.length > 0) diff --git a/frontend/src/stores/tasks.test.ts b/frontend/src/stores/tasks.test.ts index 4d7a60af6..644d6e6bb 100644 --- a/frontend/src/stores/tasks.test.ts +++ b/frontend/src/stores/tasks.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest' -import {buildDefaultRemindersForQuickAdd} from './tasks' +import {buildDefaultRemindersForQuickAdd, runWrites} from './tasks' import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo' import type {ITaskReminder} from '@/modelTypes/ITaskReminder' @@ -42,3 +42,39 @@ describe('buildDefaultRemindersForQuickAdd', () => { expect(result[0].relativeTo).toBe(REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE) }) }) + +describe('runWrites', () => { + function deferredWrite() { + const inFlight: string[] = [] + let maxConcurrent = 0 + const completed: string[] = [] + const write = async (item: string) => { + inFlight.push(item) + maxConcurrent = Math.max(maxConcurrent, inFlight.length) + await Promise.resolve() + inFlight.splice(inFlight.indexOf(item), 1) + completed.push(item) + } + return {write, completed, getMaxConcurrent: () => maxConcurrent} + } + + it('runs all writes in parallel when concurrent', async () => { + const {write, completed, getMaxConcurrent} = deferredWrite() + await runWrites(['a', 'b', 'c'], write, true) + expect(completed).toHaveLength(3) + expect(getMaxConcurrent()).toBeGreaterThan(1) + }) + + it('runs writes one at a time when not concurrent', async () => { + const {write, completed, getMaxConcurrent} = deferredWrite() + await runWrites(['a', 'b', 'c'], write, false) + expect(completed).toEqual(['a', 'b', 'c']) + expect(getMaxConcurrent()).toBe(1) + }) + + it('does nothing for an empty list', async () => { + const {write, completed} = deferredWrite() + await runWrites([], write, false) + expect(completed).toHaveLength(0) + }) +}) diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 3eb99d1ef..ac27ff0d2 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -27,6 +27,7 @@ import type {IProject} from '@/modelTypes/IProject' import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo' import {setModuleLoading} from '@/stores/helper' +import {useConfigStore} from '@/stores/config' import {useLabelStore} from '@/stores/labels' import {useProjectStore} from '@/stores/projects' import {useKanbanStore} from '@/stores/kanban' @@ -59,6 +60,22 @@ export function buildDefaultRemindersForQuickAdd( })) } +// runWrites applies a write to each item. SQLite deadlocks on concurrent writes +// (read-then-write upgrade conflict), so callers pass concurrent=false to serialize. +export async function runWrites( + items: readonly T[], + write: (item: T) => Promise, + concurrent: boolean, +): Promise { + if (concurrent) { + await Promise.all(items.map(item => write(item))) + return + } + for (const item of items) { + await write(item) + } +} + // IDEA: maybe use a small fuzzy search here to prevent errors function findPropertyByValue(object, key, value, fuzzy = false) { return Object.values(object).find(l => { @@ -131,6 +148,7 @@ export const useTaskStore = defineStore('task', () => { const labelStore = useLabelStore() const projectStore = useProjectStore() const authStore = useAuthStore() + const configStore = useConfigStore() const tasks = ref<{ [id: ITask['id']]: ITask }>({}) // TODO: or is this ITask[] const isLoading = ref(false) @@ -395,10 +413,7 @@ export const useTaskStore = defineStore('task', () => { } const labels = await ensureLabelsExist(parsedLabels) - const labelAddsToWaitFor = labels.map(async l => addLabelToTask(task, l)) - - // This waits until all labels are created and added to the task - await Promise.all(labelAddsToWaitFor) + await runWrites(labels, l => addLabelToTask(task, l), configStore.concurrentWrites) return task } diff --git a/pkg/routes/api/shared/info.go b/pkg/routes/api/shared/info.go index 423aae2c7..48cbb00a5 100644 --- a/pkg/routes/api/shared/info.go +++ b/pkg/routes/api/shared/info.go @@ -54,6 +54,8 @@ type VikunjaInfos struct { PublicTeamsEnabled bool `json:"public_teams_enabled" doc:"Whether public teams are enabled."` AllowIconChanges bool `json:"allow_icon_changes" doc:"Whether users may change project icons."` EnabledProFeatures []license.Feature `json:"enabled_pro_features" doc:"The licensed pro features enabled on this instance."` + // ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel. + ConcurrentWrites bool `json:"concurrent_writes" doc:"Whether the configured database supports concurrent writes. False on SQLite; clients should serialize batched writes when this is false."` } // AuthInfo describes the authentication methods enabled on this instance. @@ -106,6 +108,7 @@ func BuildInfo() VikunjaInfos { WebhooksEnabled: config.WebhooksEnabled.GetBool(), PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(), AllowIconChanges: config.ServiceAllowIconChanges.GetBool(), + ConcurrentWrites: config.DatabaseType.GetString() != "sqlite", EnabledProFeatures: license.EnabledProFeatures(), AvailableMigrators: []string{ (&vikunja_file.FileMigrator{}).Name(), diff --git a/pkg/webtests/huma_info_test.go b/pkg/webtests/huma_info_test.go index 5ba7f859c..d9f2684a9 100644 --- a/pkg/webtests/huma_info_test.go +++ b/pkg/webtests/huma_info_test.go @@ -21,6 +21,8 @@ import ( "net/http" "testing" + "code.vikunja.io/api/pkg/config" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -39,4 +41,7 @@ func TestHumaInfo(t *testing.T) { assert.Contains(t, body, "version") assert.Contains(t, body, "auth") assert.Contains(t, body, "available_migrators") + + require.Contains(t, body, "concurrent_writes") + assert.Equal(t, config.DatabaseType.GetString() != "sqlite", body["concurrent_writes"]) }