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

Modules activities #3057

Merged
merged 21 commits into from
Dec 11, 2023
Merged

Modules activities #3057

merged 21 commits into from
Dec 11, 2023

Conversation

VeronikaSolovei9
Copy link
Contributor

@VeronikaSolovei9 VeronikaSolovei9 commented Aug 25, 2023

Added activities support in modules. Only one activity is now added: ActivityTransmitUserFPD
Activity evaluation is running before each module hook invocation. If data transmission is prohibited by activity - only module hook payload will be modified.

@SyntaxNode SyntaxNode self-requested a review August 28, 2023 17:15
@SyntaxNode SyntaxNode self-assigned this Aug 28, 2023
@hhhjort hhhjort self-requested a review August 31, 2023 17:24
@hhhjort hhhjort self-assigned this Aug 31, 2023
@VeronikaSolovei9 VeronikaSolovei9 marked this pull request as ready for review September 2, 2023 05:14

func handleModuleActivities[T any, P any](hook hooks.HookWrapper[T], activityControl privacy.ActivityControl, payload P) P {
// only 2 stages receive bidder request: hookstage.ProcessedAuctionRequestPayload and hookstage.BidderRequestPayload
// they both implement PayloadBidderRequest interface in order to execute mutations on bid request
Copy link
Contributor

Choose a reason for hiding this comment

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

IMHO - This comment isn't necessary.

// only 2 stages receive bidder request: hookstage.ProcessedAuctionRequestPayload and hookstage.BidderRequestPayload
// they both implement PayloadBidderRequest interface in order to execute mutations on bid request
var payloadData hookstage.PayloadBidderRequest
ok := true
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: Might look a bit nicer to group the definitions.. or maybe not,. This one may be subjective. The ok := true just looks a bit odd to me (although I understand why it's done).

payloadData, ok := any(&payload).(hookstage.PayloadBidderRequest)
!ok {
  // payload doesn't have a bid request
  return payload
}

privacyEnforcement := privacy.Enforcement{}

// parse hook.Module to split it to type and mame?
// hook.Module example: "mytest.mymodule". Can it be "rtd.mymodule" or "general.mymodule"?
Copy link
Contributor

Choose a reason for hiding this comment

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

We need more information from the module to make this determination. Consider adding an RTD flag? .. or a Kind type of flag?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you mean we need to add new property to type HookWrapper[T any] struct? Like IsRTD bool?

privacyEnforcement := privacy.Enforcement{}

// parse hook.Module to split it to type and mame?
// hook.Module example: "mytest.mymodule". Can it be "rtd.mymodule" or "general.mymodule"?
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we use mytest_mymodule as the full module's name in other places.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm confused here.
This is how hooks configuration looks like:

 "hook_sequence": [
                    {
                      "module_code": "mytest.mymodule",
                      "hook_impl_code": "mymodule"
                    },
                    {
                      "module_code": "mytest2.mymodule2",
                      "hook_impl_code": "mymodule2"
                    }
                  ]

this is how activity configuration looks like:

"rules": [
            {
              "allow": false,
              "condition": {
                "componentName": [
                  "general.mymodule2"
                ]
              }
            }
          ]

Do we expect to have activity component name set to rtd.mytest.mymodule or general.mytest.mymodule?
It will need an adjustment for activity rules parser, becaule we only expect to have one . in componentName.

Copy link
Collaborator

Choose a reason for hiding this comment

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

My thought is the mytest part is the same thing as general and rtd. It is just we only envision those two categories as being relevant at present. Do you have a different understanding of where the mytest piece of the name is coming from?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this what I was thinking too. The problem here is that these two modules are created and configured by me and I can give any name to the directory where the module will be. Modules should be placed in {PBS_root}/modules directory.

In order to make activities work this directories can only have names rtd and general.

Maybe we should set module type to hook_impl_code, like

 {
    "module_code": "mytest.mymodule",
    "hook_impl_code": "rtd.mymodule"
 }

// changes need to be applied to new payload and leave original payload unchanged
bidderReq := payloadData.GetBidderRequestPayload()
var bidderReqCopy *openrtb2.BidRequest
bidderReqCopy = ptrutil.Clone(bidderReq)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: bidderReqCopy := ptrutil.Clone(bidderReq)

Copy link
Contributor

Choose a reason for hiding this comment

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

Nevermind. We discovered this breaks IntelliJ auto-complete.

var newPayload = payload
var np = any(&newPayload).(hookstage.PayloadBidderRequest)
np.SetBidderRequestPayload(bidderReqCopy)
return newPayload
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the point of newPayload? Does it make a shallow copy?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At this point we know already that request will be changed for this module (for instance user.ID will be removed).
We create a shallow copy before "Apply" and inside of the "Apply" new User will be created and set back to request so this module will not receive restricted data. We don't do this if activity will not change the request.

      if privacyEnforcement.AnyActivities() {
		// changes need to be applied to new payload and leave original payload unchanged
		bidderReq := payloadData.GetBidderRequestPayload()
		bidderReqCopy := ptrutil.Clone(bidderReq)

		privacyEnforcement.Apply(bidderReqCopy)

		var newPayload = payload
		var np = any(&newPayload).(hookstage.PayloadBidderRequest)
		np.SetBidderRequestPayload(bidderReqCopy)
		return newPayload
	}

Copy link
Collaborator

Choose a reason for hiding this comment

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

So you have a shallow copy of the bidrequest. That shallow copy includes a pointer to the original User object. If you remove the ID from the User object, you will be modifying the original request.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What you are saying is correct. In this case User copy will be handled inside of Apply function. It will execute ScrubRequest function and it will make copy of User at the beginning and then set it back to request

var userCopy *openrtb2.User
userCopy = ptrutil.Clone(bidRequest.User)
...
...
bidRequest.User = userCopy
return bidRequest

})
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Perhaps a test to insure the original request remains unmolested after the payload has been modified for the module?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this is what is checked in hooks/hookexecution/executor_test.go where tests are added for the functions ExecuteRawAuctionStage and ExecuteBidderRequestStage. The only difference from other tests there is that Activities with the restricted userFPD option is passed.

