DocuStore.io
Architecture

Temporal Workflows

How DocuStore uses Temporal for durable workflow orchestration, automatic retries, and observable processing pipelines.

Every long-running operation in DocuStore -- PDF parsing, embedding generation, LLM summarization, compound extraction -- is orchestrated by Temporal. This provides automatic retries, timeout handling, observability, and crash recovery without custom retry logic.

Architecture

The system runs as several independent processes:

ProcessRoleTask Queue
API serverHandles HTTP requests, starts manually triggered workflows--
Read workerSubscribes to EventStoreDB, projects events into MongoDB read models--
Pipeline workerSubscribes to EventStoreDB, triggers workflows--
Temporal workerExecutes CPU/IO workflows and activitiesartifact_processing
LLM workerExecutes LLM workflows and activitiesllm_processing
Plugin consumerConsumes Kafka events, starts plugin workflows and runs their workers (only needed when plugins are enabled)one queue per plugin

The pipeline worker detects domain events from the EventStoreDB subscription and starts the appropriate Temporal workflow. The two Temporal workers poll their task queues and execute the actual processing logic.

# Start each process individually for local development
make run                  # API server
make run-read-models      # Read worker (EventStoreDB → MongoDB)
make run-workflow-worker  # Pipeline worker (EventStoreDB → Temporal)
make run-temporal-worker  # Temporal worker (artifact_processing queue)
make run-llm-worker       # LLM worker (llm_processing queue)
make run-plugin-consumer  # Plugin consumer (Kafka → plugin workflows)

# Or start all six together
make run-all

Event Pipeline

Domain events trigger workflows in a cascade. Each workflow completes by saving results to the aggregate, which emits a new event, which triggers the next workflow:

Artifact.Created
  └─ ParseArtifactWorkflow (Docling parse → pages + page text)
       ├─ Page.Created (each page)
       ├─ Page.TextMentionUpdated (each page with text)
       └─ Artifact.PagesAdded

Page.Created
  └─ ExtractCompoundMentionsWorkflow (OCSR)
       └─ Page.CompoundMentionsUpdated
            ├─ EmbedCompoundSmilesWorkflow (ChemBERTa → compound_embeddings)
            └─ ReconcileCompoundLabelsWorkflow

Page.TextMentionUpdated
  ├─ PageSummarizationWorkflow
  │    └─ Page.SummaryCandidateUpdated
  │         ├─ PageSummaryEmbeddingWorkflow (→ summary_embeddings)
  │         └─ once every page of the artifact has a summary:
  │              ├─ ArtifactSummarizationWorkflow
  │              │    └─ Artifact.SummaryCandidateUpdated
  │              │         └─ ArtifactSummaryEmbeddingWorkflow (→ summary_embeddings)
  │              └─ BatchReEmbedArtifactPagesWorkflow (all chunks → page_embeddings)
  ├─ NERExtractionWorkflow
  │    └─ Page.TagMentionsUpdated
  │         ├─ ArtifactTagAggregationWorkflow
  │         │    └─ Artifact.TagMentionsUpdated (→ Qdrant payload sync)
  │         ├─ page tags → Qdrant payload sync
  │         └─ ReconcileCompoundLabelsWorkflow
  └─ DocumentMetadataExtractionWorkflow (triggered by the first page)
       └─ Artifact.TitleMentionUpdated / AuthorMentionsUpdated / PresentationDateUpdated
            (authors and date → Qdrant payload sync)

Text chunks are embedded once per artifact, after all page summaries exist, so that every chunk's context header includes its page summary. GeneratePageEmbeddingWorkflow embeds a single page and is used for manual re-embedding through the API. ReconcileCompoundLabelsWorkflow runs from both the compound and the NER branch, whichever finishes last, to reconcile OCSR labels against NER compound names.

Idempotency

Workflow IDs are derived deterministically from entity IDs:

