Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions entity-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,35 @@ M2M_CLIENT_IDS=
# CSM_MIGRATION_SALESFORCE_MEMBERSHIP_INGEST_ENABLED=true.
# SALESFORCE_INGEST_RETRY_INTERVAL=5m

# Deferred Salesforce membership writes: let a portal invitation survive a
# sales-entity-service / Salesforce outage. The rows commit with NULL sf_id, a
# salesforce_write_request row is recorded (migration 0249), and
# salesforce.membership_write.requested (the admin's intent + the project's and
# account's Salesforce ids) is published for sales-entity-service, which
# performs the write search-first and answers on the results topic; this
# service then stamps the ids and settles the request. Exactly "true"; off by
# default. Also needs CSM_MIGRATION_PORTAL_WRITES_ENABLED, the SALES_ENTITY_*
# set, EVENT_HUB_BROKER and EVENT_PUBLISHING_ENABLED=true.
# SALESFORCE_DEFERRED_WRITES_ENABLED=true
# The command topic (consumed by sales-entity-service), the results topics
# (consumed here) and this service's consumer groups. All four Event Hubs and
# both groups must exist; never share a group with another component.
# SALESFORCE_WRITE_EVENT_HUB_TOPIC=salesforce-writes
# SALESFORCE_WRITE_RESULTS_TOPIC=salesforce-write-results
# SALESFORCE_WRITE_RESULTS_DLQ_TOPIC=salesforce-write-results-dlq
# SALESFORCE_WRITE_RESULTS_CONSUMER_GROUP=entity-service-salesforce-write-results
# SALESFORCE_WRITE_RESULTS_DLQ_CONSUMER_GROUP=entity-service-salesforce-write-results-dlq
# Results consumer policies. What a result waits on is Sales Entity's REST reads
# and this database; the Salesforce retry policy itself is sales-entity-service's
# (SALESFORCE_WRITES_*RETRY* there). Main tier: a few quick attempts, then
# dead-letter. Dead-letter tier: attempts with a doubling pause up to the cap;
# after its last attempt the request is marked FAILED naming the ids.
# SALESFORCE_WRITE_RESULTS_RETRY_ATTEMPTS=3
# SALESFORCE_WRITE_RESULTS_RETRY_DELAY=2s
# SALESFORCE_WRITE_RESULTS_DLQ_RETRY_ATTEMPTS=10
# SALESFORCE_WRITE_RESULTS_DLQ_RETRY_DELAY=30s
# SALESFORCE_WRITE_RESULTS_DLQ_RETRY_MAX_DELAY=15m

