{"course":{"slug":"agents-and-automation","title":"Agents and Automation","url":"https://addaly.com/learn/agents-and-automation","level":"intermediate","track":"build"},"lesson":{"slug":"triggers-and-schedules","title":"How the thing starts","url":"https://addaly.com/learn/agents-and-automation/triggers-and-schedules","position":57,"of":72,"minutes":8,"answer":"Acknowledge webhooks fast and deduplicate on the provider's event id; when you poll, advance a cursor over the data, never over the clock.","keyIdea":"Acknowledge webhooks fast and deduplicate on the provider's event id; when you poll, advance a cursor over the data, never over the clock."},"body":{"format":"markdown","text":"## Five ways work arrives\n\n1. **A schedule**: cron, a timer, a platform's scheduler.\n2. **A webhook**: somebody else's system pushes an event to you.\n3. **A queue**: an item of work waiting to be picked up.\n4. **Polling**: you ask repeatedly, because there is no webhook.\n5. **A person**: a button, a command, an email to a special address.\n\nMost real systems use three of these at once, and each has one thing that goes wrong.\n\n## Webhooks: four rules\n\n**Answer fast, then work.** Return 200 within a second or two and do the actual job asynchronously: put it on a queue. Providers time out and retry, so a slow handler does not merely feel slow; it manufactures duplicate work while the first copy is still running.\n\n**Verify the signature.** An unauthenticated webhook endpoint is a public API for creating work in your system, and its address is not a secret.\n\n**Deduplicate on the provider's event id.** Delivery is at-least-once everywhere. You will receive the same event twice, and a unique index on the event id is the entire fix:\n\n```sql\nINSERT INTO processed_events (event_id) VALUES ($1)\nON CONFLICT DO NOTHING;     -- zero rows affected means: already handled\n```\n\n**Expect them out of order.** The \"updated\" event can arrive before the \"created\" event. Order by the event's own timestamp, and make handlers tolerant of arriving early.\n\n## Polling: the cursor rule\n\nWhen there is no webhook, you poll. The mistake is almost universal:\n\n```python\nsince = last_run_time            # wrong\nsince = last_seen_updated_at     # right\n```\n\nWith the first version, any record written *while your job was running* falls in the gap between the query and the timestamp you save. It is invisible, it is a small percentage, and it is permanent: those records are never picked up again.\n\nTrack a cursor over the **data**, not the clock. Save the highest `updated_at` you actually saw, query with `>=` on the next run, and keep a small set of ids already processed so that equal timestamps do not cause duplicates. Ordering by `(updated_at, id)` makes this exact.\n\n## Queues, between trigger and work\n\nA queue buys you retries, a concurrency limit, and somewhere for permanently failed items to sit. You do not need a product for this.\n\nThe free path is a table:\n\n```sql\nSELECT * FROM jobs\n WHERE status = 'pending'\n ORDER BY created_at\n LIMIT 1\n FOR UPDATE SKIP LOCKED;\n```\n\n`SKIP LOCKED` is what makes that safe with several workers: each one takes a different row instead of blocking. That is a real queue, in a database you already run. Redis works too. Managed options (the cloud queue services) are worth it when you need cross-region durability, not before.\n\n## Two runs on the same record\n\nIf your trigger can fire twice for one entity, two runs will act on it simultaneously and both will believe they are alone. Take a lock on the **entity**, not on the job: a row in a `locks` table keyed by the order id, with an expiry.\n\n## Backfill is a different program\n\nThe first run against four years of history is not the nightly run with a bigger date range. It needs rate limiting, checkpointing, and the ability to resume from where it stopped. Write it as its own script, run it deliberately, and watch it."},"check":{"question":"A nightly job processes records changed since it last ran, saving `datetime.now()` at the end of each run and querying `updated_at > saved_time` on the next. Roughly 0.5% of records are never processed. Why?","options":[{"text":"Clock skew between the application server and the database.","correct":false,"tempting":"A real hazard in distributed systems, but it would produce sporadic drift in both directions rather than a consistent one-way loss."},{"text":"The comparison should be `>=` rather than `>`, so boundary records are excluded.","correct":false,"tempting":"Fixes the exact-equality edge case, which is a much smaller effect than the window the job creates while it runs."},{"text":"Records updated during the run are missed because the query ran before them but the saved timestamp is after them.","correct":true},{"text":"The job is timing out before it finishes reading all the records.","correct":false,"tempting":"Would produce a large, visible failure with an error, not a small steady leak on runs that report success."}]},"prev":{"slug":"writing-an-mcp-server","title":"Writing a small MCP server","url":"https://addaly.com/learn/agents-and-automation/writing-an-mcp-server"},"next":{"slug":"where-automation-runs","title":"Where it runs when your laptop is shut","url":"https://addaly.com/learn/agents-and-automation/where-automation-runs"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'How the thing starts', in Agents and Automation. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/agents-and-automation/triggers-and-schedules (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"How the thing starts","courseTitle":"Agents and Automation","module":{"index":7,"slug":"shipping-and-operating","title":"Shipping it, and keeping it alive"},"answer":null,"keyIdea":"Acknowledge webhooks fast and deduplicate on the provider’s event id; when you poll, advance a cursor over the data, never over the clock.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"five-ways-work-arrives","runs":[{"text":"Five ways work arrives"}]},{"t":"ol","start":1,"items":[[{"text":"A schedule","b":true},{"text":": cron, a timer, a platform’s scheduler."}],[{"text":"A webhook","b":true},{"text":": somebody else’s system pushes an event to you."}],[{"text":"A queue","b":true},{"text":": an item of work waiting to be picked up."}],[{"text":"Polling","b":true},{"text":": you ask repeatedly, because there is no webhook."}],[{"text":"A person","b":true},{"text":": a button, a command, an email to a special address."}]]},{"t":"p","runs":[{"text":"Most real systems use three of these at once, and each has one thing that goes wrong."}]},{"t":"h2","id":"webhooks-four-rules","runs":[{"text":"Webhooks: four rules"}]},{"t":"p","runs":[{"text":"Answer fast, then work.","b":true},{"text":" Return 200 within a second or two and do the actual job asynchronously: put it on a queue. Providers time out and retry, so a slow handler does not merely feel slow; it manufactures duplicate work while the first copy is still running."}]},{"t":"p","runs":[{"text":"Verify the signature.","b":true},{"text":" An unauthenticated webhook endpoint is a public API for creating work in your system, and its address is not a secret."}]},{"t":"p","runs":[{"text":"Deduplicate on the provider’s event id.","b":true},{"text":" Delivery is at-least-once everywhere. You will receive the same event twice, and a unique index on the event id is the entire fix:"}]},{"t":"code","lang":"sql","label":"SQL","text":"INSERT INTO processed_events (event_id) VALUES ($1)\nON CONFLICT DO NOTHING;     -- zero rows affected means: already handled"},{"t":"p","runs":[{"text":"Expect them out of order.","b":true},{"text":" The “updated” event can arrive before the “created” event. Order by the event’s own timestamp, and make handlers tolerant of arriving early."}]},{"t":"h2","id":"polling-the-cursor-rule","runs":[{"text":"Polling: the cursor rule"}]},{"t":"p","runs":[{"text":"When there is no webhook, you poll. The mistake is almost universal:"}]},{"t":"code","lang":"python","label":"Python","text":"since = last_run_time            # wrong\nsince = last_seen_updated_at     # right"},{"t":"p","runs":[{"text":"With the first version, any record written "},{"text":"while your job was running","i":true},{"text":" falls in the gap between the query and the timestamp you save. It is invisible, it is a small percentage, and it is permanent: those records are never picked up again."}]},{"t":"p","runs":[{"text":"Track a cursor over the "},{"text":"data","b":true},{"text":", not the clock. Save the highest "},{"text":"updated_at","code":true},{"text":" you actually saw, query with "},{"text":">=","code":true},{"text":" on the next run, and keep a small set of ids already processed so that equal timestamps do not cause duplicates. Ordering by "},{"text":"(updated_at, id)","code":true},{"text":" makes this exact."}]},{"t":"h2","id":"queues-between-trigger-and-work","runs":[{"text":"Queues, between trigger and work"}]},{"t":"p","runs":[{"text":"A queue buys you retries, a concurrency limit, and somewhere for permanently failed items to sit. You do not need a product for this."}]},{"t":"p","runs":[{"text":"The free path is a table:"}]},{"t":"code","lang":"sql","label":"SQL","text":"SELECT * FROM jobs\n WHERE status = 'pending'\n ORDER BY created_at\n LIMIT 1\n FOR UPDATE SKIP LOCKED;"},{"t":"p","runs":[{"text":"SKIP LOCKED","code":true},{"text":" is what makes that safe with several workers: each one takes a different row instead of blocking. That is a real queue, in a database you already run. Redis works too. Managed options (the cloud queue services) are worth it when you need cross-region durability, not before."}]},{"t":"h2","id":"two-runs-on-the-same-record","runs":[{"text":"Two runs on the same record"}]},{"t":"p","runs":[{"text":"If your trigger can fire twice for one entity, two runs will act on it simultaneously and both will believe they are alone. Take a lock on the "},{"text":"entity","b":true},{"text":", not on the job: a row in a "},{"text":"locks","code":true},{"text":" table keyed by the order id, with an expiry."}]},{"t":"h2","id":"backfill-is-a-different-program","runs":[{"text":"Backfill is a different program"}]},{"t":"p","runs":[{"text":"The first run against four years of history is not the nightly run with a bigger date range. It needs rate limiting, checkpointing, and the ability to resume from where it stopped. Write it as its own script, run it deliberately, and watch it."}]}],"check":{"question":[{"text":"A nightly job processes records changed since it last ran, saving "},{"text":"datetime.now()","code":true},{"text":" at the end of each run and querying "},{"text":"updated_at > saved_time","code":true},{"text":" on the next. Roughly 0.5% of records are never processed. Why?"}],"options":[{"text":[{"text":"Clock skew between the application server and the database."}],"correct":false,"tempting":[{"text":"A real hazard in distributed systems, but it would produce sporadic drift in both directions rather than a consistent one-way loss."}]},{"text":[{"text":"The comparison should be "},{"text":">=","code":true},{"text":" rather than "},{"text":">","code":true},{"text":", so boundary records are excluded."}],"correct":false,"tempting":[{"text":"Fixes the exact-equality edge case, which is a much smaller effect than the window the job creates while it runs."}]},{"text":[{"text":"Records updated during the run are missed because the query ran before them but the saved timestamp is after them."}],"correct":true,"tempting":null},{"text":[{"text":"The job is timing out before it finishes reading all the records."}],"correct":false,"tempting":[{"text":"Would produce a large, visible failure with an error, not a small steady leak on runs that report success."}]}],"order":[1,0,3,2],"why":null},"prev":{"slug":"writing-an-mcp-server","title":"Writing a small MCP server","minutes":10},"next":{"slug":"where-automation-runs","title":"Where it runs when your laptop is shut","minutes":8}}}