Job posting
Keep one job posting durable and searchable while delivering every accepted update to LinkedIn and Indeed in order.
Product requirements
The process represents one job posting for its full lifecycle. It must:
- Create the Flow with a title, description, last-update timestamp, and update version 0. Title uses the CustomText full-text Search Attribute. Description is persisted but is not indexed.
- Run InitStep once. It starts UpdateLinkedInPosting and UpdateIndeedPosting in parallel. Each Step then waits on its own Channel without occupying a Worker.
- Return the current title, description, and notes through a read RPC.
- Accept a replacement title, description, and notes through an update RPC. The RPC locks the dedicated, null-valued UpdatePostingLock Attribute so only one accepted update can allocate a version and publish at a time.
- Increment UpdateVersion, persist the current posting, and publish the same immutable PostingUpdate to LinkedInPostingUpdates and IndeedPostingUpdates. The payload contains the version, a stable idempotency key, and the posting snapshot.
- Consume one message at a time from each FIFO Channel. After a successful external call, each consumer loops to a new execution of itself and waits for the next message.
- Retry each job-board call independently with exponential backoff: three seconds initially, at most 60 seconds between attempts, no more than 100 attempts, and no longer than one hour.
- Stop the Flow when the posting is deleted. Search can find active postings through their indexed Attributes.
The Flow waits while either Channel is empty and between failed Step retry attempts. The update RPC does not wait for LinkedIn or Indeed. It returns the committed version after the state and both Channel messages are durably committed.
Potential failures include:
- Creation can fail when required initial Attributes are missing or cannot be encoded. The Flow is not created with partial state.
- An update RPC can fail because the Flow ID is unknown, the Flow has stopped, the input cannot be persisted, or another RPC holds UpdatePostingLock. A lock conflict is safe to retry.
- LinkedIn or Indeed can be unavailable, time out, rate-limit the request, or reject the posting. One consumer can continue while the other retries or ultimately fails.
- A job-board request can succeed remotely while its response is lost. The version-derived idempotency key lets the external adapter deduplicate the retry.
- A failed consumer blocks later messages for that destination until it succeeds or exhausts retries. This preserves FIFO order instead of allowing a newer posting to overtake an older one.
- Delete can race with an accepted update. The chosen stop policy must cancel or finish queued and in-flight work according to the product's deletion contract.
The lock orders successful RPC commits, not client arrival times. Concurrent callers may race to acquire it. The returned version is the authoritative accepted order, and each destination observes those versions in FIFO order.
Flow design
This interactive definition graph is generated from the runnable Python Flow.
The Flow uses one locked producer and two durable, independently progressing consumers:
Create posting (version 0)
│
▼
InitStep
┌────┴────┐
▼ ▼
UpdateLinkedIn UpdateIndeed
Posting Posting
│ │
wait on LI wait on Indeed
Channel Channel
▲ ▲
│ │
└────┬────┘
│
Update RPC [UpdatePostingLock]
persist version N + publish the same PostingUpdate
│
└── return version N
It combines these design patterns:
- Entity store gives each posting a stable Flow ID and keeps its current searchable state in Attributes.
- Initialization fan-out uses InitStep to create both long-lived consumer branches exactly once.
- Locked producer serializes version allocation, state replacement, and publication through UpdatePostingLock.
- Transactional outbox inside the Flow commits the current state and both Channel messages as one RPC result, so an accepted update cannot be published to only one destination.
- Immutable versioned message gives each consumer the exact posting snapshot from its originating RPC instead of reading mutable shared Attributes later.
- FIFO Channel consumer loop processes one message and moves back to the same Step. Each destination observes v1 before v2 even when RPCs arrive quickly.
- Parallel failure isolation gives LinkedIn and Indeed separate waits, executions, retry histories, and progress. A slow destination does not block the other.
- Durable retry handles transient external failures without holding a process or losing progress across restarts.
- Indexed state supports title search without a second application-side catalog.
The destination Steps do not need Attribute locks. Each has one sequential consumer chain, and its Channel supplies the ordering. Adding destination locks would prevent overlap but would not by itself preserve an update snapshot or FIFO delivery.
Core implementation
The tabs show each Flow signature and the end-to-end mechanism: InitStep, the RPC lock, versioned dual publication, one-message Channel waits, consumer loops, and retry policy. Every snippet comes from the linked runnable example.
UPDATE_VERSION = Attribute("UpdateVersion", int)
UPDATE_POSTING_LOCK = Attribute("UpdatePostingLock", type(None))
LINKEDIN_POSTING_UPDATES = Channel("LinkedInPostingUpdates", PostingUpdate)
INDEED_POSTING_UPDATES = Channel("IndeedPostingUpdates", PostingUpdate)
class JobPostingFlow(Flow[None]):
update_version = UPDATE_VERSION
update_posting_lock = UPDATE_POSTING_LOCK
linkedin_posting_updates = LINKEDIN_POSTING_UPDATES
indeed_posting_updates = INDEED_POSTING_UPDATES
def __init__(self, service: MyDependencyService) -> None:
self.init = InitStep()
self.update_linkedin_posting = UpdateLinkedInPosting(service)
self.update_indeed_posting = UpdateIndeedPosting(service)
def get_steps(self) -> StepList[None]:
return StepList.start_step(self.init).other_steps(
self.update_linkedin_posting,
self.update_indeed_posting,
)
def get_persistence_schema(self) -> PersistenceSchema:
return PersistenceSchema.of(
self.title,
self.job_description,
self.last_update_time_millis,
self.notes,
self.update_version,
self.update_posting_lock,
self.linkedin_posting_updates,
self.indeed_posting_updates,
)
@rpc(lock_attributes=(update_posting_lock.lock(),))
def update(self, context: Context, input: JobInfo) -> RPCResult[int]:
version = self.update_version.get(context) + 1
self.title.set(context, input.title or "")
self.job_description.set(context, input.description or "")
self.update_version.set(context, version)
update = PostingUpdate(version, f"{context.flow_id}:{version}", input)
self.linkedin_posting_updates.publish(context, update)
self.indeed_posting_updates.publish(context, update)
return RPCResult(version)
class InitStep(Step[None]):
def execute(self, context: Context, input: None) -> StepDecision:
return go_to_many(
StepMovement.of(UpdateLinkedInPosting, None),
StepMovement.of(UpdateIndeedPosting, None),
)
class UpdateLinkedInPosting(Step[None]):
def wait_for(self, context: Context, input: None) -> Wait:
return Wait.until(LINKEDIN_POSTING_UPDATES.for_one())
def execute(self, context: Context, input: None) -> StepDecision:
update = LINKEDIN_POSTING_UPDATES.results(context)[0]
self.service.update_external_system(
f"update LinkedIn job posting v{update.version} "
f"[{update.idempotency_key}]: {update.posting.title}"
)
return go_to(UpdateLinkedInPosting, None)
Example: examples/python/dex_examples/products/job-post/job_post_flow.py
Demo
Run Dex and any language example server, then create and update a posting from the examples playground. This run accepted two updates. The graph shows InitStep, two ordered executions for each job board, and the next LinkedIn and Indeed consumer executions waiting for another Channel message.