# Let POST /salesforce/events act on Account envelopes (account upsert and
# soft-delete). Off by default, and keep it off while the ServiceNow sync still
# writes the account table: the two would fight over the same rows.
Expand Down
91 changes: 91 additions & 0 deletions entity-service/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ The server loads `.env` automatically on startup (silently ignored if absent). P
| `CSM_MIGRATION_SALESFORCE_PARTNER_INGEST_ENABLED` | no | `false` | Must be `"true"` for the partner-link refresh (`account_relationship` "Is Partner Of" / "Is Customer Of") to run after Account events and partner-contact membership events, and for `POST /salesforce/accounts/{sfId}/refresh-partners` to be registered (see "Salesforce partner relationships" below). Keep it off while csm-sync-service still copies `account_relationship` from ServiceNow |
| `SALESFORCE_INGEST_RETRY_INTERVAL` | no | `5m` | How often the Salesforce ingest retry worker re-runs memberships whose DATABASE step FAILED because their project or account was not in CSM yet, and how old such a failure must be before it is re-run (see "Salesforce ingest ledger and the delayed-retry job" below). `0` disables the job, and so does an unparseable or negative value (logged as a warning; it fails closed rather than falling back to `5m`). Only runs when `CSM_MIGRATION_SALESFORCE_MEMBERSHIP_INGEST_ENABLED=true` |
| `CSM_MIGRATION_PORTAL_WRITES_ENABLED` | no | `false` | Must be `"true"` to register the four portal-driven membership write routes under `/projects/{id}/contacts` (see "Portal-driven membership writes" below). Also needs `DATA_SOURCE=postgres`, a pool, and the full `SALES_ENTITY_*` set (`Config.HasPortalMembershipWrites`). Off means the routes are **not registered at all**, not 403 |
| `SALESFORCE_DEFERRED_WRITES_ENABLED` | no | `false` | Must be `"true"` to let a portal invitation survive a sales-entity-service outage: the rows commit with NULL `sf_id`, a `salesforce_write_request` row is recorded, and `salesforce.membership_write.requested` is published for this service's own consumer to finish (see "Deferred Salesforce writes" under "Portal-driven membership writes"). Also needs `HasPortalMembershipWrites` and `EVENT_HUB_BROKER` + `EVENT_PUBLISHING_ENABLED=true`; without them it is logged and stays off. Off = an outage fails the invitation with 503 as before |
| `SALESFORCE_WRITE_EVENT_HUB_TOPIC` | no | `salesforce-writes` | Topic the deferred-write **commands** are published to, consumed by sales-entity-service (digiops-sales, `membershipwrite`). The Event Hub must exist or every deferred invitation's publish fails (recorded in `event_publish_failures`, the request stays unpublished) |
| `SALESFORCE_WRITE_RESULTS_TOPIC` / `SALESFORCE_WRITE_RESULTS_DLQ_TOPIC` | no | `salesforce-write-results` / `…-dlq` | Topics sales-entity-service **answers** on (`salesforce.membership_write.completed` / `.failed`) and this service's dead-letter copy of them. Both must exist |
| `SALESFORCE_WRITE_RESULTS_CONSUMER_GROUP` / `…_DLQ_CONSUMER_GROUP` | no | `entity-service-salesforce-write-results` / `…-dlq` | This service's consumer groups on the two results topics. Never shared with another component |
| `SALESFORCE_WRITE_RESULTS_RETRY_ATTEMPTS` / `…_RETRY_DELAY` | no | `3` / `2s` | Main results tier: attempts and fixed pause, then dead-letter. A result's handling waits on Sales Entity's REST reads and this database only; the **Salesforce** retry policy lives in sales-entity-service |
| `SALESFORCE_WRITE_RESULTS_DLQ_RETRY_ATTEMPTS` / `…_DLQ_RETRY_DELAY` / `…_DLQ_RETRY_MAX_DELAY` | no | `10` / `30s` / `15m` | Dead-letter results tier: exponential, capped. On its last attempt the request is marked FAILED naming the Salesforce ids it could not stamp (the Salesforce echo still stamps them) |
| `CSM_MIGRATION_CUSTOMER_ENGAGEMENT_INGEST_ENABLED` | no | `false` | Registers `POST /customer-engagements/allocation-events` (Postgres-authoritative only); see "Allocation events" below |
| `CUSTOMER_ENGAGEMENT_FIREFIGHTING_TYPE_ID` | no | — | ServiceNow sys_id written as `engagement_type_id` on firefighting engagements created by allocation events. Unset skips creating them |
| `REDIS_URL` | no | — | `rediss://:<key>@<host>:<port>` (TLS, Azure Managed Redis); wins over `REDIS_ADDR`. Turns on the user cache (see "User cache (Redis)" below). `Validate` requires a `redis`/`rediss` scheme and a host, and never echoes the URL |
Expand Down Expand Up @@ -1850,6 +1856,91 @@ sent. A label Salesforce would reject is a **400 naming it**, not an
record Salesforce already holds (refusing would strip a real membership over a
vocabulary gap), and wrong to tolerate one a caller is asking us to write.

### Deferred Salesforce writes (`project_membership_deferred_write.go`)

The ordering above makes every invitation fail with 503 while
sales/sales-entity-service (or Salesforce behind it) is down, and a customer
admin can do nothing about that. With `SALESFORCE_DEFERRED_WRITES_ENABLED=true`
a **first invitation** survives the outage; everything else keeps failing fast.
The Salesforce half then runs in **sales-entity-service** (digiops-sales,
`membershipwrite` package), the service that owns the Salesforce connection;
this service keeps the decision, the ledger and both CSM transactions.

1. `Invite` tries Salesforce synchronously as always. Only when the validator
or the write fails with `ServiceUnavailableError` (connection error,
timeout, 5xx, an answer with no id) **and** the project has no membership
for the address yet does it fall through. A validation refusal, a conflict,
or a re-invitation of a DEACTIVATED row (which already has Salesforce ids
and would be an UPDATE there) is never deferred.
2. The rows commit with NULL `sf_id` columns (`"user"`, `account_contact`,
`project_contact`; the repository never matches or stamps a blank id, and
records **no** DATABASE onboarding step, because the ledger is keyed by
the membership id) plus a `salesforce_write_request` row (migration 0249:
the admin's request as JSON `intent`, status PENDING) in the same
transaction. Then `salesforce.membership_write.requested` is published to
`SALESFORCE_WRITE_EVENT_HUB_TOPIC`, keyed by the request id, **carrying the
whole intent and the project's and account's Salesforce ids** (Sales Entity
cannot read this database), and `published_on` is stamped. The caller gets
**201** with `salesforceSync: "PENDING"` and empty ids.
3. sales-entity-service consumes the command on two tiers (its own
`SALESFORCE_WRITES_*RETRY*` policies: short fixed, then a dead-letter topic
with exponential backoff), performs the write **search-first** (contact by
address, membership by project and contact; create on a miss, PATCH state
and roles otherwise) so a redelivery adopts rather than duplicates, and
publishes `salesforce.membership_write.completed` (both ids, the stored
state and roles) or `.failed` (`terminal: true` for a Salesforce refusal,
`false` when its retries ran out) to `SALESFORCE_WRITE_RESULTS_TOPIC`.
Before writing it runs the invitation checks the validator could not run
during the outage, from the command's `allowedAccountSfIds` (the project's
account and its partners, read here at deferral time) and `inviterEmail`:
at most one contact per address, the inviter on an allowed account, no
take-over of another account's live membership, the address's domain in
the base account's or a partner's domain list, and adoption of an existing
contact only from an allowed account. A refusal is a terminal `.failed`.
4. `startSalesforceWriteResultConsumers` (`internal/server/salesforce_write_consumer.go`)
consumes the results. `CompleteDeferredSalesforceWrite` re-reads the
request (settled → acknowledge; this is what makes a duplicate result
harmless), reads the records back through Sales Entity's REST API
(`GetProjectContact`, `GetContact`) and maps them with the ingest's
`buildMembershipUpsert`, then in **one transaction** upserts the rows with
the ids, records the DATABASE step and marks the request SUCCEEDED, and
only then publishes `project_contact.invited`. The invitation email and the
identity provisioning therefore wait for the Salesforce id; nothing else
publishes that event for a deferred row (the Salesforce echo stays silent
because the row is already INVITED). `FailDeferredSalesforceWrite` marks
the request **FAILED** with Sales Entity's reason.
5. A result whose handling cannot finish (Sales Entity REST or the database
unavailable) is retried per the results policies; on the dead-letter
tier's last call the request is marked FAILED naming the Salesforce ids it
could not stamp, so it never sits PENDING with nothing behind it. A
terminal problem (the row is gone, the records do not add up) is FAILED at
once.
6. While a request is PENDING, `UpdateRoles`, `Deactivate` and
`ResendInvitation` answer **409** with code `salesforce_sync_pending`
("still being synced to Salesforce"). Once it is FAILED, or was never
published, `UpdateRoles` and `ResendInvitation` answer **409**
`salesforce_sync_failed` and `Deactivate` runs **locally** (no Salesforce
half to undo), closing the request as CANCELLED so nothing picks it up. A
row with no id and no request keeps the generic missing-id refusal.
`salesforce_write_request.retryable` tells a FAILED row's two causes apart
(retries exhausted vs. refused). A PENDING request that was never
published (the command publish failed) reads as FAILED everywhere -- the
contact lists, the refusals and the local removal -- since nothing will
consume it without a re-publish. The contact reads
(`POST /projects/{id}/contacts/search`, `GET …/{contactId}`) carry
`salesforceSync: PENDING | FAILED` from the latest request row, read
best-effort through `WithProjectContactSalesforceSync`.

What the event path does **not** cover: a command or result that is never
delivered (the row stays PENDING, `published_on` says whether the command
left at all), and an outage that outlives Sales Entity's retries (the row
says FAILED). Both need a re-publish — a sweep or a manual retry — which is
deliberately not part of this change. `membership_registration_service.go`
already skips rows with a blank `sf_id`, so a person who signs in before the
sync lands is registered on a later login. The local stack exercises the
whole loop with `scripts/csm-compose/sf-deferred-e2e.sh` and a mock
sales-entity-service that plays the consumer.

### The Sales Entity write client (`internal/salesentity/membership_writes.go`)

`SearchContactByEmail` (`POST /contacts/search {email}`), `CreateContact`
Expand Down
15 changes: 15 additions & 0 deletions entity-service/internal/apierror/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,18 @@ const (
// lifecycle still moves. Nothing was written.
CodeChangeRequestApprovedReadOnly = "change_request_approved_read_only"
)

// The refusals of a portal membership write (PATCH / DELETE /
// projects/{id}/contacts/{email} and the resend) while the contact's
// Salesforce half is owed by a deferred write (see
// service/project_membership_deferred_write.go). The portal tells them
// apart from the generic missing-id refusal, which has no code.
const (
// CodeSalesforceSyncPending: the deferred write is still PENDING; the
// write needs the Salesforce ids it will produce. Retrying later helps.
CodeSalesforceSyncPending = "salesforce_sync_pending"
// CodeSalesforceSyncFailed: the deferred write ended FAILED (or was
// never published). Role changes and resend are refused; removing the
// contact is allowed and runs locally.
CodeSalesforceSyncFailed = "salesforce_sync_failed"
)
Loading
Loading