@bsardo bsardo added the blocked label Sep 21, 2023
@SyntaxNode
Copy link
Contributor

Prebid Server 2.0 has been released and Go Module name has changed from github.com/prebid/prebid-server to github.com/prebid/prebid-server/v2, per Go versioning conventions.

Please merge the master branch and update all prebid-server import statements to reference the v2 name change. Thank you.

# Conflicts:
#	hooks/hookexecution/context.go
#	hooks/hookexecution/execution.go
#	hooks/hookexecution/executor.go
#	hooks/hookexecution/executor_test.go
@bsardo bsardo assigned AlexBVolcy and unassigned hhhjort Nov 20, 2023
@bsardo bsardo assigned bsardo and unassigned SyntaxNode Nov 27, 2023
return payload
}

// parse hook.Module to split it to type and mame?
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this comment need to be updated? The question mark at the end suggests this to me. And also there's a slight typo mame --> name

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This comment is not needed, removed

}

// PayloadBidderRequest indicated of hook carries a bid request.
// used for activities, name can be better
Copy link
Contributor

Choose a reason for hiding this comment

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

Just wanted to point out another comment that might need to be updated, can we remove name can be better?

ortb/clone.go Outdated
Comment on lines 413 to 414
var newReq *openrtb2.BidRequest
newReq = ptrutil.Clone(req)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think these lines can be consolidated to just this:

newReq := ptrutil.Clone(req)

Tests still passed when I updated this locally.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes! I recently updated my IntelliJ to the newest version and this feature works and doesn't "yell" at me about generic type!

moduleContexts *moduleContexts
endpoint string
stage string
accountId string
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: I think there's a go convention to have Id be capitalized, so it'd look like accountID

AlexBVolcy
AlexBVolcy previously approved these changes Nov 29, 2023
brp.Request = br
}

// PayloadBidderRequest indicated of hook carries a bid request.
Copy link
Collaborator

@bsardo bsardo Dec 4, 2023

Choose a reason for hiding this comment

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

Nitpick: Can we reword this a bit differently? I'm not sure what it's trying to convey.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, basically I want to say this interface exists to indicate BidRequest is present in payload. Now we have 2 implementations: BidderRequestPayload and ProcessedAuctionRequestPayload
What do you think a good comment will be?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I would probably just say something like // RequestUpdater allows reading and writing a bid request. See my other comment about renaming this interface.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated

Comment on lines +31 to +37
func (brp *BidderRequestPayload) GetBidderRequestPayload() *openrtb_ext.RequestWrapper {
return brp.Request
}

