Open duckbytes opened 1 year ago
Hi @duckbytes,
If I only delete the record, everything works fine. If I modify the record before deleting it, I get conflict errors that are never resolved.
This sounds like a bug in DataStore, I'll label the issue as such so we take a closer look at it.
Meanwhile, can you await for the record to be modified? E.g. waiting for the outboxMutationProcessed
event before issuing the delete?
I thought a better solution to this problem would be to instead delete the body during the delete mutation with a custom override, but I wasn't able to figure out how to do it.
Yeah, that's possible but I think extending the amplify generated resolvers is a better choice in this case. You would be adding a resolver function into a slot that would do a PutItem
with the redacted field before doing the DeleteItem
(Pipeline resolvers offer the ability to serially execute operations against data sources).
After some more testing, the repeated mutations attempt seem to occur any time there is any conflict.
This is how my conflict handler looks:
import { DISCARD } from "@aws-amplify/datastore";
import {
SyncConflict,
PersistentModel,
PersistentModelConstructor,
} from "@aws-amplify/datastore";
import * as models from "../../models";
import determineTaskStatus from "../../utilities/determineTaskStatus";
const dataStoreConflictHandler = async (
conflict: SyncConflict
): Promise<symbol | PersistentModel> => {
const { modelConstructor, localModel, remoteModel } = conflict;
console.log(
"DataStore has found a conflict",
modelConstructor,
remoteModel,
localModel
);
if (remoteModel.archived === 1) {
return DISCARD;
}
if (
modelConstructor ===
(models.Task as PersistentModelConstructor<models.Task>)
) {
const remote = remoteModel as models.Task;
const local = localModel as models.Task;
let newModel = models.Task.copyOf(remote, (task) => {
task.timePickedUp = remote.timePickedUp || local.timePickedUp;
task.timeDroppedOff = remote.timeDroppedOff || local.timeDroppedOff;
task.timeRiderHome = remote.timeRiderHome || local.timeRiderHome;
task.timeCancelled = remote.timeCancelled || local.timeCancelled;
task.timeRejected = remote.timeRejected || local.timeRejected;
task.timePickedUpSenderName =
remote.timePickedUpSenderName || local.timePickedUpSenderName;
task.timeDroppedOffRecipientName =
remote.timeDroppedOffRecipientName ||
local.timeDroppedOffRecipientName;
});
console.log("Resolved task conflict result:", newModel);
const status = await determineTaskStatus(newModel);
console.log("Updating task status to", status);
newModel = models.Task.copyOf(newModel, (task) => {
task.status = status;
});
const { createdAt, updatedAt, tenantId, archived, ...rest } = newModel;
return rest;
} else if (
modelConstructor ===
(models.Comment as PersistentModelConstructor<models.Comment>)
) {
const { createdAt, updatedAt, tenantId, archived, ...rest } =
remoteModel;
return rest;
}
return DISCARD;
};
export default dataStoreConflictHandler;
I'm doing tests with the Task model as well, which looks like this in the schema:
type Task
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update]},
])
@model {
id: ID!
tenantId: ID! @index(name: "byTenantId", queryField: "listTasksByTenantId", sortKeyFields: ["createdAt"])
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read]},
])
createdAt: String @auth(rules: [{allow: private, operations: [read]}])
createdBy: User @belongsTo
dateCreated: AWSDate!
timeOfCall: AWSDateTime
timePickedUp: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timePickedUpSenderName: String
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeDroppedOff: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeDroppedOffRecipientName: String
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeCancelled: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeRejected: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
timeRiderHome: AWSDateTime
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
requesterContact: AddressAndContactDetails
pickUpLocationId: ID @index(name: "byPickUpLocation")
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
dropOffLocationId: ID @index(name: "byDropOffLocation")
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
establishmentLocationId: ID @index(name: "byEstasblishmentLocation")
pickUpLocation: Location @belongsTo(fields: ["pickUpLocationId"])
dropOffLocation: Location @belongsTo(fields: ["dropOffLocationId"])
establishmentLocation: Location @belongsTo(fields: ["establishmentLocationId"])
riderResponsibility: String
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
assignees: [TaskAssignee] @hasMany
priority: Priority
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
])
deliverables: [Deliverable] @hasMany
comments: [Comment] @hasMany(indexName: "byParent", fields: ["id"])
status: TaskStatus @index(name: "byStatus", queryField: "tasksByStatus")
isRiderUsingOwnVehicle: Int @default(value: "0")
archived: Int @default(value: "0") @index(name: "byArchivedStatus", queryField: "tasksByArchivedStatus", sortKeyFields: ["status"])
@auth(rules: [
{allow: private, operations: [read]},
{allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read]},
])
}
And if I intentionally create a conflict, it does seem to resolve it fine. The right data is saved to the server when a conflict occurs.
However if I reload the app, the conflict handler gets run every time and I get this error message from DataStore:
08-20 16:47:41.266 17734 26056 I ReactNativeJS: 'DataStore error:', { recoverySuggestion: 'Ensure app code is up to date, auth directives exist and are correct on each model, and that server-side data has not been invalidated by a schema change. If the problem persists, search for or create an issue: https://github.com/aws-amplify/amplify-js/issues',
08-20 16:47:41.266 17734 26056 I ReactNativeJS: localModel: null,
08-20 16:47:41.266 17734 26056 I ReactNativeJS: message: 'RetryMutation',
08-20 16:47:41.266 17734 26056 I ReactNativeJS: model: 'Task',
08-20 16:47:41.266 17734 26056 I ReactNativeJS: operation: undefined,
08-20 16:47:41.266 17734 26056 I ReactNativeJS: errorType: 'Unknown',
08-20 16:47:41.266 17734 26056 I ReactNativeJS: process: 'sync',
08-20 16:47:41.266 17734 26056 I ReactNativeJS: remoteModel: null,
08-20 16:47:41.266 17734 26056 I ReactNativeJS: cause: { [Error: RetryMutation] nonRetryable: true } }
08-20 16:47:41.267 17734 26056 I ReactNativeJS: 'Cause:', { [Error: RetryMutation] nonRetryable: true }
This only happens in my React native version. Either it's because I'm using a newer version of Amplify (5.3.6 vs 4.3.46 in the web version) or because of some other reason. I can't test the web version with the latest version of Amplify.
If I just return DISCARD
from the conflict resolver function, it still does repeated attempts at the mutation.
@manueliglesias thanks for the suggestions. What I tried before was actually extending the resolvers (sorry for my previous wrong choice of words).
I gave it another try. I made a file Mutation.deleteComment.postAuth.2.req.vtl
in resolvers with this:
#if( $ctx.stash.metadata.modelObjectKey )
#set( $Key = $ctx.stash.metadata.modelObjectKey )
#else
#set( $Key = {
"id": $util.dynamodb.toDynamoDB($args.input.id)
} )
#end
#set( $UpdateItem = {
"version": "2018-05-29",
"operation": "UpdateItem",
"key": $Key,
"update": {
"expression": "SET #bodyfield = :body",
"expressionNames": {
"#bodyfield": "body"
},
"expressionValues": {
":body": { "S" : "" }
},
"_version": $util.defaultIfNull($args.input["_version"], 0)
}
})
$utils.toJson({})
and pushed it up to my dev environment. Somehow it completely broke my environment and I wasn't able to recover it. The error I was getting back from the API looked like this:
byTenantId
is a GSI I have on every model.
I'd really like some advice if possible about how I can do this safely. It's hard to find good documentation or examples and it'd be really bad if I somehow broke a production environment this way.
Hi @duckbytes Apologies for the delayed response. We've looked into this internally and came to a working solution.
We think the most reliable way to solve this is by writing backend business logic in VTL to update the record before deleting it. This way we can guarantee that the field gets cleared as part of before every delete operation without relying on the clientside apps.
To do this we’ll insert new request and response resolver functions into our DeleteComment resolver pipeline. We will also need to modify the existing DeleteComment data resolver and add an override to connect the new resolver functions with the correct data source.
Add the following 2 files into ./amplify/backend/api/{your-api-name}/resolvers
Here’s the source for these 2 functions:
Mutation.deleteComment.preUpdate.res.vtl
#set( $Key = {
"id": $util.dynamodb.toDynamoDB($ctx.args.input.id)
} )
#set( $Version = $util.defaultIfNull($args.input["_version"], 1) )
#set( $UpdateItem = {
"version": "2018-05-29",
"operation": "UpdateItem",
"key": $Key,
"update": {
"expression": "SET #bodyfield = :body",
"expressionNames": {
"#bodyfield": "body"
},
"expressionValues": {
":body": { "S" : "" }
}
},
"_version": $Version
})
$utils.toJson($UpdateItem)
Mutation.deleteComment.preUpdate.res.vtl
## save the updated _version to stash. We will use it in the data resolver
$util.qr($ctx.stash.put("updatedVersion", $ctx.result["_version"]))
$util.toJson({"version":"2018-05-29","payload":{}})
Copy the existing delete Comment data resolver into the custom resolvers folder
./amplify/backend/api/{your-api-name}/build/resolvers/Mutation.deleteComment.req.vtl
into
./amplify/backend/api/{your-api-name}/resolvers/Mutation.deleteComment.req.vtl
Open the file we copied over in step 2 (./amplify/backend/api/{your-api-name}/resolvers/Mutation.deleteComment.req.vtl)
Towards the bottom of the file you will see this line
$util.qr($DeleteRequest.put("_version", $util.defaultIfNull($args.input["_version"], 0)))
Replace it with
$util.qr($DeleteRequest.put("_version", $ctx.stash.updatedVersion))
This allows us to perform the delete with the newly updated version of the record (after the update mutation we defined in step 1)
We’re not ready to deploy the changes yet. We still need to point the new resolvers at the correct data source (the Comment table).
Before we can do that we need to figure out the correct metadata for this override.
Run the following command from the root of your project
amplify api gql-compile
Now open ./amplify/backend/api/{your-api-name}/build/stacks/Comment.json and search for the new file name we added in step 1 (Mutation.deleteComment.preUpdate.req.vtl)
Take note of the property name for the function configuration (red box in the screen shot below). We’ll need it in the next step.
Run the following command from the root of your project
amplify override api
This will generate a new file: ./amplify/backend/api/{your-api-name}/override.ts
Open that file and paste the following contents:
import { AmplifyApiGraphQlResourceStackTemplate } from "@aws-amplify/cli-extensibility-helper";
export function override(resources: AmplifyApiGraphQlResourceStackTemplate) {
const model = resources.models["Comment"];
// edit and paste the function name from stacks/Comment.json
const fnName = "MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0Function.AppSyncFunction"
model.appsyncFunctions[fnName].dataSourceName = "CommentTable";
}
For fnName you’ll need to modify the value from stacks/Comment.json in the following way:
Copy the value immediately before AppSyncFunctionxxxxx (see bold substring below) MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0FunctionAppSyncFunction3B09C3BA And then append .AppSyncFunction to the end of it.
In other words: MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0FunctionAppSyncFunction3B09C3BA
becomes
MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0Function.AppSyncFunction
That’s the value we’ll use for fnName
Now we can run amplify push
to deploy these changes.
We should also update the client-side code that was shared in the issue comments above, removing the update call. Every delete Comment mutation will now clear the value of the body field in the service before performing the delete.
Hi @chrisbonifacio. Thanks very much for your detailed instructions.
I think I followed the steps correctly, but it didn't work for me. A couple observations:
In step one you refer to the same file for both blocks of code. I assumed the first one was the res
file and second one was the req
file.
In step 4 you reference a screenshot that wasn't attached, but I'm pretty sure I got this step right from your description.
The first problem I came across was getting this error on a new deployment:
🛑 The following resources failed to deploy:
Resource Name: MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0FunctionAppSyncFunction3B09C3BA (AWS::AppSync::FunctionConfiguration)
Event Type: create
Reason: No data source found named CommentTable (Service: AWSAppSync; Status Code: 404; Error Code: NotFoundException; Request ID: 790810fc-f4e5-40ee-bf96-0f4bb79fbbfb; Proxy: null)
🛑 Resource is not in the state stackUpdateComplete
Name: MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0FunctionAppSyncFunction3B09C3BA (AWS::AppSync::FunctionConfiguration), Event Type: create, Reason: No data source found named CommentTable (Service: AWSAppSync; Status Code: 404; Error Code: NotFoundException; Request ID: 790810fc-f4e5-40ee-bf96-0f4bb79fbbfb; Proxy: null), IsCustomResource: false
Learn more at: https://docs.amplify.aws/cli/project/troubleshooting/
Session Identifier: d88d5109-c82f-4aa2-93af-80edd7c14148
It seems like if I don't include this particular override I can make a deployment with no error. If I then add the override and update the stack, everything works. I do need to be able to make new deployments though, so this would be a blocker for me.
After successfully making the deployment, I'm still receiving the missing index error. On first load everything works, but as soon as I make any mutation, the indexes are deleted.
The specific error coming back from the API is:
The table does not have the specified index: deliverableTypesByTenantId (Service: DynamoDb, Status Code: 400, Request ID: 389F0TFL0FL3AVBG0E80KDDHPRVV4KQNSO5AEMVJF66Q9ASUAAJG)
but with a different index name. It happens on every sync request for every model with the tenantId index.
To complicate things a bit more, I already have some overrides:
import { AmplifyApiGraphQlResourceStackTemplate } from "@aws-amplify/cli-extensibility-helper";
import { overrideDataSourceByFileName } from "./overrideHelpers"; // <<== the helper file in this repo
export const override = (resources: AmplifyApiGraphQlResourceStackTemplate) => {
overrideDataSourceByFileName(
resources,
"Mutation.createTaskAssignee.postAuth.2", // <== The name of your file (without the extension)
"TaskAssignee", // <== The model that this resolver falls within
"TaskTable" // <== The new datasource that you want to use
);
overrideDataSourceByFileName(
resources,
"Mutation.createComment.postAuth.2", // <== The name of your file (without the extension)
"Comment", // <== The model that this resolver falls within
"UserTable" // <== The new datasource that you want to use
);
const model = resources.models["Comment"];
// edit and paste the function name from stacks/Comment.json
const fnName =
"MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0Function.AppSyncFunction";
model.appsyncFunctions[fnName].dataSourceName = "CommentTable";
};
This is what I deployed with.
The other comment override is to prevent users from creating comments with an author other than themselves, but only effects the createComment mutation.
Another thing I was wondering about is how I've declared my indexes. I did something a bit dumb at the start of my project and copy pasted them with the same name:
tenantId: ID! @index(name: "byTenantId")
so like that but on multiple models. I realised later it would have been better to just not give it a name and wondered if it might be causing issues having all indexes share a name. The entire schema for reference.
But I tried a push with just `@index' and still got the same deleted indexes error.
Hey @duckbytes , apologies for the delay. We're reviewing your latest comment and looking into it. We'll report back with any findings in the near future. Thank you for your patience!
Before opening, please confirm:
JavaScript Framework
React Native
Amplify APIs
GraphQL API, DataStore
Amplify Categories
auth, api
Environment information
Describe the bug
I would like to modify a record before deleting it with DataStore. In my case, I have a Comment model, and I would like to strip the body before deleting it. I am using optimistic concurrency.
If I only delete the record, everything works fine. If I modify the record before deleting it, I get conflict errors that are never resolved.
On the web version of my app, I was able to work around this issue by returning the remote model in the conflict resolver. This is using amplify 4.3.46. I'm now developing a mobile version with amplify 5.3.6 using the same backend. The workaround no longer seems to work, and DataStore continually tries to send the mutation.
However the data on the backend does seem to update successfully as reflected in dynamodb.
I thought a better solution to this problem would be to instead delete the body during the delete mutation with a custom override, but I wasn't able to figure out how to do it.
I'm not able to look at the actual network data as it seems to be quite difficult in React Native, but here are the responses I see in the web version, which complete successfully after one failed attempt:
Expected behavior
I would expect datastore to not continually try to send mutations after deleting a record.
Reproduction steps
Code Snippet
My model in the schema:
App.tsx
deleteComment function
Conflict resolver:
DataStore.configure (run in redux-saga)
Log output
aws-exports.js
Manual configuration
No response
Additional configuration
No response
Mobile Device
Android Emulator: Samsung_Galaxy_S8_API_30
Mobile Operating System
No response
Mobile Browser
No response
Mobile Browser Version
No response
Additional information and screenshots
package.json