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)
- Create an empty SQLite database and run the
Elsa.Persistence.Dapper.Migrations assembly with FluentMigrator (MigrateUp()).
- Configure Elsa with
UseDapper(...), UseWorkflowManagement(m => m.UseDapper()) and UseWorkflowRuntime(r => r.UseDapper()).
- Register this workflow:
new Sequence { Activities = {
new Event("probe-start") { CanStartWorkflow = true },
new Event("probe-resume"),
new WriteLine("resumed") } }
- 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.
Summary
BookmarkQueueItemRecordhas aSerializedOptionsproperty.DapperBookmarkQueueStorefills it fromBookmarkQueueItem.Options(ResumeBookmarkOptions; seeDapperBookmarkQueueStore.cs:95and: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,ActivityTypeNameandCreatedAt.The insert and upsert column list is built from the record's properties, so every
AddAsyncandSaveAsyncincludesSerializedOptions, even whenOptionsis 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)
Elsa.Persistence.Dapper.Migrationsassembly with FluentMigrator (MigrateUp()).UseDapper(...),UseWorkflowManagement(m => m.UseDapper())andUseWorkflowRuntime(r => r.UseDapper()).IEventPublisher.PublishAsync("probe-start")IEventPublisher.PublishAsync("probe-resume")IEventPublisher.PublishAsync("probe-resume")againIBookmarkQueue.EnqueueAsync(...)directlyI 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 corerelease/3.9.0head). I also ran it onmain(8c03cc5, Elsa 3.10.0-preview.5705). The results were identical.Observed error
IBookmarkQueueStore.AddAsync(Options = null)SqliteException: SQLite Error 1: 'table BookmarkQueueItems has no column named SerializedOptions'IBookmarkQueueStore.SaveAsync(Options set)IBookmarkQueueStore.PageAsyncIBookmarkQueue.EnqueueAsyncPublishAsync("probe-start")(the trigger starts a workflow, but no existing bookmark matches)Running/Suspended), thenPublishAsyncthrows the same exceptionPublishAsync("probe-resume")(an existing bookmark matches)FinishedPublishAsync("probe-resume")again, or publishing an event nobody listens to42703: column "SerializedOptions" of relation "BookmarkQueueItems" does not exist.V3_3also lacks the column, so expectMsg 207: Invalid column name 'SerializedOptions'.Impact
Can a bookmark resume that goes through the bookmark queue succeed on Dapper? No.
IBookmarkQueueStore.AddAsyncinsideStoreBookmarkQueue.EnqueueAsync).BookmarkQueueProcessor. The processor'sPageAsyncworks, but it only ever sees an empty queue.Is the failure visible?
StoreBookmarkQueuedoesn'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:
IStimulusSender.SendAsynccall where no existing bookmark matches.StimulusSenderenqueues the stimulus so a bookmark created later can still pick it up. So on Dapper,PublishAsyncthrows for:DispatchStimulusCommand): sameStimulusSenderpath, run in the background, so the error is only logged (from code).BackgroundActivityInvokerresumes 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,BulkDispatchWorkflowsandExecuteWorkflowwithWaitForCompletion. The parent is resumed byWorkflowExecutedhandlers that enqueue. When the child finishes, the parent never resumes, and the exception is raised in the child's execution path (from code).RunTaskcompletion throughITaskReporter(the task-completed API): the caller gets an error (from code).?async=true: 500 (from code).Elsa.Alterations) (from code).For every "from code" item above, I verified the underlying
IBookmarkQueue.EnqueueAsynccall throws. I did not run each flow end to end.Not affected:
HttpWorkflowsMiddlewareresumes directly.ResumeWorkflowTaskruns the instance directly.This is separate from #245 (Dapper tenant filtering). My repro sets the default tenant context so #245 doesn't interfere.
Affected versions
SerializedOptionsto the Dapper record but never added it to the Dapper migration.Elsa.Dapper)release/3.9.0andmainUseWorkflowRuntime(r => r.UseDapper())) is affected. The only working setup is one where the column was added by hand. I verified that afterALTER TABLE BookmarkQueueItems ADD SerializedOptions:Optionsround-trip throughSaveAsync/FindAsync,Suggested fix
Recommended: a new runtime migration that adds the column as nullable.
Why this is the safer option:
ALTER TABLE ... ADD COLUMN.Not recommended: dropping
SerializedOptionsfrom the record mapping. That would turn a loud failure into silent data loss.ResumeBookmarkOptionscarries:WaitForCompletion.Without it, resumes would "succeed" but with missing data.
Suggested tests
DapperBookmarkQueueStoreAddAsync/SaveAsyncwithOptionsnull and non-null.FindAsyncround trip that preservesOptions.InputandOptions.Properties.PageAsyncordering byCreatedAt.DispatchWorkflowparent withWaitForCompletionresumes after the child finishes.