Shared releases
With synchronized contexts, every application of a split content model previews the
others' drafts, releases and experiments, but only the application that manages the releases changes its entities in
them. Shared releases let the other applications participate: a PIM changes its categories in the CMS's release
autumn, with the same release id, and the CMS publishes the release with its pages and the PIM's categories together.
| Application | Role | Changes in the release |
|---|---|---|
| CMS | Coordinates its releases and experiments | Its pages |
| PIM | Participates in the CMS's releases and experiments | Its categories |
| Delivery | Consumes both | Nothing |
Each application merges only what it authors, in its own transaction. No transaction spans the applications, so a shared publication is atomic per application and eventual across them: Vox prepares every participant before any of them publishes, and it reports a partial failure as one, but it can't undo what an application has already published.
Roles
UseContextSynchronization has three roles, configured per application:
- Consume a source's contexts, see synchronized contexts. A delivery application needs nothing else, and it's never a participant.
- Participate in a source's releases, or in a mergeable context type it explicitly shares, eg its experiments. It needs authoring, and it consumes the source's contexts too.
- Coordinate the releases and mergeable context types this application owns with the configured participants. It needs authoring, with content releases if they're shared. The coordinator can change its own entities in them too.
// The CMS owns the releases and the experiments, and shares them with the PIM
services.AddVox(options => options
.UseAuthoring()
.ConfigureContextType<Experiment>(type => type.Mergeable())
.UseContextSynchronization(sync => sync
.Consume("pim", pim => pim.Drafts().Entities<Category>())
.Coordinate("cms", cms => cms
.Participant("pim", pim => pim
.At(new Uri("https://pim.example.com/vox/"), configuration["Vox:PimSecret"]!)
.Releases()
.ContextType<Experiment>()))));
// The PIM changes its categories in them, without releases of its own
services.AddVox(options => options
.UseAuthoring(authoring => authoring.WithoutContentReleases())
.ConfigureContextType<Experiment>(type => type.Mergeable())
.UseContextSynchronization(sync => sync
.Participate("cms", cms => cms
.Drafts()
.Releases<CmsRelease>()
.ContextType<Experiment>()
.Entities<CategoryContentPage>())));
Coordinate(source, ...)names this application as its participants know it, the source of the snapshots it sends them.Participant(name, ...)adds a participant by the name of the source of its snapshots.At(endpoint, secret)is the base address of the participant's Vox API and the secret its requests are signed with; register anotherIVoxContextParticipantClientto reach the participants in another way.Participate(source, ...)takes what's shared:Releases<TRelease>()binds the source's releases, andContextType<TContext>()a mergeable context type of the source, which keeps the source's name, as the generated external type does, since the participant's contributions are keyed by it. The source's requests are signed with the secret of its snapshots, configured withapiOptions.ReceiveEntitySync(source, secret).
The coordinator also receives the participants' contributions: a snapshot subscription of the PIM that chooses
$release and the shared context types in SidecarContextTypes carries the PIM's changes in the CMS's releases to the
CMS, which previews and validates its pages against them.
Startup rejects a participant without authoring or without anything shared, a coordinator without authoring, releases shared by an application without releases of its own, and a shared context type that the coordinator doesn't own.
Changing entities in a shared release
A participant's authored entities are changed in the source's release like in a release of its own: saved at its key,
$release=autumn, with IVoxAuthoringEntityUpdater, added and removed with IVoxContentReleaseManager, and previewed
with context=$release:autumn. The release must be open, which its synchronized status tells. A participant's item
operations need its own publish permission of the entity type, since the release isn't managed there, while creating
the release, scheduling, publishing and discarding it belong to the coordinator.
Each application lists only its own items. Its merge never edits or deletes the release or the coordinator's experiment, and never merges the cells of external entities: the CMS's cells of the PIM's category in the release stay until the PIM's snapshots remove them.
Publishing
IVoxContentReleaseManager.PublishAsync of a shared release, or its due schedule, starts a coordinated operation,
which has a stable id, the release's identity and lifecycle revision, the participants with their states, and their
receipts. It's saved before every request, so a restart or a lost acknowledgement continues it where it was.
- Prepare. Every participant, the coordinator's own contribution first, closes its contribution with a durable guard and captures it: the entities it changes, with the revisions of their snapshots. While it's closed, every write of a local authored entity at the release's key is rejected, also entity, batch and cell updates, existence, imports, publishing drafts into the release and removing items. Synchronization and calculations continue.
- Validate. Every participant waits until the others' captured entities have arrived there, at least at the captured revisions, so it never validates against a copy it knows is stale. Then it validates its contribution, including its relations to the others' content in the release, with the rules of a release: drafts within the release, conflicts with published content, publish permissions and validation.
- Commit. Once every participant has prepared, the commit is decided. Every participant merges its prepared contribution and records its receipt in one transaction, and a participant without changes records a no-op. A release with content in any application can be published; one with no content anywhere is rejected.
- Finish. Once every participant has committed, the coordinator marks the release published. Receiving that status is never an instruction to merge. Normal synchronization carries the published content, and removes the preview cells.
A participant that fails before the commit cancels the operation: the others reopen their contributions, nothing is published, and the release gets the error, like a failed publication of a release of one application. After the commit decision the operation can't be cancelled: a participant that fails to commit, eg on a conflict with what was published since, reopens its contribution for repair and is retried, while what the others committed stays committed and can be visible. An unavailable participant leaves the operation pending, never silently excluded.
PublishAsync returns the published release when every participant answers at once. When the operation continues in
the background, eg until an unavailable participant has prepared, it throws VoxContextOperationPendingException with
the operation, and the API answers 202 with it. The operation continues on the Nexus queue vox_context_operation.
Retries and idempotency
Every request is retried with the operation id until it's answered. A participant that has committed returns its receipt instead of merging later edits or publishing again. A delayed request of a cancelled or superseded operation, recognized by its lifecycle revision, is rejected, and so is a request with another payload than the one recorded for its id. Only one operation of a member runs at a time, and a manual action and a schedule compete through it: an obsolete schedule is a no-op once the release is published, and changing the schedule while an operation runs is rejected.
Schedules and reverts
Only the coordinator schedules. A due schedule starts the same operation as publishing now, and participants run no timers of their own. A shared revert is an operation of its own, tied to the receipts of the publication it reverts: every participant checks that its own publication can be restored, then restores it atomically, conflicting as restores do, and the release is marked reverted once all have. A release published before it was shared is reverted where it was published.
Merging and discarding
Merging a variant of a shared experiment with IVoxContextMerger runs the same protocol, and the coordinator
deletes the variant once every participant has merged its contribution.
Discarding a release or a variant that isn't published, with IVoxContextOperations.DiscardAsync or the ordinary
delete of its entity, closes every participant's contribution, asks each one to remove it, and then deletes the member
at the coordinator. A published release can't be discarded; revert it instead.
A deleted member is retired with how it ended, Merged, Discarded or Deleted, the operation that ended it and its
lifecycle revision, and its deletion carries that to the participants and consumers, since neither the member's absence
nor its deletion tells what became of the contributions. A participant holds back the deletion of a member while its own
contribution there isn't settled, eg one the source says it merged while it wasn't merged there, instead of deleting it.
Operations in the API and the admin
| Endpoint | Application | |
|---|---|---|
GET api/context-operations?contextType=$release&memberId=autumn | Coordinator | The operations of a member, with each participant's state, receipt and error |
GET api/context-operations/{id} | Coordinator | One operation |
POST api/context-operations/{id}/retry | Coordinator | Asks the participants again |
POST api/context-operations/{id}/cancel | Coordinator | Cancels an operation before its commit is decided |
POST api/context-operations/discard?contextType=…&memberId=…&version=… | Coordinator | Discards a member |
GET api/context-operations/contributions?contextType=…&memberId=… | Participant | Its own contributions |
POST api/context-operations/{source} | Participant | The signed requests of the source |
Operations of releases need vox:ContentReleases at the coordinator. A participant authorizes the source's requests as
the principal of the source, by default the integration vox-sync:{source}, which needs the publish permissions of the
entity types it changes in the shared members; the user who started the operation is recorded, but a remote user's
permissions don't apply. Change the principal with cms.As(principal).
The coordinator's release shows the participants it's shared with and the latest operation, with the progress and error of each participant, to retry or cancel it. A participant lists the source's releases with their status and the state of the source's operation, and shows its own entities in a release next to its part of the operation. A consumer stays read-only.
Eventual visibility
A shared publication is visible application by application: the PIM's categories can be published a moment before the CMS's pages, and a delivery application receives each source's published snapshots when they arrive, see eventual consistency. Atomic activation of a combined publication in delivery sinks is a planned follow-up.