Skip to content

Poller fixed-delay and fixed-rate support for Duration syntax #8625

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

Closed
mauromol opened this issue May 17, 2023 · 15 comments · Fixed by #8627
Closed

Poller fixed-delay and fixed-rate support for Duration syntax #8625

mauromol opened this issue May 17, 2023 · 15 comments · Fixed by #8627

Comments

@mauromol
Copy link
Contributor

Expected Behavior

It would be nice and useful (from a configuration point of view) if <int:poller> attributes fixed-delay and fixed-rate supported the java.time.Duration syntax, with support for resolvable configuration values (via a StringValueResolver) just like @Scheduled/ScheduledAnnotationBeanPostProcessor do.

Current Behavior

fixed-delay and fixed-rate attributes in <int:poller> must be integer values, interpreted as milliseconds or amount of time-unit units. Resolvable configuration values are supported, but configuration must then specify these values as milliseconds, or provide two distinct configuration values for delay/rate and time unit.

Context

Using Spring Integration with Spring Boot 2.7.10. Duration syntax is nicely supported in a lot of places, but not in <int:poller> tag.
The alternative to have maximum flexibility is to leave fixed-delay/fixed-rate in milliseconds and externalize them in configuration. But milliseconds are not so readable when resolutions of minutes or hours is needed.

@mauromol mauromol added status: waiting-for-triage The issue need to be evaluated and its future decided type: enhancement labels May 17, 2023
@artembilan artembilan added this to the 6.2.x milestone May 17, 2023
@artembilan artembilan added in: core and removed status: waiting-for-triage The issue need to be evaluated and its future decided labels May 17, 2023
@artembilan
Copy link
Member

The XML configuration is not in priority these days: it is recommended to move Spring Integration configuration to Java DSL, especially with Spring Boot.

We have there a Pollers factory with a Duration variants.
Feel free to use as a reference in XML.
Or that <int:poller> has a ref attribute to point to the PeriodicTrigger bean which you can configure with the mentioned Duration.

However I think we indeed need to do anything to fix the PeriodicTrigger deprecated ctors, which we still use from the PollerParser:

		BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(PeriodicTrigger.class);
		builder.addConstructorArgValue(fixedDelayAttribute);
		if (StringUtils.hasText(timeUnit)) {
			builder.addConstructorArgValue(timeUnit);
		}

I guess one day they are going to be removed from there either way.

So, let's see if we can come up with a PeriodicTriggerFactoryBean with all the hard logic to parse a period value into a Duration!
Contribution is welcome!

@artembilan
Copy link
Member

I addition, I think our @Poller parser in the MessagingAnnotationPostProcessor logic does not support Duration, too.

@aljopainter
Copy link

aljopainter commented May 17, 2023 via email

@artembilan
Copy link
Member

No, that is the right place to debate.

Give us, please, some hint what advantages you find over Java DSL?

@aljopainter
Copy link

aljopainter commented May 17, 2023 via email

@mauromol
Copy link
Contributor Author

mauromol commented May 17, 2023

I also think that Spring Integration is probably one of the Spring projects where XML configuration (using namespaces) is much better than the Java counterpart (another example is probably Spring Security).
The main selling point, for me, is readability: I tried to read again the chapter in Spring Integration manual regarding the Java DSL, but when the flow becomes a little more complicated it's really really hard to read its definition, IMHO. Partly because the builder pattern is not as much "natural" as an XML tag with all of its attributes set, but also because Java code formatting may not match the requirements to properly "indent" code so that the integration flow declaration is "visibly clear" (I would probably need a set of different formatting rules for the classes where I define my integration flows...). Also, lambda expressions allow for a more compact syntax, but may distract you by mixing runtime logic with actual flow definition.

Another difficulty I find in these Java DSLs (I have the same difficulty with Spring Security) is to understand what a method call is supposed to return, in order to understand which calls I should chain at which point to achieve what I want. On the contrary, the XML namespace is really clear: elements with attributes, possibly with subelements when something relates to the parent element; then, other elements, bound to the previous ones through channels or in a chain: very simple.

I know XML config is no more in fashion these days, but I think that if something works well for a given task, it shouldn't be replaced just because the trend goes somewhere else.