WorkflowWorkflow ID Pattern
Artifact parsingartifact-parse-{artifact_id}
Compound extractioncompound-extraction-{page_id}
SMILES embeddingsmiles-embedding-{page_id}
Compound label reconciliationreconcile-compound-labels-{page_id}
NER extractionner-extraction-{page_id}
Artifact tag aggregationartifact-tag-aggregation-{artifact_id}
Document metadatadoc-metadata-{artifact_id}
Page summarizationpage-summarization-{page_id}
Page summary embeddingpage-summary-embedding-{page_id}
Artifact summarizationartifact-summarization-{artifact_id}
Artifact summary embeddingartifact-summary-embedding-{artifact_id}
Batch chunk embeddingbatch-reembed-{artifact_id}
Single-page embedding (manual)embedding-{page_id}

Workflows use Temporal's ALLOW_DUPLICATE ID reuse policy, so a finished workflow can be re-run under the same ID. Starting a workflow whose ID is still running is rejected by Temporal; the orchestrator logs the rejection and moves on, so duplicate triggers are a safe no-op.

Port-Based Design

The application layer depends on an abstract WorkflowOrchestrator port:

class WorkflowOrchestrator(Protocol):
    async def start_embedding_workflow(self, page_id: UUID) -> None: ...
    async def start_compound_extraction_workflow(self, page_id: UUID) -> None: ...
    async def start_ner_extraction_workflow(self, page_id: UUID) -> None: ...
    async def start_artifact_summarization_workflow(self, artifact_id: UUID) -> None: ...
    # ... additional workflow methods

The TemporalWorkflowOrchestrator in the infrastructure layer implements this port using the Temporal Python SDK. The application layer never imports Temporal directly, so the orchestration backend could be swapped to Celery, AWS Step Functions, or any other system without changing business logic.

Task Queues

DocuStore uses two task queues to control concurrency:

QueuePurposeMax Concurrent Activities
artifact_processingCPU-bound work (parsing, embedding, OCSR, tag aggregation)Configurable via TEMPORAL_MAX_CONCURRENT_ACTIVITIES
llm_processingLLM inference (page and artifact summarization, NER, document metadata)Configurable via TEMPORAL_MAX_CONCURRENT_LLM_ACTIVITIES

Each queue is served by its own worker process. Separating LLM activities onto their own queue prevents slow LLM calls from blocking fast embedding and parsing tasks. For local development with Ollama, TEMPORAL_MAX_CONCURRENT_LLM_ACTIVITIES=2 prevents memory exhaustion.

Retry Configuration

Activities are configured with appropriate timeouts and retry policies:

WorkflowActivity TimeoutMax AttemptsBackoff
PDF parsing30 min35s to 60s
Compound extraction (OCSR)10 min32s to 60s
SMILES embedding5 min32s to 60s
Compound label reconciliation2 min32s to 60s
NER extraction10 min310s to 120s
Artifact tag aggregation5 min35s to 60s
Document metadata5 min35s to 60s
Page summarization15 min35s to 120s
Artifact summarization60 min35s to 120s
Summary embedding (page and artifact)5 min35s to 60s
Batch chunk embedding30 min25s to 60s
Single-page embedding5 min31s to 30s

Activity errors go through the retry policy above; errors that a retry cannot fix (not found, validation, not ready) are marked non-retryable. Embedding activities treat "nothing to embed" (a blank page, a deleted artifact) as a completed run, but fail the workflow on embedding provider or internal errors, so the failure shows in the workflow status endpoints instead of passing as success.

Configuration

VariableDefaultDescription
TEMPORAL_ADDRESSlocalhost:7233Temporal server address
TEMPORAL_MAX_CONCURRENT_ACTIVITIES10Max parallel activities on the main queue
TEMPORAL_LLM_TASK_QUEUEllm_processingTask queue name for LLM activities
TEMPORAL_MAX_CONCURRENT_LLM_ACTIVITIES2Max parallel LLM activities (Ollama: 1-2, cloud: 5-10)

Observability

Every workflow execution is visible in the Temporal UI at http://localhost:8233 (default). You can inspect:

  • Workflow execution history (started, activities, completed/failed)
  • Activity inputs, outputs, and retry attempts
  • Pending activities and their timeouts
  • Workflow search by ID pattern (e.g., all ner-extraction-* workflows)