diff --git a/docs/content/interacting/duplicate-writes.mdx b/docs/content/interacting/duplicate-writes.mdx new file mode 100644 index 0000000000..2df11e4a2c --- /dev/null +++ b/docs/content/interacting/duplicate-writes.mdx @@ -0,0 +1,243 @@ +--- +sidebar_position: 3 +slug: /interacting/duplicate-writes +description: Handle duplicate writes and missing deletes in write operations +--- + +import { + AuthzModelSnippetViewer, + CardBox, + DocumentationNotice, + ProductConcept, + ProductName, + ProductNameFormat, + RelatedSection, + RelationshipTuplesViewer, + WriteRequestViewer, + SupportedLanguage, +} from "@components/Docs"; + + +# Duplicate Writes + + + +Handle duplicate writes and missing deletes gracefully using the Write API's optional `on_duplicate` and `on_missing` parameters. + + + +Common scenarios include high-volume data imports into or migrations between distributed systems where you need to ensure data synchronization without causing transaction failures on duplicate writes or missing deletes. + + + +## Understanding the Problem + +The Write API is the primary method for adding and removing in your store. + +By default, the Write API operates with strict validation ("all-or-nothing"): + +- Writing a tuple that already exists causes the entire request to fail (even if only 1 tuple out of 40 possible tuples is a duplicate) +- Deleting a tuple that doesn't exist causes the entire request to fail + +This strict behavior requires applications to check the current state of the tuple in before making changes, implement complex retry logic, or simply ignore the errors altogether. + +## How to Handle Duplicate Operations + +To improve import and migration scenarios, the Write API provides optional parameters to control how duplicate writes and missing deletes are handled. + +The `on_duplicate` and `on_missing` parameters change this behavior, allowing you to instruct the API to ignore these cases and process the rest of the request successfully. + +### Response: Default Behavior + +Without the `on_duplicate` parameter, attempting to write an existing tuple returns a `400 Bad Request`. + +```http +HTTP/1.1 400 Bad Request + +{ + "code": "write_failed_due_to_invalid_input", + "message": "cannot write a tuple which already exists: user: 'user:anne', relation: 'writer', object: 'document:2025-budget': tuple to be written already existed or the tuple to be deleted did not exist" +} +``` + +### Response: Ignoring Duplicates + +Setting `on_duplicate: "ignore"` allows the duplicate to be ignored and the API returns a `200 OK`. + +```http +HTTP/1.1 200 OK + +{} +``` + +## Optional API Parameters + +The parameters are added within the `writes` and `deletes` objects in the body of a Write request. + +### Writes + +The `on_duplicate` parameter controls the behavior when writing tuples. + +- **"error" (Default)**: The request fails if any tuple in the writes array already exists. This maintains backward compatibility. +- **"ignore"**: The API ignores any attempt to write a tuple that already exists and proceeds to write the new ones in the same transaction. The request succeeds. + + + +:::caution +At the moment, this feature is only available on the API. Supported SDKs will follow shortly after. +::: + +### Deletes + +The `on_missing` parameter controls behavior when deleting tuples. + +- **"error" (Default)**: The request fails if any tuple in the deletes array does not exist. +- **"ignore"**: The API ignores any attempt to delete a tuple that does not exist and proceeds to delete the ones that do exist. The request succeeds. + + + +## Using Both Parameters Together + +The decision to have separate `on_duplicate` and `on_missing` parameters is intentional. This design gives you granular, independent control over the behavior of writes and deletes within a single atomic transaction. You can mix and match strict and permissive behaviors to suit your exact needs. + +For example, you might perform a strict delete (`on_missing: "error"`) to confirm that a specific permission has been successfully removed before making a permissive write (`on_duplicate: "ignore"`) that guarantees the new permission exists. + +:::tip +This flexibility is particularly useful when you are trying to synchronize state between your application's database and . You might need a strict operation to fail so you can roll back corresponding changes in your own database, ensuring overall system consistency. +::: + + + +In this example: +- If the permission to delete doesn't exist, the entire request will fail as intended. +- If the tuple exists and is successfully deleted, the write permission will succeed whether the tuple already exists or is written in that moment. + +## Important Concepts + +### The Write Request Remains Atomic + +All tuples in a request will still be processed as a single atomic unit. The `ignore` option only changes the success criteria for individual operations in the request. + +### "Best effort" ignore + +For writes: An `on_duplicate: 'ignore'` operation uses a "best effort" approach. We will attempt *once* to ignore duplicates and write non-duplicates, but if there is ever a conflict writing to the database (i.e. write a tuple we don’t think exists but it suddenly exists, probably due to a parallel request), we will abort the race condition immediately, and just return a `409 Conflict` error. These errors are rare, but can happen. + +For deletes: An `on_missing: 'ignore'` operation is immune to race conditions due to database-level locks. It is not possible for another request to interfere since a delete operation will always succeed if the tuple exists or not. + +### "Ignore" is Not an "Upsert" + +It is critical to understand that `on_duplicate: "ignore"` will not update an existing tuple, only ignore an identical tuple. This is why we do not call the operation an "idempotent" operation. + +The behavior of `on_duplicate: "ignore"` is more nuanced for tuples with conditions. +- **Identical Tuples**: If a tuple in the request is 100% identical to an existing tuple (same user, relation, object, condition name, and condition context), it will be safely ignored. +- **Conflicting Tuples**: If a tuple key (user, relation, object) matches an existing tuple, but the condition is different, this is a conflict. The write attempt will be rejected, and the entire transaction will fail with a `409 Conflict` error. **The correct pattern to safely update a tuple's condition requires explicitly deleting the old tuple and writing the new one within the same atomic Write request.** + + + +```http +HTTP/1.1 409 Conflict +{ + "code": "Aborted", + "message": "transactional write failed due to conflict: attempted to write a tuple which already exists with a different condition: user: 'user:anne', relation: 'writer', object: 'document:2025-budget'" +} +``` + +:::tip +The condition is not returned in the response, but you can call `/read` for this tuple to view its condition. +::: + +:::warning +The deletes operation in the Write API does not accept a condition. Attempting to include one will result in an invalid request. +::: + +## Related Sections + + diff --git a/docs/content/interacting/overview.mdx b/docs/content/interacting/overview.mdx index 9ea513c0ae..3180d1fde8 100644 --- a/docs/content/interacting/overview.mdx +++ b/docs/content/interacting/overview.mdx @@ -47,6 +47,11 @@ This section helps you integrate +:::tip +For handling cases where you need to write tuples that might already exist or delete tuples that might not exist, check out [Duplicate Writes](./duplicate-writes.mdx) which provides resilient write operations. +::: + The Write API allows you to send up to 100 unique tuples in the request. (This limit applies to the sum of both writes and deletes in that request). This means we can submit one API call that converts the `tweet` from public to visible to only the `user`'s followers. service attempts to perfo `${JSON.stringify(tuple)}`).join(',') - : ''; - const deleteTuples = opts.deleteRelationshipTuples - ? opts.deleteRelationshipTuples.map((tuple) => `${JSON.stringify(tuple)}`).join(',') - : ''; - const writes = `"writes": { "tuple_keys" : [${writeTuples}] }`; - const deletes = `"deletes": { "tuple_keys" : [${deleteTuples}] }`; - const separator = `${opts.deleteRelationshipTuples && opts.relationshipTuples ? ',' : ''}`; + // Build the JSON object for pretty printing + interface RequestBody { + writes?: { + tuple_keys: Array>; + on_duplicate?: string; + }; + deletes?: { + tuple_keys: Array>; + on_missing?: string; + }; + authorization_model_id?: string; + } + + const requestBody: RequestBody = {}; + + if (opts.relationshipTuples?.length) { + requestBody.writes = { + tuple_keys: opts.relationshipTuples.map((tuple) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _description, ...cleanTuple } = tuple; + return cleanTuple; + }), + }; + if (opts.writeOptions?.on_duplicate) { + requestBody.writes.on_duplicate = opts.writeOptions.on_duplicate; + } + } + + if (opts.deleteRelationshipTuples?.length) { + requestBody.deletes = { + tuple_keys: opts.deleteRelationshipTuples.map((tuple) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _description, ...cleanTuple } = tuple; + return cleanTuple; + }), + }; + if (opts.deleteOptions?.on_missing) { + requestBody.deletes.on_missing = opts.deleteOptions.on_missing; + } + } + + // Add authorization_model_id at the end + requestBody.authorization_model_id = modelId; + + const prettyJson = JSON.stringify(requestBody, null, 2); + return `curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/write \\ -H "Authorization: Bearer $FGA_API_TOKEN" \\ # Not needed if service does not require authorization -H "content-type: application/json" \\ - -d '{${opts.relationshipTuples ? writes : ''}${separator}${ - opts.deleteRelationshipTuples ? deletes : '' - }, "authorization_model_id": "${modelId}"}'`; + -d '${prettyJson}'`; } case SupportedLanguage.JS_SDK: {