Hello devs,
XClassDeletingListener (xwiki-platform-refactoring-default) and DocumentsDeletingListener (xwiki-platform-extension-handler-xar) share this block:
} catch (InterruptedException e) {
this.logger.warn("Confirm question has been interrupted.");
cancelableEvent.cancel("Question has been interrupted.");
}
// we always want the event and the CancelableJobStatus to be consistent
if (jobStatus instanceof CancelableJobStatus cancelableJobStatus) {
if (cancelableJobStatus.isCanceled()) {
cancelableEvent.cancel();
}
if (cancelableEvent.isCanceled()) {
cancelableJobStatus.cancel();
}
}
I noticed it because [Misc] Fix SonarQube issues by restoring the thread interrupt status by claude[bot] · Pull Request #6248 · xwiki/xwiki-platform · GitHub fails its SonarCloud quality gate on it (new_duplicated_lines_density at 5%, threshold 3%): that PR adds a single Thread.currentThread().interrupt(); line to each of the two files, and both land inside the duplicated block.
Every type involved is in Commons: Job, JobStatus and CancelableJobStatus in xwiki-commons-job-api, CancelableEvent in xwiki-commons-observation-api. And a base listener class wouldn’t be right, since the shared code never touches onEvent(), the event list or the listener name: the two classes have nothing in common as listeners, they just need the same helper. So I’d rather have a component in xwiki-commons-job-api, which already has org.xwiki.observation on its compile classpath:
@Role
public interface JobQuestionAsker
{
/**
* Ask a question to the user of the passed job, and cancel both the event and the job status when no answer
* is received before the timeout expires or when the wait is interrupted.
*
* @param job the job asking the question
* @param event the cancelable event being handled
* @param question the question to ask
* @param timeout how long to wait for an answer
* @param noAnswerMessage the message to log and to cancel the event with when no answer is received
* @since 18.8.0RC1
*/
void ask(Job job, CancelableEvent event, Object question, Duration timeout, String noAnswerMessage);
}
The default implementation in xwiki-commons-job-default is just the two blocks moved out of the listeners:
@Component
@Singleton
public class DefaultJobQuestionAsker implements JobQuestionAsker
{
@Inject
private Logger logger;
@Override
public void ask(Job job, CancelableEvent event, Object question, Duration timeout, String noAnswerMessage)
{
JobStatus jobStatus = job.getStatus();
try {
if (!jobStatus.ask(question, timeout.toMillis(), TimeUnit.MILLISECONDS)) {
// Without any answer we must cancel the operation.
this.logger.warn(noAnswerMessage);
event.cancel(noAnswerMessage);
}
} catch (InterruptedException e) {
this.logger.warn("Confirm question has been interrupted.");
Thread.currentThread().interrupt();
event.cancel("Question has been interrupted.");
}
// We always want the event and the CancelableJobStatus to be consistent.
if (jobStatus instanceof CancelableJobStatus cancelableJobStatus) {
if (cancelableJobStatus.isCanceled()) {
event.cancel();
}
if (event.isCanceled()) {
cancelableJobStatus.cancel();
}
}
}
}
Note that this is also where the Thread.currentThread().interrupt(); that started all this would live, once, instead of in each listener.
XClassDeletingListener
Today:
@Override
public void onEvent(Event event, Object source, Object data)
{
Job job = (Job) source;
CancelableEvent cancelableEvent = (CancelableEvent) event;
XClassBreakingQuestion question = this.buildQuestion(job, cancelableEvent, data);
if (question == null) {
return;
}
// Ask a confirmation to the user if some pages contain used XClass
if (!question.getImpactedObjects().isEmpty()) {
JobStatus jobStatus = job.getStatus();
try {
if (this.documentAccessBridge.isAdvancedUser(job.getRequest().getProperty("user.reference"))) {
// Conservative choice: we let the user enable the pages to delete.
question.unselectAll();
// The user can modify the question so it could disable some EntitySelection.
// We add a timeout because when a refactoring job is running, it prevents others to run.
// 5 minutes is probably enough for the user to decide if the process should go on.
boolean ack = job.getStatus().ask(question, 5, TimeUnit.MINUTES);
if (!ack) {
// Without any confirmation, we must cancel the operation.
String message = "The question has been asked, however no answer has been received.";
this.logger.warn(message);
cancelableEvent.cancel(message);
}
} else {
question.setRefactoringForbidden(true);
// we don't want the user to answer the question,
// but we want to display that his action is forbidden.
boolean ack = jobStatus.ask(question, 1, TimeUnit.MINUTES);
if (!ack) {
String message = "The question has been canceled because this refactoring is forbidden.";
cancelableEvent.cancel(message);
}
}
} catch (InterruptedException e) {
this.logger.warn("Confirm question has been interrupted.");
cancelableEvent.cancel("Question has been interrupted.");
}
// we always want the event and the CancelableJobStatus to be consistent
if (jobStatus instanceof CancelableJobStatus cancelableJobStatus) {
if (cancelableJobStatus.isCanceled()) {
cancelableEvent.cancel();
}
if (cancelableEvent.isCanceled()) {
cancelableJobStatus.cancel();
}
}
}
}
With the component:
@Override
public void onEvent(Event event, Object source, Object data)
{
Job job = (Job) source;
CancelableEvent cancelableEvent = (CancelableEvent) event;
XClassBreakingQuestion question = this.buildQuestion(job, cancelableEvent, data);
// Ask a confirmation to the user if some pages contain used XClass
if (question == null || question.getImpactedObjects().isEmpty()) {
return;
}
if (this.documentAccessBridge.isAdvancedUser(job.getRequest().getProperty("user.reference"))) {
// Conservative choice: we let the user enable the pages to delete.
question.unselectAll();
// The user can modify the question so it could disable some EntitySelection.
// We add a timeout because when a refactoring job is running, it prevents others to run.
// 5 minutes is probably enough for the user to decide if the process should go on.
this.questionAsker.ask(job, cancelableEvent, question, Duration.ofMinutes(5),
"The question has been asked, however no answer has been received.");
} else {
// We don't want the user to answer the question, but we want to display that this action is forbidden.
question.setRefactoringForbidden(true);
this.questionAsker.ask(job, cancelableEvent, question, Duration.ofMinutes(1),
"The question has been canceled because this refactoring is forbidden.");
}
}
45 lines down to 24, two nesting levels less. One behaviour change to note: the “refactoring is forbidden” branch currently cancels without logging, and would now log the message as a warning.
DocumentsDeletingListener
Today (the tail of onEvent(), the rest is unchanged):
JobStatus jobStatus = job.getStatus();
// Ask a confirmation to the user if some pages belong to extensions
if (!question.getExtensions().isEmpty()) {
// Conservative choice: we let the user enable the pages to delete.
question.unselectAll();
try {
// The user can modify the question so it could disable some EntitySelection.
// We add a timeout because when a refactoring job is running, it prevents others to run.
// 5 minutes is probably enough for the user to decide if the process should go on.
boolean ack = jobStatus.ask(question, 5, TimeUnit.MINUTES);
if (!ack) {
// Without any confirmation, we must cancel the operation.
String message = "The question has been asked, however no answer has been received.";
this.logger.warn(message);
cancelableEvent.cancel(message);
}
} catch (InterruptedException e) {
this.logger.warn("Confirm question has been interrupted.");
cancelableEvent.cancel("Question has been interrupted.");
}
// we always want the event and the CancelableJobStatus to be consistent
if (jobStatus instanceof CancelableJobStatus cancelableJobStatus) {
if (cancelableJobStatus.isCanceled()) {
cancelableEvent.cancel();
}
if (cancelableEvent.isCanceled()) {
cancelableJobStatus.cancel();
}
}
}
With the component:
// Ask a confirmation to the user if some pages belong to extensions
if (!question.getExtensions().isEmpty()) {
// Conservative choice: we let the user enable the pages to delete.
question.unselectAll();
// The user can modify the question so it could disable some EntitySelection.
// We add a timeout because when a refactoring job is running, it prevents others to run.
// 5 minutes is probably enough for the user to decide if the process should go on.
this.questionAsker.ask(job, cancelableEvent, question, Duration.ofMinutes(5),
"The question has been asked, however no answer has been received.");
}
29 lines down to 10, no behaviour change.
Open questions
- Is a component the right shape, or is a static utility enough? The argument for a component is testability:
XClassDeletingListenerTestandDocumentsDeletingListenerTestboth stubstatus.ask(...)and re-verify the interruption behaviour today, whereas with a component they’d verify one interaction and the behaviour would be tested once, in Commons. - Naming:
JobQuestionAsker, orJobQuestionManagerto stay in line with our other component names? - Signature:
Duration, orlong timeout, TimeUnit unitto stay consistent with the existingJobStatus.ask()? And is passingnoAnswerMessageas aStringacceptable? Returning the boolean and letting the caller cancel would put the duplication straight back. - The two listeners also share their
event.isCanceled()/!job.getRequest().isInteractive()pre-checks (Sonar doesn’t flag those only because the log messages differ). Should acanAsk()method cover them too? - And the real question: is ~15 lines duplicated in exactly two places worth a new API in Commons? Doing nothing and accepting the duplication on that PR is a perfectly valid answer.
In practice this would mean an XCOMMONS issue and PR landing first, then the Platform side, with @since 18.8.0RC1.
Honestly I’m not sure it’s worth it, hence the brainstorming tag. Curious to have your POV.
Thanks