func (brp *BidderRequestPayload) SetBidderRequestPayload(br *openrtb_ext.RequestWrapper) {
brp.Request = br
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm curious, do we need a get and set? Could we access the request directly given that the payload is public?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, we need a unified interface to get and set RequestWraper from payloads where we have it. We also need this interface to check data type in handleModuleActivities, we only process activities if input type has a BidderRequest in payload:

payloadData, ok := any(&payload).(hookstage.PayloadBidderRequest)
	if !ok {
		return payload
	}

With this interface we don't need to know the exact type of Payload, we only need to know if it has a BidderRequest.

Copy link
Collaborator

@bsardo bsardo Dec 6, 2023

Choose a reason for hiding this comment

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

Gotcha, I see the need for the interface. I got a little confused by the name of the interface when looking at the code in handleModuleActivities. I originally thought the name PayloadBidderRequest in that function was referring to a specific type of payload, BidderRequestPayload, rather than an interface. I suggest we change the name of this interface to something like RequestMutator, RequestReaderWriter, RequestWriter, RequestModifier, RequestUpdater, RequestReplacer etc. to reduce confusion and to have the interface name avoid constraining us to a specific underlying data type.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

renamed to RequestUpdater

Comment on lines +31 to +37
func (parp *ProcessedAuctionRequestPayload) GetBidderRequestPayload() *openrtb_ext.RequestWrapper {
return parp.Request
}

func (parp *ProcessedAuctionRequestPayload) SetBidderRequestPayload(br *openrtb_ext.RequestWrapper) {
parp.Request = br
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same comment as in bidderrequest.go. Why add a get and set that just read back the value or do a simple assignment?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Answered for BidderRequestPayload

@@ -311,3 +314,29 @@ func handleHookMutations[P any](

return payload
}

func handleModuleActivities[T any, P any](hook hooks.HookWrapper[T], activityControl privacy.ActivityControl, payload P) P {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are generics needed here at this time? It looks like we only care about the bidder request hook at the moment. I'm also not sure if generics will work to support additional hooks without more work given that this function always casts the payload to the bidder request payload instead of deriving the payload type somehow from the T and/or P. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need to know hook hooks.HookWrapper[T] and payload P to process activities.
P can be any of hookstage payload: AllProcessedBidResponsesPayload, AuctionResponsePayload, RawBidderResponsePayload and others.
We only need to check and run Activities for implementations of PayloadBidderRequest

Or I don't understand your question.

Copy link
Collaborator

@bsardo bsardo Dec 6, 2023

Choose a reason for hiding this comment

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

It looks like this function signature can be simplified a bit to exclude type parameter T and instead just pass in hookCode string instead of hook:
func handleModuleActivities[P any](hookCode string, activityControl privacy.ActivityControl, payload P) P {

In general, I'm still on the fence here on whether generics make sense, mostly because this function is basically a noop for all payloads that don't include a request. In that case, I thought interfaces were a better fit where P becomes whatever your interface name is (e.g. RequestModifier) but that would require the calling code to perform the type analysis on P and converting it to the interface type which may not make such a change worth it. Given that this code is called by generic code there really isn't any getting away from the type analysis. The only potential benefit I see to such a change, ignoring any potential performance implications, is that this function is better scoped. We can discuss more offline.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hook hooks.HookWrapper[T] - removed!
I tried to move the P check outside, but don't think it fits there. It's related to activitites only.
We still need to have a payload(payloadData) casted to hookstage.RequestUpdater later in the func handleModuleActivities.

Can we keep it inside of the handleModuleActivities and don't overload executeGroup?

@@ -205,6 +205,8 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http

activityControl = privacy.NewActivityControl(&account.Privacy)

hookExecutor.SetActivityControl(activityControl)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need to call this for other endpoints as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ooof, yes, I will add this to amp and video.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added to AMP endpoint only.
In Video endpoint hooks are not configured:
HookExecutor: hookexecution.EmptyHookExecutor{},

Source: &openrtb2.Source{TID: "testTID"},
}
result := CloneBidderReq(given)
assert.NotSame(t, given, result.BidRequest, "pointer")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we want an assert.Equal here to ensure the values are the same?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Pointers should not be the same. We clone BidRequest too: newReq := ptrutil.Clone(req) and then User, Device and Source.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes but the values should be the same right? I think assert.Equal is a value comparison rather than a memory address comparison.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh yes! Values should be the same, but pointers different. Do you want me to add assert.Equal for values check?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added

@@ -669,13 +672,76 @@ func TestExecuteRawAuctionStage(t *testing.T) {
},
},
},
{
Copy link
Collaborator

Choose a reason for hiding this comment

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

If I understand correctly, this test should not modify the payload because it involves the raw auction hook instead of the bidder request hook right? The raw auction hook payload is different from the one used in the bidder request hook and does not currently support activities.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test is probably not needed. Idea here is to test that for the stages and payloads without BidderRequest activities do nothing and don't modify anything. Inn this case we execute ExecuteRawAuctionStage with RawAuctionRequestPayload and with disabled activity and verify nothing was changed. This test exercises scenario where "ok" is false.

payloadData, ok := any(&payload).(hookstage.PayloadBidderRequest)
	if !ok {
		return payload 
	}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Gotcha, yeah I think you can probably get rid of this test. You have appropriate test coverage of the false case in execution_test.go#TestHandleModuleActivitiesNoBidderRequestPayload.

As for making sure the original payload isn't modified by handleModuleActivities, I think you can easily ensure that by updating the three tests in execution_test.go with the following if you'd like:

origInPayloadData := test.inPayloadData
assert.Equal(t, origInPayloadData, test.inPayloadData)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, done

@@ -1164,12 +1231,68 @@ func TestExecuteBidderRequestStage(t *testing.T) {
},
},
},
{
description: "Group payload not changes with transmitUFPD activity restriction",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why isn't this test modifying the payload? This involves the bidder request hook, whose payload has activity support, and you've set it up so the activity infrastructure says transmitting FPD is not allowed. Shouldn't the payload be modified?

Is the issue the fact that this test has always compared givenBidderRequest with expectedBidderRequest expecting them to be equal, which should still be true even though we are cloning the the bidder request portion of the payload and modifying it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this test we check that input BidderRequest didn't change and remains the same.
In this test we execute ExecuteBidderRequestStage with 2 groups (each with one hook). We modify BidRequest for first hook, because it matches the restriction config and don't modify BidderRequest for the second group.
Modified payload (BidRequest) is only passed into executeHook.

newPayload := handleModuleActivities(hook, executionCtx.activityControl, payload)
		wg.Add(1)
		go func(hw hooks.HookWrapper[H], moduleCtx hookstage.ModuleInvocationContext) {
			defer wg.Done()
			executeHook(moduleCtx, hw, newPayload, hookHandler, group.Timeout, resp, rejected)
		}(hook, mCtx)

Incoming BidRequest remains the same.

In other words: BidRequest outside of func executeGroup[H any, P any]( should remain unchanged.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I find this test to be a little redundant and confusing, particularly because it has the exact same inputs and outputs as other test cases except for the fact that there is an activities object input.

What do you think about deleting this test case and instead adding this code block to each of the six stage tests in this file right after the call to SetAccount:

privacyConfig := getTransmitUFPDActivityConfig("foo", false)
ac := privacy.NewActivityControl(privacyConfig)
exec.SetActivityControl(ac)

In this case, we will be taking advantage of the test cases that already exist throughout the file, exercising the activity infrastructure logic for each stage (not a necessity IMO given the nice unit test coverage you have in execution_test.go but comes for free here), and introducing some symmetry which I think makes it easier for us to maintain this as it makes the coverage intent easier to discover. On top of that, what you're trying to test here will be exercised for each test case because the TestApplyHookMutationsBuilder used in each of the six stage tests already defines a foo hook in each stage plan.
Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree, modified!

}
}

