Separating “enquiry accepted” from “enquiry processed” in an n8n intake flow

I’ve been rebuilding a small lead-intake demonstration around one distinction:

Accepting an enquiry is not the same as completing the downstream work.

The intake path now:

  1. Receives and validates fictional enquiry data
  2. Calls a PostgreSQL function through a restricted runtime role
  3. Saves the enquiry and an initial pending job in one database operation
  4. Returns 202 accepted only after persistence succeeds

A verified test produced:

Plain Text

submission_id: n8n-test-001

lead: stored

linked job: stored

job status: pending

attempt count: 0

pending is intentional. It means the request is safe and waiting for processing, not that the destination action has already succeeded.

I also separated the duplicate states conceptually:

Plain Text

same ID + same payload → already received

same ID + changed payload → conflict

What this checkpoint does not prove yet:

  • concurrent duplicate handling;
  • processor leases and recovery;
  • safe retry of external side effects;
  • exactly-once delivery.

The next implementation boundary is claiming a pending job safely and recording the destination outcome without allowing an old worker to overwrite a newer claim.

For people running queue-like processing in n8n with PostgreSQL: do you keep claiming and completion inside database functions, or have you found a reliable reason to split those operations across workflow nodes?

Inside the database, in my case. In a booking agent I built, the availability check and the insert are one Postgres function with FOR UPDATE on the room-type row, and n8n only calls it. Splitting check and write across nodes is exactly how two guests get the last room.

For the old-worker problem, I’d make completion conditional on the claim you hold. Store a claim token (or attempt number) with the lease and complete with UPDATE ... WHERE id = $1 AND claim_token = $2. If the lease was re-issued, zero rows update and the stale worker knows it lost.

One thing I’d take from my own gaps: expiry needs something scheduled to sweep it. An unpaid hold of mine counts as expired after 5 minutes, but nothing runs the sweep function on a schedule yet, so the row only changes state when the next request touches it.

That booking example makes the boundary clear. Keeping availability checking and reservation creation inside one database operation prevents two workers from independently seeing the same room as available.

The claim-token condition is the part I need for the next checkpoint:

UPDATE ... WHERE id = $1 AND claim_token = $2

If the lease has expired and another worker has claimed the job, the stale worker updates zero rows instead of overwriting the newer outcome.

I also agree about the sweeper. An expiry timestamp alone doesn’t create recovery; something still has to identify expired claims and move them back to a recoverable state.

My next test will cover:

  1. Worker A claims a pending job.
  2. Worker A exceeds its lease.
  3. The sweeper makes the job claimable again.
  4. Worker B receives a new claim token.
  5. Worker A finishes late and attempts completion.
  6. Worker A’s update affects zero rows, while Worker B remains the valid owner.

I’ll keep the claim and conditional completion inside PostgreSQL, with n8n calling those operations rather than implementing the ownership checks across separate nodes.

Thanks for sharing the unpaid-hold gap too. It’s a good reminder that “expired according to the timestamp” and “actually recovered by the system” are different states.

1 « J'aime »