@@ -27,18 +27,33 @@ pub struct NewPipeline<'a> {
2727 pub timeout_seconds : i32 ,
2828 pub job_timeout_seconds : i32 ,
2929 pub request_id : Option < & ' a str > ,
30+ /// Webhook delivery that triggered this pipeline (None for manual
31+ /// dispatch/rerun). One pipeline per (delivery, workflow) — a retried
32+ /// delivery returns the existing row instead of creating a duplicate.
33+ pub webhook_delivery_id : Option < & ' a str > ,
3034}
3135
3236/// Create the pipeline, its jobs, the creation ledger entry, and the audit
3337/// row in one transaction. The per-repository number is claimed race-free
34- /// through pipeline_counters.
38+ /// through pipeline_counters. Returns `(pipeline, jobs, newly_created)`:
39+ /// when `webhook_delivery_id` is set and a pipeline for that
40+ /// (delivery, workflow) already exists — a retried delivery — the existing
41+ /// row comes back with `newly_created = false` and an empty jobs vec.
3542pub async fn create (
3643 pool : & PgPool ,
3744 new : & NewPipeline < ' _ > ,
3845 jobs : & [ PlannedJob ] ,
39- ) -> sqlx:: Result < ( Pipeline , Vec < PipelineJob > ) > {
46+ ) -> sqlx:: Result < ( Pipeline , Vec < PipelineJob > , bool ) > {
4047 let mut tx = pool. begin ( ) . await ?;
4148
49+ // Idempotency guard BEFORE the counter bump so retries never burn
50+ // pipeline numbers.
51+ if let Some ( delivery_id) = new. webhook_delivery_id
52+ && let Some ( existing) = find_by_delivery ( & mut tx, delivery_id, new. workflow_id ) . await ?
53+ {
54+ return Ok ( ( existing, Vec :: new ( ) , false ) ) ;
55+ }
56+
4257 let ( number, ) : ( i32 , ) = sqlx:: query_as (
4358 r#"
4459 INSERT INTO pipeline_counters (repository_id, next_number)
@@ -52,14 +67,15 @@ pub async fn create(
5267 . fetch_one ( & mut * tx)
5368 . await ?;
5469
55- let pipeline = sqlx:: query_as :: < _ , Pipeline > (
70+ let inserted = sqlx:: query_as :: < _ , Pipeline > (
5671 r#"
5772 INSERT INTO pipelines
5873 (workspace_id, repository_id, workflow_id, workflow_name, workflow_path,
5974 number, trigger, triggered_by, commit_sha, commit_message, commit_author,
6075 actor_login, actor_avatar_url, git_ref, trigger_inputs, pr_number,
61- timeout_seconds)
62- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
76+ timeout_seconds, webhook_delivery_id)
77+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
78+ $17, $18)
6379 RETURNING *
6480 "# ,
6581 )
@@ -80,8 +96,31 @@ pub async fn create(
8096 . bind ( new. trigger_inputs )
8197 . bind ( new. pr_number )
8298 . bind ( new. timeout_seconds )
99+ . bind ( new. webhook_delivery_id )
83100 . fetch_one ( & mut * tx)
84- . await ?;
101+ . await ;
102+
103+ let pipeline = match inserted {
104+ Ok ( pipeline) => pipeline,
105+ // A concurrent processor won the (delivery, workflow) race: the tx
106+ // (including the counter bump) rolls back and the winner's row is
107+ // returned instead.
108+ Err ( error) if is_delivery_conflict ( & error) => {
109+ drop ( tx) ;
110+ let delivery_id = new
111+ . webhook_delivery_id
112+ . expect ( "delivery conflict requires a delivery id" ) ;
113+ let existing = sqlx:: query_as :: < _ , Pipeline > (
114+ "SELECT * FROM pipelines WHERE webhook_delivery_id = $1 AND workflow_id = $2" ,
115+ )
116+ . bind ( delivery_id)
117+ . bind ( new. workflow_id )
118+ . fetch_one ( pool)
119+ . await ?;
120+ return Ok ( ( existing, Vec :: new ( ) , false ) ) ;
121+ }
122+ Err ( error) => return Err ( error) ,
123+ } ;
85124
86125 let mut job_rows = Vec :: with_capacity ( jobs. len ( ) ) ;
87126 for job in jobs {
@@ -142,7 +181,32 @@ pub async fn create(
142181 . await ?;
143182
144183 tx. commit ( ) . await ?;
145- Ok ( ( pipeline, job_rows) )
184+ Ok ( ( pipeline, job_rows, true ) )
185+ }
186+
187+ /// Existing pipeline for a (webhook delivery, workflow) pair, if any.
188+ async fn find_by_delivery (
189+ tx : & mut sqlx:: Transaction < ' _ , sqlx:: Postgres > ,
190+ delivery_id : & str ,
191+ workflow_id : Uuid ,
192+ ) -> sqlx:: Result < Option < Pipeline > > {
193+ sqlx:: query_as :: < _ , Pipeline > (
194+ "SELECT * FROM pipelines WHERE webhook_delivery_id = $1 AND workflow_id = $2" ,
195+ )
196+ . bind ( delivery_id)
197+ . bind ( workflow_id)
198+ . fetch_optional ( & mut * * tx)
199+ . await
200+ }
201+
202+ /// True when an insert failed on the pipelines_delivery_workflow_uq partial
203+ /// unique index (concurrent creation for the same delivery + workflow).
204+ fn is_delivery_conflict ( error : & sqlx:: Error ) -> bool {
205+ matches ! (
206+ error,
207+ sqlx:: Error :: Database ( db_err)
208+ if db_err. constraint( ) == Some ( "pipelines_delivery_workflow_uq" )
209+ )
146210}
147211
148212pub struct ListFilter {
0 commit comments