Skip to content

Dapper: BookmarkQueueItems has no SerializedOptions column, so every bookmark-queue enqueue fails (Event publish, background activities, WaitForCompletion resumes) #264

Description

@sfmskywalker

Summary

BookmarkQueueItemRecord has a SerializedOptions property. DapperBookmarkQueueStore fills it from BookmarkQueueItem.Options (ResumeBookmarkOptions; see DapperBookmarkQueueStore.cs:95 and :111). The table created by the Dapper migrations has no such column. BookmarkQueueItems (Runtime/V3_3, migration 20004) has only these columns: Id, TenantId, WorkflowInstanceId, CorrelationId, BookmarkId, StimulusHash, ActivityInstanceId, ActivityTypeName and CreatedAt.

The insert and upsert column list is built from the record's properties, so every AddAsync and SaveAsync includes SerializedOptions, even when Options is null. As a result, nothing can ever be enqueued on a Dapper runtime. Reads still work (FindAsync, FindManyAsync, PageAsync, DeleteAsync), but they always see an empty queue. Every provider is affected, because the SQL Server/PostgreSQL and SQLite branches of the migration all lack the column.

Repro (fresh DB)

  1. Create an empty SQLite database and run the Elsa.Persistence.Dapper.Migrations assembly with FluentMigrator (MigrateUp()).
  2. Configure Elsa with UseDapper(...), UseWorkflowManagement(m => m.UseDapper()) and UseWorkflowRuntime(r => r.UseDapper()).
  3. Register this workflow:
    new Sequence { Activities = {
        new Event("probe-start") { CanStartWorkflow = true },
        new Event("probe-resume"),
        new WriteLine("resumed") } }
  4. Start the host. Then, in the default tenant context, run:
    • IEventPublisher.PublishAsync("probe-start")
    • IEventPublisher.PublishAsync("probe-resume")
    • IEventPublisher.PublishAsync("probe-resume") again
    • IBookmarkQueue.EnqueueAsync(...) directly

I ran this on release/3.9.0 (2cb5b75) with Elsa 3.9.0-preview.5708 (as referenced) and 3.9.0-preview.5724 (the current core release/3.9.0 head). I also ran it on main (8c03cc5, Elsa 3.10.0-preview.5705). The results were identical.

Observed error

Operation Result
IBookmarkQueueStore.AddAsync (Options = null) SqliteException: SQLite Error 1: 'table BookmarkQueueItems has no column named SerializedOptions'
IBookmarkQueueStore.SaveAsync (Options set) same
IBookmarkQueueStore.PageAsync OK (always empty)
IBookmarkQueue.EnqueueAsync same exception
PublishAsync("probe-start") (the trigger starts a workflow, but no existing bookmark matches) the workflow instance is created (Running/Suspended), then PublishAsync throws the same exception
PublishAsync("probe-resume") (an existing bookmark matches) OK. The instance resumes directly, without the queue, and reaches Finished
PublishAsync("probe-resume") again, or publishing an event nobody listens to throws the same exception
  • PostgreSQL 17: the store's Add and Save fail with 42703: column "SerializedOptions" of relation "BookmarkQueueItems" does not exist.
  • SQL Server: not run. Inferred from the DDL and SQL: the SQL Server branch of V3_3 also lacks the column, so expect Msg 207: Invalid column name 'SerializedOptions'.

Impact

Can a bookmark resume that goes through the bookmark queue succeed on Dapper? No.

  • It fails at the enqueue (IBookmarkQueueStore.AddAsync inside StoreBookmarkQueue.EnqueueAsync).
  • It never reaches BookmarkQueueProcessor. The processor's PageAsync works, but it only ever sees an empty queue.

Is the failure visible? StoreBookmarkQueue doesn't catch anything, so the exception goes to whoever enqueued. For synchronous callers that means a thrown exception, or a 500 for API callers. For background callers it ends up in a log entry while the workflow stays suspended.

Flows that go through the queue and are therefore broken on Dapper:

  • Synchronous Event publishing, and any IStimulusSender.SendAsync call where no existing bookmark matches. StimulusSender enqueues the stimulus so a bookmark created later can still pick it up. So on Dapper, PublishAsync throws for:
    • every event that only starts new workflows through triggers. The workflow does start, but the caller sees a failure, so a retry can create duplicate instances. This is verified above.
    • every event with no listener.
    • every event that arrives before its bookmark exists.
  • Asynchronous stimulus dispatch (DispatchStimulusCommand): same StimulusSender path, run in the background, so the error is only logged (from code).
  • Background activities (activities that run in the background, Kind Task/Job). BackgroundActivityInvoker resumes the workflow through the queue once the background work finishes. On Dapper the workflow never resumes, and the error only reaches the background job's log (from code).
  • DispatchWorkflow, BulkDispatchWorkflows and ExecuteWorkflow with WaitForCompletion. The parent is resumed by WorkflowExecuted handlers that enqueue. When the child finishes, the parent never resumes, and the exception is raised in the child's execution path (from code).
  • RunTask completion through ITaskReporter (the task-completed API): the caller gets an error (from code).
  • Tokenized bookmark resume endpoint with ?async=true: 500 (from code).
  • Workflows resumed after an alteration plan completes (Elsa.Alterations) (from code).