func TestHandleModuleActivitiesProcessedAuctionRequestPayload(t *testing.T) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

It looks like processed auction request works because the payload type is able to be casted to the bidder request payload type, is that correct?
Are these the only two payload types we care about because they both deal with a bid request?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Correct, ProcessedAuctionRequestPayload and BidderRequestPayload are only payloads with BidderRequest and they both implement PayloadBidderRequest interface.
This makes them to pass type check here payloadData, ok := any(&payload).(hookstage.PayloadBidderRequest)

Comment on lines 58 to 60
//check input payload didn't change
origInPayloadData := test.inPayloadData
assert.Equal(t, origInPayloadData, test.inPayloadData)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you need to move the line origInPayloadData := test.inPayloadData above the call to handleModuleActivities.

Comment on lines 110 to 112
//check input payload didn't change
origInPayloadData := test.inPayloadData
assert.Equal(t, origInPayloadData, test.inPayloadData)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you need to move the line origInPayloadData := test.inPayloadData above the call to handleModuleActivities.

Comment on lines 146 to 148
//check input payload didn't change
origInPayloadData := test.inPayloadData
assert.Equal(t, origInPayloadData, test.inPayloadData)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you need to move the line origInPayloadData := test.inPayloadData above the call to handleModuleActivities.

Comment on lines 1179 to 1182
privacyConfig := getTransmitUFPDActivityConfig("foo", false)
ac := privacy.NewActivityControl(privacyConfig)
exec.SetActivityControl(ac)
exec.SetAccount(test.givenAccount)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are you able to add this to all six stage tests?

  • TestExecuteRawAuctionStage
  • TestExecuteProcessedAuctionStage
  • TestExecuteBidderRequestStage
  • TestExecuteRawBidderResponseStage
  • TestExecuteAllProcessedBidResponsesStage
  • TestExecuteAuctionResponseStage

@bsardo bsardo merged commit ec873dc into master Dec 11, 2023
@SyntaxNode SyntaxNode deleted the modules_activities branch December 19, 2023 17:10
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