feat(scheduled): address schedule assignments by id for multi-instance scheduling#280
Merged
Conversation
…e scheduling Schedule assignments are now addressed by their own id. Submitting with an existing assignment id upserts that row; omitting it creates a new instance, so a single subject can hold multiple assignments to the same named schedule (e.g. several reminders of the same kind, or rescheduling a specific one). - migration drops the unique (user_id, schedule_id) / (organization_id, schedule_id) indexes; the non-unique lookup indexes are kept - UpsertUserSchedule / UpsertOrganizationSchedule take an id and conflict on (id), guarded by user/schedule so a supplied id cannot hijack another subject's row (ErrScheduleOwnershipMismatch otherwise) - ScheduledMsg carries assignment_id; the client API exposes an optional id on the upsert requests and returns the assignment id in the 202 response; the management API exposes an optional id so the console can target an instance - journey schedule steps derive a deterministic assignment id from (user, schedule) so re-runs/retries stay idempotent (no behaviour change) - regenerated client + management Go oapi and the console TS types
…elete-by-id Follow-up to the id-based assignment change: - Journey schedule steps now create a NEW assignment per journey entry (a user's pass through the journey), so re-entering schedules an additional instance. The id is derived from (journey_entry_id, step_id) so a redelivered/retried step message stays idempotent instead of duplicating. - Client API delete now accepts an optional id to remove a single assignment (ownership-checked); without an id it still deletes every assignment by name. name is now optional (either id or name required); regenerated client oapi.
The generated files were produced with v2.5.1 locally, which diverges from the CI-pinned v2.7.0 and broke the generate check. Regenerated with v2.7.0; the diff vs main is now scoped to the new schedule id fields only.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the scheduling assignment layer so per-subject schedule assignments are addressed by assignment id (rather than being uniquely keyed by (subject, schedule_id)), enabling multiple instances of the same named schedule per user/organization while still allowing safe idempotent updates when an assignment id is supplied.
Changes:
- Drop the unique
(user_id, schedule_id)/(organization_id, schedule_id)indexes to allow multiple assignments per named schedule. - Update store upserts to
ON CONFLICT (id)with an ownership guard, returningErrScheduleOwnershipMismatchwhen an id targets a different subject/schedule. - Thread optional assignment
idthrough client + management APIs and pubsub messages; journeys derive deterministic assignment ids per(journey_entry_id, step_id).
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/store/subjects/scheduled.go | Switch assignment upserts to be keyed by assignment id with ownership guard + new mismatch error. |
| internal/store/subjects/scheduled_test.go | Update/add store tests for id-based upsert behavior, multi-instance creation, and ownership mismatch. |
| internal/store/subjects/migrations/1784600000_schedule_multi_assignment.up.sql | Remove unique assignment-per-(subject,schedule) constraint to enable multiple instances. |
| internal/store/subjects/migrations/1784600000_schedule_multi_assignment.down.sql | Recreate the unique indexes on rollback (with caveat about existing duplicates). |
| internal/pubsub/schemas/events.go | Add assignment_id to scheduled pubsub message schema. |
| internal/pubsub/consumer/scheduled.go | Consume assignment_id when upserting user/org assignments; treat ownership mismatch as permanent failure. |
| internal/journeys/schedule.go | Generate deterministic assignment ids per journey entry + step to avoid duplicates on retries/redelivery. |
| internal/journeys/schedule_test.go | Add coverage ensuring per-entry instance creation and same-entry idempotency. |
| internal/http/controllers/v1/management/scheduled.go | Management upsert supports optional assignment id; returns 400 on ownership mismatch. |
| internal/http/controllers/v1/management/scheduled_test.go | Update management tests for id-based idempotency; add “creates new instance without id” test. |
| internal/http/controllers/v1/management/oapi/resources.yml | Extend management upsert request schemas with optional assignment id. |
| internal/http/controllers/v1/management/oapi/resources_gen.go | Regenerated management Go types for optional assignment id. |
| internal/http/controllers/v1/client/scheduled.go | Client endpoints mint/accept assignment ids, return assignment id in 202, and support delete-by-id vs delete-by-name. |
| internal/http/controllers/v1/client/oapi/resources.yml | Extend client schemas with optional assignment id; make delete name optional. |
| internal/http/controllers/v1/client/oapi/resources_gen.go | Regenerated client Go types reflecting optional id / optional name on delete. |
| console/src/oapi/management.generated.ts | Regenerated console TS management types for optional assignment id on upsert. |
Files not reviewed (1)
- internal/http/controllers/v1/client/oapi/resources_gen.go: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…dentifier Addresses PR review: - Consumer derives a deterministic assignment id from (subject, schedule) when a ScheduledMsg omits assignment_id, so producers that don't set one (user/org anniversary schedules) stay idempotent under at-least-once redelivery instead of minting a fresh id per delivery and duplicating the assignment. The client API still sends an explicit id, so its create-new-per-submit behaviour is unchanged. - DeleteUserScheduledRequest now requires identifier in the schema (the handler already rejected requests without one); regenerated client oapi and updated the handler for the now non-pointer identifier. - Added a unit test for resolveAssignmentID.
The UpsertUserSchedule / UpsertOrganizationSchedule doc comments still described the old (subject, schedule) conflict model. Updated to reflect that assignments are keyed by their own id (nil creates a new instance; an existing id updates in place, guarded by ErrScheduleOwnershipMismatch).
jeroenrinzema
added a commit
that referenced
this pull request
Jun 29, 2026
…e scheduling (#280) * feat(scheduled): address schedule assignments by id for multi-instance scheduling Schedule assignments are now addressed by their own id. Submitting with an existing assignment id upserts that row; omitting it creates a new instance, so a single subject can hold multiple assignments to the same named schedule (e.g. several reminders of the same kind, or rescheduling a specific one). - migration drops the unique (user_id, schedule_id) / (organization_id, schedule_id) indexes; the non-unique lookup indexes are kept - UpsertUserSchedule / UpsertOrganizationSchedule take an id and conflict on (id), guarded by user/schedule so a supplied id cannot hijack another subject's row (ErrScheduleOwnershipMismatch otherwise) - ScheduledMsg carries assignment_id; the client API exposes an optional id on the upsert requests and returns the assignment id in the 202 response; the management API exposes an optional id so the console can target an instance - journey schedule steps derive a deterministic assignment id from (user, schedule) so re-runs/retries stay idempotent (no behaviour change) - regenerated client + management Go oapi and the console TS types * feat(scheduled): journeys schedule a new instance per entry; client delete-by-id Follow-up to the id-based assignment change: - Journey schedule steps now create a NEW assignment per journey entry (a user's pass through the journey), so re-entering schedules an additional instance. The id is derived from (journey_entry_id, step_id) so a redelivered/retried step message stays idempotent instead of duplicating. - Client API delete now accepts an optional id to remove a single assignment (ownership-checked); without an id it still deletes every assignment by name. name is now optional (either id or name required); regenerated client oapi. * fix(scheduled): regenerate oapi with pinned oapi-codegen v2.7.0 The generated files were produced with v2.5.1 locally, which diverges from the CI-pinned v2.7.0 and broke the generate check. Regenerated with v2.7.0; the diff vs main is now scoped to the new schedule id fields only. * fix(scheduled): keep omitted-id messages idempotent; require delete identifier Addresses PR review: - Consumer derives a deterministic assignment id from (subject, schedule) when a ScheduledMsg omits assignment_id, so producers that don't set one (user/org anniversary schedules) stay idempotent under at-least-once redelivery instead of minting a fresh id per delivery and duplicating the assignment. The client API still sends an explicit id, so its create-new-per-submit behaviour is unchanged. - DeleteUserScheduledRequest now requires identifier in the schema (the handler already rejected requests without one); regenerated client oapi and updated the handler for the now non-pointer identifier. - Added a unit test for resolveAssignmentID. * docs(scheduled): update upsert doc comments for id-based semantics The UpsertUserSchedule / UpsertOrganizationSchedule doc comments still described the old (subject, schedule) conflict model. Updated to reflect that assignments are keyed by their own id (nil creates a new instance; an existing id updates in place, guarded by ErrScheduleOwnershipMismatch).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Today a subject can hold only one schedule assignment per named schedule: the
user_schedules/organization_schedulesrows are unique on(subject, schedule_id),and since
schedule_idis resolved from the name, submitting the same name againoverwrites the existing assignment. There was no way to schedule the same thing twice
for one user, or to reschedule/delete a specific instance.
What
Schedule assignments are now addressed by their own
id:assignments to the same named schedule.
The schedule definition (
schedules, unique perproject_id + name) is unchanged —this only affects the per-subject assignment layer.
Changes
1784600000_schedule_multi_assignmentdrops the unique(user_id, schedule_id)/(organization_id, schedule_id)indexes (the non-uniquelookup indexes are kept).
UpsertUserSchedule/UpsertOrganizationScheduletake anidandON CONFLICT (id), guarded byuser_id/schedule_id(and the org equivalent) so asupplied id cannot hijack another subject's row — returns
ErrScheduleOwnershipMismatchotherwise. A nil id mints a fresh one.
id; the202response returns the assignmentid (matching its documented "scheduled instance" meaning), threaded via
ScheduledMsg.assignment_id.idto remove a single instance(ownership-checked); without an id, delete still removes all assignments by name.
nameis now optional (eitheridornamerequired).idon upsert, so the console can target a specificinstance.
pass of a user through the journey), so re-entering schedules an additional instance.
The id is derived deterministically from
(journey_entry_id, step_id)so aredelivered/retried step message updates the same instance instead of duplicating it.
Semantic changes to be aware of
PUT .../scheduledis no longer idempotent-by-name — without an id itcreates a new instance. Idempotency is now by id (resubmit with the returned id).
Tests updated (
TestUpsert*ScheduledIdempotentnow pass the id; addedTestUpsertUserScheduledCreatesNewInstance).202responseidis now the assignment id rather than a throwawaypubsub-envelope id.
Console
No console code change required: the scheduled table is already keyed per-instance (no
dedup), so multiple same-named assignments render as separate rows, and per-instance
reschedule already exists (expand a row → edit the time →
PATCH .../scheduled/{id}).With this PR, "two schedules same name" + "reschedule one" work end-to-end.
Follow-up (separate repos)
idfields (trackedseparately).
Tests
TestUpsertUserScheduleCreatesDistinctInstancesandTestUpsertUserScheduleOwnershipMismatch. ✅TestHandleScheduleMultipleEntries(per-entry create + same-entry idempotency). ✅