For every "from code" item above, I verified the underlying IBookmarkQueue.EnqueueAsync call throws. I did not run each flow end to end.

Not affected:

  • HTTP Endpoint resumes: HttpWorkflowsMiddleware resumes directly.
  • Scheduled Delay/Timer/Cron resumes: ResumeWorkflowTask runs the instance directly.
  • Any stimulus that matches an existing bookmark at the time it is sent.

This is separate from #245 (Dapper tenant filtering). My repro sets the default tenant context so #245 doesn't interfere.

Affected versions

  • Introduced in elsa-core ba2603ebd, "Add Bookmark Queue and Restore Background Activity Execution" (elsa-core#5758, 2024-07-15). That commit added SerializedOptions to the Dapper record but never added it to the Dapper migration.
  • Affected releases:
    • elsa-core 3.3.0 – 3.5.x (Elsa.Dapper)
    • elsa-extensions 3.6.0 – 3.8.4, release/3.9.0 and main
  • This has never worked. Any Dapper user running the workflow runtime on Dapper (UseWorkflowRuntime(r => r.UseDapper())) is affected. The only working setup is one where the column was added by hand. I verified that after ALTER TABLE BookmarkQueueItems ADD SerializedOptions:
    • enqueue works,
    • Options round-trip through SaveAsync / FindAsync,
    • both Event publish cases above succeed.

Suggested fix

Recommended: a new runtime migration that adds the column as nullable.

// e.g. in the next free runtime migration (20008, "Elsa:Runtime:V3.9"). It can share a migration with the KeyValues fix.
if (!Schema.Table("BookmarkQueueItems").Column("SerializedOptions").Exists())
    Alter.Table("BookmarkQueueItems").AddColumn("SerializedOptions").AsString(int.MaxValue).Nullable();
// Down: guarded Delete.Column("SerializedOptions").FromTable("BookmarkQueueItems")

Why this is the safer option:

  • It only adds a nullable column, which works on every provider, including SQLite's ALTER TABLE ... ADD COLUMN.
  • There are no existing rows to backfill, because no row could ever be inserted.
  • The existence guard keeps databases where someone added the column by hand working.

Not recommended: dropping SerializedOptions from the record mapping. That would turn a loud failure into silent data loss. ResumeBookmarkOptions carries:

  • the resume input and properties for queued stimuli,
  • the background-activity results (outputs, outcomes, journal data, scheduled activities),
  • child-workflow output for WaitForCompletion.

Without it, resumes would "succeed" but with missing data.

Suggested tests

  • Fresh migrated SQLite DB:
    • DapperBookmarkQueueStore AddAsync / SaveAsync with Options null and non-null.
    • A FindAsync round trip that preserves Options.Input and Options.Properties.
    • PageAsync ordering by CreatedAt.
  • Upgrade:
    • A DB migrated to 20007, then to latest, gains the column.
    • A DB with a hand-added column migrates cleanly.
  • Integration on Dapper:
    • Publishing an event that starts a workflow via a trigger does not throw.
    • An event published before its bookmark exists is delivered by the queue worker once the bookmark appears.
    • A DispatchWorkflow parent with WaitForCompletion resumes after the child finishes.
    • A background activity resumes its workflow.
  • CI: run the same tests on PostgreSQL and SQL Server via Testcontainers.

Activity

  1. added this to the 3.9 milestone on Sep 28, 2026
  2. moved this to In Progress in Elsa 3 Crewon Sep 28, 2026
  3. sfmskywalker commented on Sep 28, 2026

    @sfmskywalker
    MemberAuthor

    Starting: guarded Dapper migration (create KeyValues if missing / add nullable BookmarkQueueItems.SerializedOptions if missing), fresh migration-built DB tests. One PR into release/3.9.0 covering #263 and #264 (3.9 train with #260), then ported to main.

  4. added theissue type on Oct 2, 2026
  5. sfmskywalker commented on Oct 2, 2026

    @sfmskywalker
    MemberAuthor

    Delivered via #266 (release/3.9.0, squash aa646675) and #267 (main, merge dc30ffbc). Migration 20008 adds a nullable BookmarkQueueItems.SerializedOptions column if it's missing, so existing Dapper databases heal on their next migrate.

  6. moved this from In Progress to Done in Elsa 3 Crewon Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriaged

    Type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions