Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

B is not idempotent for identical requests #100

Closed
civsiv opened this issue Mar 5, 2021 · 6 comments · Fixed by #207
Closed

B is not idempotent for identical requests #100

civsiv opened this issue Mar 5, 2021 · 6 comments · Fixed by #207
Labels
bug Something isn't working

Comments

@civsiv
Copy link
Contributor

civsiv commented Mar 5, 2021

The Booking Specification states the following:
"note call to B is idempotent for the case where OrderItems match".

However this is not the case for two identical B requests. An OrderAlreadyExistsError error is returned.

An example of the B request sent is below:

{
    "@context": [
        "https://openactive.io/"
    ],
    "broker": {
        "id": "google",
        "name": "Google",
        "type": "Organization"
    },
    "brokerRole": "https://openactive.io/AgentBroker",
    "customer": {
        "email": "[email protected]",
        "familyName": "Doe",
        "givenName": "Jane",
        "id": "0",
        "telephone": "+1 800-789-7890",
        "type": "Person"
    },
    "orderedItem": [
        {
            "acceptedOffer": {
                "id": "https://iminoareferenceimplementation.azurewebsites.net/api/identifiers/session-series/209#/offers/0",
                "type": "Offer"
            },
            "orderedItem": {
                "id": "https://iminoareferenceimplementation.azurewebsites.net/api/identifiers/scheduled-sessions/209/events/2084",
                "type": "ScheduledSession"
            },
            "position": 0,
            "type": "OrderItem"
        }
    ],
    "seller": {
        "id": "https://iminoareferenceimplementation.azurewebsites.net/api/identifiers/sellers/1",
        "type": "Organization"
    },
    "totalPaymentDue": {
        "price": 0,
        "priceCurrency": "GBP",
        "type": "PriceSpecification"
    },
    "type": "Order"
}
@civsiv civsiv added the bug Something isn't working label Mar 5, 2021
@lukehesluke
Copy link
Contributor

I concur. Here is a test run which tests idempotency (and fails):

free-opportunities_opportunity-free-without-checkpoints_OpenBookingSimpleFlow_FacilityUseSlot.md

This is only for Simple Flow, btw. Approval Flow allows a repeated B:

free-opportunities_opportunity-free-without-checkpoints_OpenBookingApprovalFlow_FacilityUseSlot.md

@nickevansuk
Copy link
Contributor

nickevansuk commented Jun 4, 2021

Noting this could be solved by storing the hash of a successful request at B, along with the successful response, and then reading that hash if an Order already exists to determine whether to return the same response, or an OrderAlreadyExistsError

And that it also would need some kind of AsyncLock to avoid two Orders with the same uuid being processed at the same time

@nickevansuk
Copy link
Contributor

nickevansuk commented Jun 4, 2021

Suggested design:

  1. Could be an additional idempotency hook “GetIdempontentOrderResponse” added to the OrderStore, that simply resolves the response string based on a ClientID, UUID, and request hash, and returns null otherwise (a specific hook will make it clear to implementers they need to do something for this?).
  2. Then CreateOrder could return false instead of OrderAlreadyExistsError
  3. Then the "GetIdempontentOrderResponse" gets called by the StoreBookingEngine if CreateOrder returns false, and either responds with the response string or a OrderAlreadyExistsError
  4. Also a new hook “CompleteOrder” that is called after “UpdateOrder” (before the transaction completes), accepting the same params as UpdateOrder with the additional params of “serialisedResponseOrder” and “requestIdempotencyHash”. This ensures the Order is not serialised twice. (Note the Order can be mutated in UpdateOrder, and the OrderItemIds are added after CreateOrder - hence suggestion to add a new hook at the end of the flow)

Also worth noting that idempotency could be implemented using MemoryCache in the OrderStore, or another similar method, rather than storing the hash/response in the Order table - as the response probably only needs to be cached for e.g. 15 minutes. Perhaps the ref impl should use MemoryCache rather than storing this in the DB in its example, as it’s also cleaner from a GDPR compliance perspective (personal data in the response is not stored for longer than is needed, and not duplicated)?

Or perhaps the above hooks could be be virtual rather than abstract and include a default MemoryCache implementation? Would make for a cleaner upgrade path for existing implementers too. If we did this potentially the default implementation should also compress responses (e.g.) to optimise memory usage.

The above hooks would support both…

Also add an AsyncLock or similar to ensure that two requests for the same UUID are not processed in parallel

@matdavies
Copy link
Contributor

matdavies commented Jun 6, 2021

I started trying to implement per the above design suggestions, but came across an issue:

StoreBookingEngine.ProcessFlowRequest (which is where I think you're suggesting we make modifications) returns TOrder, not a string (or ResponseContent). So part 3 above is not really possible in that method? Instead if GetOrder returns false, we'd need to deserialise the response into a TOrder again, and return that?

So instead I looked at modifying ProcessOrderCreationB in CustomBookingEngine, but of course, CustomBookingEngine does not have access to the OrderStore, so it can't call the GetIdempotentOrderResponse method from there. Instead IBookingEngine would need that GetIdempotentOrderResponse method on it (which, in the StoreBookingEngine, would just pass off to the OrderStore).

Let me know which you would prefer, or if I've got the wrong end of the stick.

Also, the above aside, I think CreateOrder should return this:

    /// <summary>
    /// Result of creating (or attempting to create) an Order in a store
    /// </summary>
    public enum CreateOrderResult
    {
        OrderSuccessfullyCreated,
        OrderAlreadyExists
    }

instead of a bool, as the above matches DeleteOrder and makes it easier to see what to return (as opposed to having to guess that you need to return false from CreateOrder if the order already exists - otherwise people could easily just continue to throw exceptions there which is what the reference impl does)

@nickevansuk
Copy link
Contributor

nickevansuk commented Jun 7, 2021

Yes sounds good - CreateOrderResult is much clearer!

Can see the advantages of doing this at the CustomBookingEngine level, as for one implementation pattern we're really adding a caching hook.

The tradeoff is that this is more prescriptively suggesting that a separate caching layer is used, rather than e.g. using the database to store it within the same transaction. An alternative could be that ProcessFlowRequest returns a string, and responsibility of OpenActiveSerializer.Serialize(response) is moved into the StoreBookingEngine?

@nickevansuk
Copy link
Contributor

After some thinking about the design, I've opted for doing this optionally at the CustomBookingEngine level (see #207), as it's cleaner and more closely follows the pattern of other libraries e.g. https://github.com/ikyriak/IdempotentAPI (which could be used if openactive/open-booking-api#239 is adopted in future).

This does mean that it's not as easy to store a hash of the request in the database within CreateOrder, however it does result in a more maintainable solution overall.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
4 participants