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

Enable contract listeners with multiple filters #1418

Merged
merged 32 commits into from
Jul 15, 2024

Conversation

nguyer
Copy link
Contributor

@nguyer nguyer commented Oct 12, 2023

This PR adds the ability to listen to multiple types of events on the same contract listener, by adding an array of listeners, rather than a single event signature/location per listener. The old way of creating a listener is still accepted by the API, but it will always be returned in the filters array now. This is a migration concern that needs to be documented.

Open questions

One thing I'm not sure about here, is that this PR as-is removes the uniqueness constraint on listeners by topic/location/signature. It now allows multiples. I'm not sure if this is a problem or not. I can add that constraint back, but it would likely require some more sophisticated DB changes. Which brings me to the next point...

Right now all the filters for a contract listener get serialized to JSON and stored in a single column. I lated realized this means we lose the ability to query/filter (no pun intended) by signature, location, etc. which we used to do, in order to check for duplicates. I'm not sure if this is required or not, but wanted to call it out.

Example

Create contract listener request

{
    "filters": [
        {
            "interface": {
                "id": "aaa0e410-2b5b-4815-a80a-a18f2ae59f7d"
            },
            "eventPath": "BatchPin",
            "location": {
                "address": "0xb0cd60ade460e797e0c9d206290ac4ed45672c60"
            }
        }
    ],
    "name": "CustomBatchPin",
    "options": {
        "firstEvent": "oldest"
    },
    "topic": "batch-pin"
}

Create contract listener response

{
    "id": "acc0d227-1da4-4d0d-bbe0-0c60f754158f",
    "namespace": "default",
    "name": "CustomBatchPin",
    "backendId": "018b258a-0c2c-07c0-5d59-50583ae91f1e",
    "created": "2023-10-12T20:18:06.012167Z",
    "filters": [
        {
            "event": {
                "name": "BatchPin",
                "description": "",
                "params": [
                    {
                        "name": "author",
                        "schema": {
                            "type": "string",
                            "details": {
                                "type": "address",
                                "internalType": "address"
                            },
                            "description": "A hex encoded set of bytes, with an optional '0x' prefix"
                        }
                    },
                    {
                        "name": "timestamp",
                        "schema": {
                            "oneOf": [
                                {
                                    "type": "string"
                                },
                                {
                                    "type": "integer"
                                }
                            ],
                            "details": {
                                "type": "uint256",
                                "internalType": "uint256"
                            },
                            "description": "An integer. You are recommended to use a JSON string. A JSON number can be used for values up to the safe maximum."
                        }
                    },
                    {
                        "name": "action",
                        "schema": {
                            "type": "string",
                            "details": {
                                "type": "string",
                                "internalType": "string"
                            }
                        }
                    },
                    {
                        "name": "uuids",
                        "schema": {
                            "type": "string",
                            "details": {
                                "type": "bytes32",
                                "internalType": "bytes32"
                            },
                            "description": "A hex encoded set of bytes, with an optional '0x' prefix"
                        }
                    },
                    {
                        "name": "batchHash",
                        "schema": {
                            "type": "string",
                            "details": {
                                "type": "bytes32",
                                "internalType": "bytes32"
                            },
                            "description": "A hex encoded set of bytes, with an optional '0x' prefix"
                        }
                    },
                    {
                        "name": "payloadRef",
                        "schema": {
                            "type": "string",
                            "details": {
                                "type": "string",
                                "internalType": "string"
                            }
                        }
                    },
                    {
                        "name": "contexts",
                        "schema": {
                            "type": "array",
                            "details": {
                                "type": "bytes32[]",
                                "internalType": "bytes32[]"
                            },
                            "items": {
                                "type": "string",
                                "description": "A hex encoded set of bytes, with an optional '0x' prefix"
                            }
                        }
                    }
                ]
            },
            "location": {
                "address": "0xb0cd60ade460e797e0c9d206290ac4ed45672c60"
            },
            "interface": {
                "id": "aaa0e410-2b5b-4815-a80a-a18f2ae59f7d"
            },
            "signature": "BatchPin(address,uint256,string,bytes32,bytes32,string,bytes32[])"
        }
    ],
    "topic": "batch-pin",
    "options": {
        "firstEvent": "oldest"
    }
}

@codecov-commenter
Copy link

codecov-commenter commented Oct 12, 2023

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (c00ecf8) to head (fc18b63).

Additional details and impacted files
@@            Coverage Diff             @@
##              main     #1418    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          324       325     +1     
  Lines        23706     24019   +313     
==========================================
+ Hits         23706     24019   +313     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@nguyer nguyer marked this pull request as draft November 8, 2023 16:44
@peterbroadhurst
Copy link
Contributor

which we used to do, in order to check for duplicates. I'm not sure if this is required or not, but wanted to call it out.

@nguyer (fyi @EnriqueL8) - there is a really good reason why we need this (at least prior to this PR). That's because calculating the signature of a listener is not an easy thing to do - and FireFly doesn't expose any way for code outside of FireFly to do it.

So if you think about the common startup reconcile in most applications, where it does a query-then-create on the listeners it needs, you need to be able to match the things you get back, to the things you need.

That means you need to be able to create the signature for the things you need (not just the things you create).

Currently, we avoid this by having it so that if you try and recreate the new thing with the same signature, it prevents you with a 409. In fact that's important enough that we found and fixed a bug in it recently in #1433.

Now I can imaging this PR might provide a proposal for a fundamentally new way to do this, by pushing the complex reconcile logic inside of listener. But we do need a clear migration path, and to know what the impact would be on apps relying on the old behavior - because without any thought they would duplicate a new listener on every startup.

@nguyer nguyer marked this pull request as ready for review February 13, 2024 15:25
@nguyer
Copy link
Contributor Author

nguyer commented Feb 13, 2024

@peterbroadhurst I'm trying to pick this back up but really not sure what direction to take it. I think part of my struggle is that I myself haven't run into the use case that is driving the need for this functionality, so I'm sort of designing a solution to an unknown problem.

I could add an updated version of the constraint we used to have (and maybe do some heavy DB schema changes to support that) but what would the constraint be? Topic/Location/[SOMETHING]. Is that last element of the constraint the combined signature of each filter in the listener? Do we need to prevent someone from creating the same exact Topic/Location/[Combination of Signatures] or do we want to prevent the same Topic/Location/[Single event signature] from being used in multiple listeners even if the set of filters in each of those listeners is different. Does that question make sense?

I feel like this PR probably needs another round of design discussion before it's ready for more coding.

Signed-off-by: Nicko Guyer <nicko.guyer@kaleido.io>
Signed-off-by: Nicko Guyer <nicko.guyer@kaleido.io>
Signed-off-by: Nicko Guyer <nicko.guyer@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
@EnriqueL8
Copy link
Contributor

New approach is that we have introduced a new API at /contract/listeners/hash that accepts the same payload as a POST. I did think we could have a subset of payload so just the filters and event part, could clean that up but for consistency kept the same. This API will either return the FilterHash for the filters or the deprecated signature for one event and the filters hash as well. Exposes as well the filterHash to be returned from the POST when creating a new listeners and queryable on the GET of listeners to figure out.

PR needs some conflict cleaning and test coverage but test in a FF CLI setup and looking good!

Copy link
Contributor

@peterbroadhurst peterbroadhurst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @EnriqueL8 - lots of mini comments here, but it all rolls up to one big question about the relationship between:

  • len(filters) == 1 so signature is like SomeEvent(1,2,3)
  • len(filters) > 1 so:
    • Each filter has a signature like like SomeEventA(1,2,3) etc. is queryable in the filter[0] JSON
    • The signature of the listener is hash(filters[*].signature)

Then the implication of this is that signature + topic is still a uniqueness criteria on the listener (assuming it is at 1.3.0)

.vscode/launch.json Outdated Show resolved Hide resolved
internal/apiserver/route_post_contract_listeners_hash.go Outdated Show resolved Hide resolved

filters := make([]*filter, 0)
// This keeps the existing behaviour where a contract listener could only listen to one event
if listener.Event != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels odd to me that it's the blockchain/ethereum layer responsible for this array-ification.
Why isn't this done for us in a consistent deprecated field handler, in the FF/API layer, before calling this always with an array.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If then a blockchain connector supports only len(filters) == 1, it can return an error

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah makes sense - I think this was done before for reload of contract listeners on restart, but how not standardise that on load of listeners from DB instead of here!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to address this now

internal/contracts/manager.go Outdated Show resolved Hide resolved
@@ -835,6 +901,50 @@ func (cm *contractManager) AddContractListener(ctx context.Context, listener *co
listener.Options.FirstEvent = cm.getDefaultContractListenerOptions().FirstEvent
}

// Normalize Event/EventPath + Location to list of Listeners
if len(listener.Filters) == 0 {
listener.Filters = append(listener.Filters, &core.ListenerFilterInput{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I was expecting to see as the migration from the deprecated fields.
The fact this is in the manager level, makes me confused why any other code apart from this one bit needs to worry about the fact that "on the API boundary we have a special spelling for handling for len(filters) == 1"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have added this at the manager level and also still provided the deprecated fields in the API

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address this in the blockchain plugin

internal/contracts/manager.go Outdated Show resolved Hide resolved
pkg/core/contract_listener.go Outdated Show resolved Hide resolved
@EnriqueL8
Copy link
Contributor

For the second case, we need to include the location if not it will not be unique and will cause problems

@EnriqueL8
Copy link
Contributor

EnriqueL8 commented Jun 11, 2024

@peterbroadhurst Thanks for the comments - will amend the PR with changed.

I think the challenge here is the location:

  • The current uniqueness constraints is location + topic + signature which makes sense
  • I think we need to keep the same thing at top level so what I propose is that for:
    • len(filters) == 1 we stay with location + topic + signature.
    • len(filters) > 1 we embed all the possible different locations into the hash that becomes the signature and still keep the signature for each event in so queryable in the filter[0] JSON. The signature then becomes hash(filters[*].signature+filters[*].location)

Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
@EnriqueL8
Copy link
Contributor

EnriqueL8 commented Jun 12, 2024

@peterbroadhurst From the review comments and the chances I made, came up with three other worries:

  • Filtering over certain fields at the API level will be worse as we are nesting a lot of the fields such as location, eventPath, etc.. under a JSONAny that is stored as TEXT in one column and doesn't have the native filtering. Wondering how bad that would be?
  • Have made an opinionated assumption that both Tezos and Fabric only support a single filter for their event streams. I'm pretty certain that Fabric does restrict you to one, @denisandreenko I wonder if you might know if tezosconnect supports multiple filters for the subscriptions?
  • Multiple filters that clash with each other... so probably need to check that

@denisandreenko
Copy link
Member

@EnriqueL8 tezosconnect doesn't support event streaming yet, so don't worry about compatibility with this connector

@EnriqueL8
Copy link
Contributor

@denisandreenko thanks for the input - so a follow up PR might be needed to provide the user with a better error on event streaming because it seems the code does go ahead and try and create a subscription in the Tezos connector

@EnriqueL8
Copy link
Contributor

Looks like Docker has been update in the Github Action and the packaged docker-compose version no longer support ContainerConfig docker/compose#11742 (comment)

@EnriqueL8
Copy link
Contributor

The GH action is using V1 for some reason /usr/local/bin/docker-compose up -d and it should be using v2. FireFly CLI checks if V1 is available and uses that or V2 if available

@EnriqueL8
Copy link
Contributor

EnriqueL8 commented Jun 13, 2024

This should do the trick hyperledger/firefly-cli#307 - I'll have to update the action to pull in a new version

@peterbroadhurst
Copy link
Contributor

Multiple filters that clash with each other... so probably need to check that

This comment you made is highlighted, but there's no commentary on where you got to with it @EnriqueL8 .
What was your analysis/proposal?

Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Comment on lines 903 to 907
// comparing to channel + chaincode
if strings.Contains(duplicateMapChecker[filter.Signature], location) || strings.Contains(location, duplicateMapChecker[filter.Signature]) {
return i18n.NewError(ctx, coremsgs.MsgDuplicateContractListenerFilterLocation)
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do wonder if this should be moved to the plugin specific code... or the plugin should generate a signature including the location

Copy link
Contributor

@peterbroadhurst peterbroadhurst Jul 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's your proposal here @EnriqueL8? Are you suggesting you make a change before this goes in?

I think your point that requiring location to be part of the signature is valid - but you'd be best positioned to comment on the cost vs. benefit in the code I think.

Comment on lines +1039 to +1044
for _, listener := range existing {
// We have extended the event signature to add more information
// So we compare the start with is not guaranteed to be the same
// but it's the best comparison
if strings.HasPrefix(signature, listener.Signature) {
return i18n.NewError(ctx, coremsgs.MsgContractListenerExists)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again maybe this comparing of signatures should be plugin specific

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think comparing signatures should be specific, but I don't understand the HasPrefix here.
Surely the signatures are the same, or they are not.

Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Copy link
Contributor

@peterbroadhurst peterbroadhurst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next round of detail comments/questions up for your @EnriqueL8 - these are all around the "how" I think at this point, so that's great.

Pausing here as I do think there's some significant questions here for you to think about before I go further.

@EnriqueL8
Copy link
Contributor

EnriqueL8 commented Jul 9, 2024

@peterbroadhurst Just working through the changes came across apis/{apiName}/listeners/{eventPath} which uses the signature to query the DB for the listener... We need to make a decision if this API now makes sense with multiple filters in one listeners and this hierarchy only makes sense for one event or we implement a logic to look in the filters and find out if the event exists

@EnriqueL8
Copy link
Contributor

I think it's quite easy to sort it by checking if the whole signature contains the current event signature and location!

Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
@EnriqueL8
Copy link
Contributor

EnriqueL8 commented Jul 11, 2024

Great code coverage is working, fixing

Weird I'm getting 100% locally for coverage

EnriqueL8 and others added 3 commits July 11, 2024 14:19
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Co-authored-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
@EnriqueL8
Copy link
Contributor

The Postgres migration seem to be failing, looking into it.. It's a shame the GitHub actions do no give you logs of the docker containers, something to add to my list

@EnriqueL8
Copy link
Contributor

EnriqueL8 commented Jul 11, 2024

Replicate locally FF00184: Database migration failed: FF00184: Database migration failed: migration failed: syntax error at or near "MAX" (column 67) in line 4: BEGIN;

Changing it to just VARCHAR which according to the Postgres documentation is unbounded https://www.postgresql.org/docs/current/datatype-character.html
If character varying (or varchar) is used without length specifier, the type accepts strings of any length

Signed-off-by: Enrique Lacal <enrique.lacal@kaleido.io>
@EnriqueL8 EnriqueL8 mentioned this pull request Jul 11, 2024
Copy link
Contributor

@peterbroadhurst peterbroadhurst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ 🚀 🌑

@peterbroadhurst peterbroadhurst merged commit a56d9e8 into hyperledger:main Jul 15, 2024
16 checks passed
@peterbroadhurst peterbroadhurst deleted the filters branch July 15, 2024 14:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants