Jira Cloud to Jira Cloud: Loop issue with Checklist addon

Hi,

We have a Checklist addon on both of our Jira Cloud instances and we want to sync this from one of the instances to the other, but we also want to be able to make changes on the other instance and have that being synced back (two-way sync).

The checklist addon works in a way that you can add items to the checklist directly in the ticket, and the entire checklist is exposed with a single text field through custom fields (using its own markup language to populate the checklists). So if we add an item to the checklist, the text field is updated to include the new item:

“customFields”: {
“Checklist Text”: {
“id”: 10171,
“name”: “Checklist Text”,
“uid”: “10171”,
“type”: “TEXT”,
“value”: “# Default checklist\n* [done] test\n* [done] Test 2\n* [open] Test 3\n* [open] Test 4\n* [open] Test 5\n* [open] Test 6”
}
},

On both sites we have the following in our outgoing scripts:
replica.customFields.“Checklist Text” = issue.customFields.“Checklist Text”

On both sites we have the following in our incomming scripts:
issue.customFields.“Checklist Text”.value = replica.customFields.“Checklist Text”?.value

If we make a simple change (ex. mark one of the checklist items as “done”, or add a new item to the checklist), the change is synced over to the other site with no problem. However, if we make multiple rapid changes (ex. mark several of the checklist items as “done” within a few seconds) it creates a sync loop where only some of the changes are being transferred over to the other site, and since there is a two-way sync they don’t match and it creates an infinite loop where they are trying to correct each other. The end result is that there is always something in the sync que for those tickets and both sites have the incorrect value of the checklist text field (ex. only 3/5 is marked as done, when it was supposed to be 5/5 of the items marked as done).

If we comment out the outgoing sync for the Checklist Text-field on one of the sites (disabling two-way sync), the loop is broken and all rapid changes are synced over from one site to the other without issues.

How can we add support for two-way sync without introducing this loop?

Thanks for the detailed explanation! This is a classic challenge with two-way sync of custom fields that can be rapidly updated, especially when the field is a single text value representing a complex object (like a checklist). The loop happens because both sides try to “correct” each other when changes are made in quick succession, and the sync engines can’t always determine which version is the latest.

Here’s what the Exalate documentation recommends for handling this scenario:

  • Implement conflict resolution or change detection: Instead of always overwriting the checklist field on both sides, you can add logic to your sync scripts to only update the field if the incoming value is different and newer than the current value. This often involves using timestamps or versioning.
  • Use a “last updated” timestamp: If your checklist add-on or Jira custom field supports a last-modified timestamp, you can sync this as well and compare it in your incoming script. Only update the field if the incoming value is newer.
  • Prevent echoing changes: You can add a mechanism to detect if the change originated from the other side and avoid re-sending it back, breaking the loop.

Here’s a reference from the Exalate documentation that covers avoiding sync loops and handling two-way sync:

To avoid infinite sync loops, implement logic in your sync scripts to detect and ignore changes that originated from the other side. This can be done by using a custom field or property to track the source of the last update, or by comparing timestamps.

A common approach is to use a custom field (e.g., “Last Synced By Exalate”) or a property in the replica to track the source and time of the last update. Then, in your incoming script, only apply the update if it’s newer than the current value.

Example logic for your incoming script:

if (replica.customFields."Checklist Text"?.value && 
    (replica.customFields."Checklist Text".lastUpdated > issue.customFields."Checklist Text".lastUpdated)) {
    issue.customFields."Checklist Text".value = replica.customFields."Checklist Text".value
    issue.customFields."Checklist Text".lastUpdated = replica.customFields."Checklist Text".lastUpdated
}

You’ll need to adapt this to your actual field structure and ensure the lastUpdated property is available or can be set.

Key steps:

  1. Add a timestamp or version property to your checklist field if possible.
  2. Update your sync scripts to compare timestamps/versions before applying changes.
  3. Optionally, use a custom field to track the source of the last update and prevent echoing.

You can find more details and examples in the Exalate documentation on avoiding infinite sync loops.

This approach should help you achieve reliable two-way sync without loops, even with rapid changes.

Hi,

Thank you for the detailed description of the issue. This is a classic race condition with two-way sync of frequently changing fields. The root cause is that when Site B receives the update and writes it locally, that write triggers Site B’s outgoing sync, which echoes the value back to Site A, and so on – creating an infinite loop when rapid changes are involved.

The solution is to use the previous. keyword in your incoming scripts on both sites. By comparing replica. (the current incoming payload) with previous. (the payload received from the remote side during the last sync), you can detect whether the remote side actually made a new change or is simply echoing back a value you sent. If the value hasn’t changed on the remote side, you skip the update entirely, which breaks the loop.


Outgoing scripts

replica.customFields.“Checklist Text” = issue.customFields.“Checklist Text”

Incoming script

def remoteChecklist  = replica.customFields."Checklist Text"?.value

def previousChecklist = previous.customFields."Checklist Text"?.value

if (remoteChecklist != previousChecklist) {

    issue.customFields."Checklist Text".value = remoteChecklist

Kind regards,

Mathieu Lepoutre

Hi Mathieu,

Thanks for looking into this.

We have implemented the suggested solution with the following in both incoming script (not within first sync block):

def remoteChecklist  = replica.customFields."Checklist Text"?.value
def previousChecklist = previous.customFields."Checklist Text"?.value

if (remoteChecklist != previousChecklist) {
    issue.customFields."Checklist Text".value = remoteChecklist

}

And the following in outgoing scripts:

replica.customFields."Checklist Text" = issue.customFields."Checklist Text"

But we received the following error pointing to line 24 (def previousChecklist = previous.customFields.“Checklist Text”?.value):

  • Impact: RELATION

  • Local entity: null

  • Remote entity: SISM-120

  • Connection:

  • Error type: Incoming script error

  • Error Creation Time: Feb 20, 2026 08:26:29

  • Error Detail Message: No such property: previous for class: Script15

  • Error Stack Trace: com.exalate.api.exception.script.ScriptException: No such property: previous for class: Script15 at com.exalate.error.services.ScriptExceptionCategoryService.categorizeProcessorAndIssueTrackerExceptionsIntoScriptExceptions(ScriptExceptionCategoryService.scala:51) at com.exalate.processor.ExalateProcessor.executeProcessor(ExalateProcessor.java:77) at com.exalate.replication.services.processor.CreateIssueProcessor.$anonfun$executeScriptRules$1(CreateIssueProcessor.scala:221) at scala.util.Try$.apply(Try.scala:210) at com.exalate.replication.services.processor.CreateIssueProcessor.executeScriptRules(CreateIssueProcessor.scala:213) at com.exalate.replication.services.processor.CreateIssueProcessor.$anonfun$createIssue$1(CreateIssueProcessor.scala:119) at scala.concurrent.impl.Promise$Transformation.run(Promise.scala:470) at akka.dispatch.BatchingExecutor$AbstractBatch.processBatch(BatchingExecutor.scala:63) at akka.dispatch.BatchingExecutor$BlockableBatch.$anonfun$run$1(BatchingExecutor.scala:100) at scala.runtime.java8.JFunction0$mcV$sp.apply(JFunction0$mcV$sp.scala:18) at scala.concurrent.BlockContext$.withBlockContext(BlockContext.scala:94) at akka.dispatch.BatchingExecutor$BlockableBatch.run(BatchingExecutor.scala:100) at akka.dispatch.TaskInvocation.run(AbstractDispatcher.scala:49) at akka.dispatch.ForkJoinExecutorConfigurator$AkkaForkJoinTask.exec(ForkJoinExecutorConfigurator.scala:48) at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:373) at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1182) at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1655) at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1622) at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:165) Caused by: javax.script.ScriptException: javax.script.ScriptException: groovy.lang.MissingPropertyException: No such property: previous for class: Script15 at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:164) at java.scripting/javax.script.AbstractScriptEngine.eval(AbstractScriptEngine.java:262) at com.exalate.processor.ExalateProcessor.execute(ExalateProcessor.java:114) at com.exalate.processor.ExalateProcessor.executeProcessor(ExalateProcessor.java:75) … 17 more Caused by: javax.script.ScriptException: groovy.lang.MissingPropertyException: No such property: previous for class: Script15 at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:334) at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:161) … 20 more Caused by: groovy.lang.MissingPropertyException: No such property: previous for class: Script15 at org.codehaus.groovy.runtime.ScriptBytecodeAdapter.unwrap(ScriptBytecodeAdapter.java:67) at org.codehaus.groovy.vmplugin.v8.IndyGuardsFiltersAndSignatures.unwrap(IndyGuardsFiltersAndSignatures.java:161) at org.codehaus.groovy.vmplugin.v8.IndyInterface.fromCache(IndyInterface.java:318) at Script15.run(Script15.groovy:24) at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:331) … 21 more

Looking forward to your follow up!

Best regards

Stian

Hi again Mathieu,

We managed to fix this using the following code:

if (firstSync) {
  issue.customFields."Checklist Text".value = replica.customFields."Checklist Text"?.value
} else {
  def localChecklist  = issue.customFields."Checklist Text"?.value
  def remoteChecklist  = replica.customFields."Checklist Text"?.value
  def previousChecklist = previous.customFields."Checklist Text"?.value

  if (localChecklist != previousChecklist && localChecklist != remoteChecklist) {
    issue.customFields."Checklist Text".value = remoteChecklist
  } 
}

Now rapid changes and semi-rapid changes syncs completely without any loops.

The only issue we have now is if we perform changes on both sides at the same time, but this is not that important for us to fix as it will likely never happen. It does not create any loops, but the result is incorrect.

Thanks for pointing us in the correct direction!

Best regards

Stian