If I had to think of another example where XML config has been replaced, I can think of Gradle vs Maven. There, however, they have built a Groovy DSL which is really effective and can really make a difference. Gradle build scripts are for the most part really easy to read: however, I just had a quick look at Spring Integration Groovy DSL, but it's nothing near to the clearness of Gradle one, it seems like it's just an extension of the Java DSL to use Groovy language constructs. And plain Java, to write a DSL, is not quite adequate IMHO...

Just my 2 cents.

Regarding this issue: I didn't know about the ref attribute of <int:poller>, it's probably not even mentioned in the reference documentation. It's an interesting workaround, although I still feel it would be sooo natural to be able to specify Duration strings directly in fixed-rate and fixed-delay attributes :-)

@artembilan
Copy link
Member

Good! Thank you for feedback.

Let me come up with some answers for you!

Names of beans in MessageHistory and in logs are much clearer from the
XML configuration

Java DSL endpoint definitions also come with an id() option: https://docs.spring.io/spring-integration/docs/current/reference/html/dsl.html#java-dsl-endpoints
You probably will end up with the same result in a history if you don't specify an id on XML components.

can reference beans by name whereas with DSL you'll need to inject the
bean in either the Class constructor or the Bean method

There are overloaded DSL operators which accepts bean names:

	/**
	 * Populate a {@link ServiceActivatingHandler} for the
	 * {@link org.springframework.integration.handler.MethodInvokingMessageProcessor}
	 * to invoke the {@code method} for provided {@code bean} at runtime.
	 * @param beanName the bean name to use.
	 * @param methodName the method to invoke.
	 * @return the current {@link BaseIntegrationFlowDefinition}.
	 */
	public B handle(String beanName, @Nullable String methodName) {

much easier to navigate in IntelliJ from a bean reference to the bean
implementation with XML configuration

This is out of this project scope.
You need to thank to your IDE to have that navigation support for an XML config.
It just doesn't support such a feature for Java "config by names" 😄 .
It also annoys when I try to auto-wire JdbcTemplate from auto-configuration: the IDE just doesn't see those beans coming from Spring Boot.

easier to compartmentalize aspects within a given .xml file

No one said you that you have to compose all your logic in a single IntegrationFlow. You always can come up with independent components which you can reuse in other places.
See some samples of this decomposition in my Microservices Patterns project: https://github.com/artembilan/microservices-patterns-spring-integration/blob/main/outbox/src/main/java/org/springframework/integration/microservices/outbox/OutboxApplication.java

becomes a little more complicated it's really really hard to read its definition

Fully agreed. But this doesn't mean that we don't have similar problem with any design. See my previous comment: no one makes you to put everything into a single flow definition. You simply can have all the channel adapters declared in a separate methods and just references to them from the flow.
The Circuit Breaker sample should give some clue how that could be configured: https://github.com/artembilan/microservices-patterns-spring-integration/blob/main/circuit-breaker/src/main/java/org/springframework/integration/microservices/circuitbreaker/CircuitBreakerApplication.java.
I doubt that it is going to be much easier with an XML.

Java code formatting may not match the requirements to properly "indent"

Not sure how this could be treated as a DSL problem. You get same outcome with plain Java Stream API or Reactor Flux.

lambda expressions allow for a more compact syntax, but may distract you by mixing runtime logic with actual flow definition.

Sure! That's why we always have freedom to extract that logic into methods and use them as reference: handle(this::myBusinessLogic).
You probably still use SpEL with an XML configuration though, so, I don't see technical difference with those lambdas in Java DSL...

elements with attributes, possibly with subelements

That's why I said before that you simply can declare something like Http.inboundChannelAdapter() as a top-level bean and just reference to it from flow definition.
The IntegrationFlow is essentially a mimic of a <chain> in XML where we don't care what channels are between those endpoints.

The most closer variant for what you talk about wiring with channels are messaging annotations where you configure a @ServiceActivator on a business method with channel attributes and so on in other method with other messaging annotations to connect them: https://docs.spring.io/spring-integration/docs/current/reference/html/configuration.html#annotations
Sometime I find it really difficult to navigate an XML from endpoint to endpoint just because they are connected through those channels. Same for these messaging annotations.
Where with an IntegationFlow we have everything wired directly by those EIP-methods. We also have from(IntegrationFlow) and to(IntegrationFlow) to be able to divide logic between different (or even reusable) flows which simply can be navigated from IDE: https://docs.spring.io/spring-integration/docs/current/reference/html/dsl.html#integration-flows-composition

I just had a quick look at Spring Integration Groovy DSL, but it's nothing near to the clearness of Gradle one

I cannot agree with this since (even if I like Gradle and Groovy by itself) it is hard to determine what and how to write in that Gradle config, especially for plugin configurations.
We really designed Groovy DSL similar way to Gradle style, despite it is just wrapper around Java DSL:

		@Bean
		someFlow() {
			integrationFlow { 'test' }
					{
						log LoggingHandler.Level.WARN, 'test.category'
						channel { queue 'pollerResultChannel' }
					}
		}

I'm not sure what you see here as a difference with Gradle style...
And thanks to @CompileStatic and @DelegatesTo we have a good IDE support for suggestions, unlike with Gradle 😄

Sorry for a lengthy , but I tried to cover all your questions.

Of course, we are opened for suggestions to improve an API to make your developer life easier.

The ref attribute is definitely documented: https://docs.spring.io/spring-integration/docs/current/reference/html/endpoint.html#endpoint-namespace.

As we agreed here, the Duration support will be added to <poller> configuration as a task for this issue.

Thank you for your time again!

@aljopainter
Copy link

aljopainter commented May 17, 2023 via email

@artembilan
Copy link
Member

I believe that there is no bean name overload for "transform()"

Yeah... I see.
Right, we have something like this for splitter:

public B split(String expression) {

split(String beanName, @Nullable String methodName) {

But only the first variant for transform().
I think I don't see a problem adding transform(String beanName, @Nullable String methodName) variant.

Please, raise a new GH issue.

Thank you for the pointer!

@aljopainter
Copy link

aljopainter commented May 17, 2023 via email

@mauromol
Copy link
Contributor Author

Java code formatting may not match the requirements to properly "indent"

Not sure how this could be treated as a DSL problem. You get same outcome with plain Java Stream API or Reactor Flux.

Hi Artem,
first of all I would like to say that the problems I see with Java DSL are not about features, but rather about readability and ease of writing. Ok, perhaps if I were comfortable with Java DSL it would be easier, or perhaps it's my limit, but I find really hard to read a complex flow written with Java DSL, while I can follow it quite easily in XML.
So, the "indentation" problem I described is not a DSL problem from a feature point of view, but simply it's due to the fact that (in my opinion) the Java DSL is not the best language/technology to use to describe a flow composition.

Sure! That's why we always have freedom to extract that logic into methods and use them as reference: handle(this::myBusinessLogic). You probably still use SpEL with an XML configuration though, so, I don't see technical difference with those lambdas in Java DSL...

You're right. But SpEL expressions should be short and they are put into attributes, so they are in some way confined. You have to use them sparingly. In Java code you're free to do anything. Yes, you can decompose things to improve readability, but still you end up with code describing something, rather than with an actual description of that thing. It's hard for me to explain, I hope you at least get the concept...

The most closer variant for what you talk about wiring with channels are messaging annotations where you configure a @ServiceActivator on a business method with channel attributes and so on in other method with other messaging annotations to connect them: https://docs.spring.io/spring-integration/docs/current/reference/html/configuration.html#annotations Sometime I find it really difficult to navigate an XML from endpoint to endpoint just because they are connected through those channels. Same for these messaging annotations. Where with an IntegationFlow we have everything wired directly by those EIP-methods. We also have from(IntegrationFlow) and to(IntegrationFlow) to be able to divide logic between different (or even reusable) flows which simply can be navigated from IDE: https://docs.spring.io/spring-integration/docs/current/reference/html/dsl.html#integration-flows-composition

I'm not sure I can fully follow you. I use annotations sparingly and only to avoid the use of interfaces or to describe "singleton" components in my integration flows. The main flow description is written in XML and channels are declared in XML as well. I agree with you that when you start to mix XML and annotations it can become a mess.

I cannot agree with this since (even if I like Gradle and Groovy by itself) it is hard to determine what and how to write in that Gradle config, especially for plugin configurations. We really designed Groovy DSL similar way to Gradle style, despite it is just wrapper around Java DSL:

Documentation is of course essential. XML has the benefit to guide you with a schema, a Groovy DSL does not have a schema and often the IDE is not really helpful (that's the case for Gradle Groovy DSL too, at least in Eclipse), however that is not the point.

		@Bean
		someFlow() {
			integrationFlow { 'test' }
					{
						log LoggingHandler.Level.WARN, 'test.category'
						channel { queue 'pollerResultChannel' }
					}
		}

What disturbs me are all those methods with multiple parameters. Why a "{" block after integrationFlow { 'test' }? What does it mean? What are LoggingHandler.Level.WARN and 'test.category'? Indeed, these are method calls, they are not pure description.

The following is pure description instead, although it's actually mapped to method calls under the hood:

publishing {
	publications {
		mavenJava(MavenPublication) {
			from components.java
			artifactId 'myartifact'
		}
	}

	repositories {
		maven {
			url 'myRepoUrl'
			credentials {
				username = 'myUserName'
				password = 'myPassword'
			}
			allowInsecureProtocol = true
		}
	}
}

So you have just blocks describing components (just like XML elements), properties (just like XML attributes) and nesting of blocks to describe nested components.
Of course I'm simplifying. What I'm trying to say is that I like my flow descriptions to be just descriptions, not imperative code, even if I try to disguise those methods calls with curated methods names using the chain pattern.

The ref attribute is definitely documented: https://docs.spring.io/spring-integration/docs/current/reference/html/endpoint.html#endpoint-namespace.

You're right, sorry, I was looking at the wrong place:
https://docs.spring.io/spring-integration/docs/5.4.11/reference/html/core.html#polling-consumer
On the above link I can't find mentions of ref.
Still, looking at the right page, it's not that easy to grasp that ref can point to a PeriodicTrigger bean, it just says it may point to a top-level <int:poller>.

@artembilan
Copy link
Member

it's not that easy to grasp that ref can point to a PeriodicTrigger

Oh! My fault. The attribute we are looking for is a trigger:

Reference to any Spring-configured bean that implements the org.springframework.scheduling.Trigger interface. However, if this attribute is set, none of the following attributes must be specified: fixed-delay, fixed-rate, cron, and ref. Optional.

Re. Groovy DSL.

I don't see difference between integrationFlow { 'test' } { definition and your mavenJava(MavenPublication) {.
That log LoggingHandler.Level.WARN, 'test.category' is exactly what you show with an `url 'myRepoUrl'.
It is not fully clear why would one need that:

credentials {
				username = 'myUserName'
				password = 'myPassword'
			}

when it could be as simple as credentials 'myUserName', 'myPassword'.
Why allowInsecureProtocol = true, but not just allowInsecureProtocol true.
Too much inconsistency in that Gradle DSL.

However I think you are right and we are not fully following Gradle style since don't have those intermediate containers like the mentioned credentials.
Either way we have to get used to that Gradle DSL and it is not the same for all the plugins.

And here is a screenshot for Groovy DSL in my IDE:
image

As you see it does give us those parameter descriptions.

However I got you point about description.
I'm not sure what could be better than setters and builder pattern in Java do describe something.
Here is a sample of JPA channel adapter:

.handle(Jpa.retrievingGateway(entityManagerFactory)
							.jpaQuery("from Student s where s.id = :id")
							.expectSingleResult(true)
							.parameterExpression("id", "headers[payloadId]"))

Why do you find it so bad than similar XML variant:

	<int-jpa:retrieving-outbound-gateway id="retrievingJpaOutboundGateway"
		entity-manager-factory="entityManagerFactory"
		auto-startup="true"
		entity-class="org.springframework.integration.jpa.test.entity.StudentDomain"
		order="1"
		max-results="55"
		delete-after-poll="true"
		flush-after-delete="true"
		request-channel="in"
		reply-channel="out"
		reply-timeout="100"
		expect-single-result="true"
		requires-reply="false"/>

?

With a handle() we say that it is a service activator or an outbound gateway.
With a Jpa.retrievingGateway() factory we say that it is going to be a gateway for SELECT via JPA.
The rest is just a result of builder pattern method chaining which shows us exactly options and their value.
How that is different from an equivalent XML?

If you are not comfortable to put them together into an IntegrationFlow, feel free to use @ServiceActivator which would give you those input and output channel options.
However you mentioned a <chain> where you "mess" several nested components. How is that different then to an IntegationFlow?

@mauromol
Copy link
Contributor Author

mauromol commented May 18, 2023

it's not that easy to grasp that ref can point to a PeriodicTrigger

Oh! My fault. The attribute we are looking for is a trigger:

Ok, this is clear now, thanks a lot for pointing that out!

Re. Groovy DSL.

I don't see difference between integrationFlow { 'test' } { definition and your mavenJava(MavenPublication) {. That log LoggingHandler.Level.WARN, 'test.category' is exactly what you show with an `url 'myRepoUrl'. It is not fully clear why would one need that:

credentials {
				username = 'myUserName'
				password = 'myPassword'
			}

when it could be as simple as credentials 'myUserName', 'myPassword'.

Because here you loose parameter names, which describe what are you doing. In this example it's quite clear what myUserName and myPassword are, but in other context that may not be the case.
Regarding integrationFlow { 'test' } {, there are two blocks one after the other and it's not explicit what they should do and what is their mutual relationship. Unless you search for documentation, of course.

Why allowInsecureProtocol = true, but not just allowInsecureProtocol true. Too much inconsistency in that Gradle DSL.

I believe that allowInsecureProtocol true works as well, something comes into my mind, but I'm not sure now. I agree there is inconsistency here and there. I just wanted to point out a good example of Groovy DSL, not a perfect one :-)

However I got you point about description. I'm not sure what could be better than setters and builder pattern in Java do describe something. Here is a sample of JPA channel adapter:

As I said, I think the problem is just Java. Which I love and I think it's great, but it's not the best solution for this problem. As always, IMHO.

With a handle() we say that it is a service activator or an outbound gateway. With a Jpa.retrievingGateway() factory we say that it is going to be a gateway for SELECT via JPA. The rest is just a result of builder pattern method chaining which shows us exactly options and their value. How that is different from an equivalent XML?

Given that XML works and its support is very good in Spring Integration, I'll try to reverse the question: why is XML so bad that is somewhat being deprecated? :-)
I guess it's about maintenance, but also about AOT compiling it (i.e.: GraalVM), because I guess XML processing would need to be refactored to try to remove reflection, am I right? Type safety is less important IMHO because Spring Tooling is quite good on XML editing, although less effort has gone there since STS 4.

@artembilan
Copy link
Member

Yeah...
So, I think something like this we have so far:

aggregate {
	id 'aggregator'
	outputProcessor { it.one }
	expireGroupsUponCompletion false
}

Is what you are talking about descriptive style.

But others are really different and not so readable. Like this sample for the splitter:

split(Object, { it }) {
	id 'splitterEndpoint'
}

where it is not clear what are those Object and closure with it.

This one does look well, too:

poller {
	it.fixedDelay(10)
		.maxMessagesPerPoll(1)
}

Although it has some visibility level.
for the poller property there is just no Groovy @DelegatesTo like we do with that aggregate before. Just because we use here directly Java EndpointSpec class, no groovy wrapper.

Well, that something to think and play with.

Feel free to raise an new GH issue about this Groovy DSL concern.

Thank you for feedback and contribution is welcome!

artembilan added a commit to artembilan/spring-integration that referenced this issue May 19, 2023
Fixes spring-projects#8625

The duration can be represented in a ISO 8601 format, e.g. `PT10S`, `P1D` etc.
The `<poller>` and `@Poller` don't support such a format.

* Introduce a `PeriodicTriggerFactoryBean` to accept string values for
trigger options and parse them manually before creating the target `PeriodicTrigger`
* Use this `PeriodicTriggerFactoryBean` in the `PollerParser` and `AbstractMethodAnnotationPostProcessor`
where we parse options for the `PeriodicTrigger`
* Modify tests to ensure that feature works
* Document the duration option
* Add more cross-links into polling docs
* Fix typos in the affected doc files
* Add `-parameters` for compiler options since SF 6.1 does not support `-debug` anymore
for method parameter names discovery
@artembilan artembilan modified the milestones: 6.2.x, 6.2.0-M1 May 19, 2023
@artembilan artembilan self-assigned this May 19, 2023
garyrussell added a commit that referenced this issue May 22, 2023
* GH-8625: Add Duration support for `<poller>`

Fixes #8625

The duration can be represented in a ISO 8601 format, e.g. `PT10S`, `P1D` etc.
The `<poller>` and `@Poller` don't support such a format.

* Introduce a `PeriodicTriggerFactoryBean` to accept string values for
trigger options and parse them manually before creating the target `PeriodicTrigger`
* Use this `PeriodicTriggerFactoryBean` in the `PollerParser` and `AbstractMethodAnnotationPostProcessor`
where we parse options for the `PeriodicTrigger`
* Modify tests to ensure that feature works
* Document the duration option
* Add more cross-links into polling docs
* Fix typos in the affected doc files
* Add `-parameters` for compiler options since SF 6.1 does not support `-debug` anymore
for method parameter names discovery

* Fix typos

Co-authored-by: Gary Russell <[email protected]>

---------

Co-authored-by: Gary Russell <[email protected]>
artembilan added a commit to artembilan/spring-integration that referenced this issue May 31, 2023
The concern has been driven by the discussion from: spring-projects#8625
The point is that Java method arguments are not so descriptive when we read the code.
Therefore, it is better to design DSL the way it would be cleaner from reading perspective.
Plus less choice of methods to chain would give a better end-user experience from coding.

* Add a `HeaderFilterSpec` which can accept `headersToRemove` and `patternMatch` as individual
options instead of top-level deprecated `headerFilter(headersToRemove, patternMatch)` `IntegrationFlow` method.
This way Kotlin and Groovy DSLs get a gain from their "inner section" style.
* Such a `Consumer<HeaderFilterSpec>` way to configure an endpoint is similar to already
existing `aggregate(Consumer<AggregatorSpec>)`, `resequence(Consumer<ResequencerSpec>)` etc.
In other words those components which has a dedicated `ConsumerEndpointSpec` extension are OK
from an idiomatic DSL style perspective
* Expose a `HeaderFilter.setHeadersToRemove()` to make it working smoothly with this new
DSL requirements
* Apply a new `headerFilter()` style into Kotlin and Groovy DSLs

This is just an initial work to surface an idea.
If it is OK, I'll slow continue with others to realign and simplify the paradox of choice.
@artembilan
Copy link
Member

Hi @mauromol !

If you don't mind, please, take a look into this PR: #8636.

Essentially what I did is something like this in Groovy DSL:

headerFilter {
	patternMatch false
	headersToRemove "notAHeader", "headerToRemove"
}

Kinda exactly what we discussed with you before about an aggregate.

I understand that you might be interested in something more in Groovy style which would mimic an XML configuration, probably fully without that integationFlow { } wrapper, but that would be a bit different story and let's move to there in a baby steps for now.

garyrussell pushed a commit that referenced this issue Jun 1, 2023
* Introduce HeaderFilterSpec to streamline DSL API

The concern has been driven by the discussion from: #8625
The point is that Java method arguments are not so descriptive when we read the code.
Therefore, it is better to design DSL the way it would be cleaner from reading perspective.
Plus less choice of methods to chain would give a better end-user experience from coding.

* Add a `HeaderFilterSpec` which can accept `headersToRemove` and `patternMatch` as individual
options instead of top-level deprecated `headerFilter(headersToRemove, patternMatch)` `IntegrationFlow` method.
This way Kotlin and Groovy DSLs get a gain from their "inner section" style.
* Such a `Consumer<HeaderFilterSpec>` way to configure an endpoint is similar to already
existing `aggregate(Consumer<AggregatorSpec>)`, `resequence(Consumer<ResequencerSpec>)` etc.
In other words those components which has a dedicated `ConsumerEndpointSpec` extension are OK
from an idiomatic DSL style perspective
* Expose a `HeaderFilter.setHeadersToRemove()` to make it working smoothly with this new
DSL requirements
* Apply a new `headerFilter()` style into Kotlin and Groovy DSLs

This is just an initial work to surface an idea.
If it is OK, I'll slow continue with others to realign and simplify the paradox of choice.

* * Fix asterisk imports in the `KotlinIntegrationFlowDefinition`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants