You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Supabase保存Calendly webhook数据时竞态条件处理方案

Calendly Webhook重调度竞态问题解决方案

问题现象

当前对接Calendly Webhook时,订阅了两类事件:

  • invitee.created:会话创建
  • invitee.canceled:会话取消
    接收推送后自动更新数据库内的预约记录,单独创建、单独取消的常规场景下逻辑运行正常。但用户执行重排日程操作时,Calendly会按「先触发旧会话取消事件→再触发新会话创建事件」的逻辑推送两条通知,现有代码对两个事件做异步并发处理、无顺序控制,会出现竞态条件,导致最终数据库状态不符合预期。

现有业务代码

// data is what I get from the webhook which is 
// an object containing information about the session 
// booked or cancellled. It looks something like this:

{
  created_at: '2022-07-06',
  created_by: 'https://api.calendly.com/users/3a1c8403',
  event: 'invitee.created',
  payload: {
    cancel_url: 'https://calendly.com/cancellations/058cb81f',
    created_at: '2022-07-06',
    email: 'xx@xxx.com',
    event: 'https://api.calendly.com/scheduled_events/7867fa63',
    first_name: null,
    last_name: null,
    name: 'xxxx',
    new_invitee: null,
    no_show: null,
    old_invitee: 'https://api.calendly.com/scheduled_events//invitees/283ba2b1-da59-4b2c',
    payment: null,
    questions_and_answers: [ [Object] ],
    reconfirmation: null,
    reschedule_url: 'https://calendly.com/reschedulings/058cb81f',
    rescheduled: false,
    routing_form_submission: null,
    status: 'active',
    text_reminder_number: null,
    timezone: 'Asia',
    tracking: {
      utm_campaign: null,
      utm_source: '158',
      utm_medium: null,
      utm_content: null,
      utm_term: null,
      salesforce_uuid: null
    },
    updated_at: '2022-07-06T16:53:23.191059Z',
    uri: 'https://api.calendly.com/scheduled_events/7867f'
  }
}


// then my logic:
const getSessionData = () => {
  axios
    .request({
      method: 'GET',
      url: `http://xxxx`,
      headers: {
        'Content-Type': 'application/json',
        Authorization: process.env.CALENDLY_API_KEY,
      },
    })



export default async (req, res) => {
  const body = (await buffer(req)).toString()
  const data = body ? JSON.parse(body) : null

  if(data) {
    switch (data?.event) {
      case 'invitee.canceled':
        const processCancellation = async () => {
          const cancelPromise = getSessionData(event)
          const cancelSessionData = await cancelPromise
    
          // update database with sessionData
          await supabase
            .from('booking')
            .update({
              // cancelSessionData...
            })
            .eq('id', sessionId)
        }
        processCancellation()
        break

      case 'invitee.created':
        const processBooking = async () => {
          const createPromise = getSessionData(event)
          const createSessionData = await createPromise
          await supabase
            .from('booking')
            .update({
              // createSessionData...
            })
            .eq('id', sessionId)
        }
        processBooking()
        break

      default:
        console.log(
          `Sorry, no data!`
        )
    }
  }
  res.send({ received: true })
}

可选解决方案

方案1:基于事件时间戳实现乐观锁(优先推荐,改造成本最低)

Calendly推送的所有事件payload中都自带updated_at时间戳,重调度场景下取消事件的时间戳必然早于新建事件的时间戳,基于这个特性做乐观锁即可解决乱序问题:

  1. 给booking表新增last_event_time字段,用于存储最后一次更新该记录的事件时间戳
  2. 每次执行更新操作时,增加判断条件:只有当前事件的updated_at晚于数据库中存储的last_event_time时,才允许更新
  3. 更新时同步将last_event_time字段的值设为当前事件的updated_at
    核心更新逻辑示例:
// 取消事件处理逻辑,创建事件逻辑完全一致
await supabase
  .from('booking')
  .update({
    // 原有需要更新的业务字段
    last_event_time: data.payload.updated_at
  })
  .eq('id', sessionId)
  .lt('last_event_time', data.payload.updated_at) // 仅更新时间戳更早的记录,拦截乱序请求

该方案完全在数据库层面实现拦截,不需要修改webhook响应逻辑、不需要引入额外组件,哪怕取消事件因为网络延迟晚于创建事件到达,也会因为时间戳更旧被拦截,不会覆盖新预约的有效状态。

方案2:识别重调度关联字段,跳过无效取消

Calendly的事件payload本身自带重调度关联标识:

  • 重调度触发的invitee.created事件中,old_invitee字段不为空,指向被替换的旧预约资源地址
  • 对应的旧预约触发的invitee.canceled事件中,new_invitee字段不为空,指向替换后的新预约资源地址
    处理取消事件时增加判断:如果当前取消事件的payload中new_invitee字段有值,说明本次取消是重调度触发的,直接跳过本次取消的数据库更新操作,等待后续创建事件落地即可。
    该方案逻辑简单,但需要做好新旧预约的ID映射匹配,避免误拦截用户主动触发的正常取消事件。

方案3:同预约ID事件串行处理(适合复杂业务场景)

如果后续需要对接的webhook事件类型更多、业务逻辑更复杂,可以引入分布式锁或者消息队列:

  • 以预约ID作为锁键,同一个预约的事件处理必须拿到锁才能执行
  • 或者用消息队列按预约ID做哈希路由,保证同一个预约的所有事件被同一个消费者串行处理
    该方案从执行机制上根源避免并发问题,但需要引入额外中间件,改造成本更高,适合业务规模较大的场景。

现有代码的额外问题

  1. 目前getSessionData函数没有返回axios请求实例,调用后拿到的永远是undefined,无法正确获取Calendly接口返回的数据,需要补上return关键字。
  2. 分支中调用processCancellation()和processBooking()时没有加await,触发异步函数后直接返回了webhook响应,如果函数执行过程中报错会直接静默失败,无法感知异常,也会导致请求上下文提前释放引发不可预期的问题。

内容的提问来源于stack exchange,提问作者Omar

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 16:15:10