UpdateInstalledExtensionCountScheduler

Last modified by Vincent Massol on 2026/08/31 15:13

Installed extension count update

Update the counter of installed extensions

0 0 0 ? * MON
The script is the code that will be executed when the job is triggered by the scheduler. It should be written in the Groovy language. The XWiki API is available through the xwiki and context pre-defined variables.
import groovy.json.JsonOutput
import groovy.json.JsonSlurper

import org.xwiki.activeinstalls2.ActiveInstallsConfiguration

// Instances running XWiki < 14.5 send their pings to the Active Installs 1 Elasticsearch instance, newer ones send
// them to the Active Installs 2 instance. Both have to be queried and their results added up, otherwise only a
// fraction of the running instances is counted.

/**
 * @return the number of distinct instances having pinged Active Installs 2 during the last day, keyed by extension id
 */
def getActiveInstalls2Counts()
{
  def query = [
    'size': 0,
    'query': [
      'range': [
        'date.current': ['gte': 'now-1d']
      ]
    ],
    'aggs': [
      'extensions': [
        // "extensions" is mapped as a nested type so that each entry's id/version/features stay indexed together,
        // which means it can only be reached through a nested aggregation.
        'nested': ['path': 'extensions'],
        'aggs': [
          'ids': [
            'terms': ['field': 'extensions.id', 'size': 10000],
            'aggs': [
              // The instance id is on the root document and not on the nested extension, so step back out to count it.
              'instances': [
                'reverse_nested': [:],
                'aggs': [
                  'count': ['cardinality': ['field': 'distribution.instanceId']]
                ]
              ]
            ]
          ]
        ]
      ]
    ]
  ]

  // The Active Installs 2 script service exposes no aggregation API, so query the Elasticsearch REST endpoint
  // directly. A single request returns the count for every extension, which is much cheaper than the one request per
  // extension that Active Installs 1 requires. Once this wiki runs XWiki 18.7 or later, this whole method can be
  // replaced by a call to services.activeinstalls2.countDistinctInstallsByExtension().
  def configuration = services.component.getInstance(ActiveInstallsConfiguration.class)
  def connection = new URL("${configuration.pingInstanceURL}/_search").openConnection()
  connection.requestMethod = 'POST'
  connection.doOutput = true
  connection.connectTimeout = 30000
  connection.readTimeout = 300000
  connection.setRequestProperty('Content-Type', 'application/json')
  // The default JDK user agent gets served a Cloudflare challenge instead of the query result.
  connection.setRequestProperty('User-Agent', configuration.userAgent)
  connection.outputStream.withWriter('UTF-8') { writer -> writer << JsonOutput.toJson(query) }

  if (connection.responseCode != 200) {
    throw new Exception("Active Installs 2 query failed with [${connection.responseCode}]: "
      + "[${connection.errorStream?.getText('UTF-8')}]")
  }

  def response
  connection.inputStream.withReader('UTF-8') { reader -> response = new JsonSlurper().parse(reader) }

  def buckets = response.aggregations.extensions.ids.buckets
  if (response.aggregations.extensions.ids.sum_other_doc_count > 0) {
    // A terms aggregation silently drops the extensions that don't fit in its size, which would under-report them.
    throw new Exception("Too many extension ids returned by Active Installs 2, raise the aggregation size above "
      + "[${buckets.size()}]")
  }

  def counts = [:]
  buckets.each { bucket -> counts.put(bucket.key, bucket.instances.count.value as long) }
  return counts
}

/**
 * @return the number of distinct instances having pinged Active Installs 1 during the last day for the passed
 *         extension. Contrary to Active Installs 2, the whole set cannot be aggregated in one query: the old
 *         Elasticsearch instance holds too much data for a terms aggregation on the extension ids and breaks its
 *         circuit breaker, so one query per extension is needed.
 */
def getActiveInstalls1Count(extensionId)
{
  def query = [
    'query': [
      'bool': [
        'must': [
          ['range': ['_timestamp': ['gte': 'now-1d']]],
          // The extension id is passed as a term value instead of being interpolated into a query string, so that an
          // id containing Lucene syntax cannot change what the query matches.
          ['term': ['extensions.id': extensionId]]
        ]
      ]
    ],
    'aggs': [
      'instanceId_count': [
        'cardinality': [
          'field': 'instanceId'
        ]
      ]
    ]
  ]

  def result = services.activeinstalls.searchInstalls('install2', JsonOutput.toJson(query), ['search_type' : 'count'])
  if (result == null) {
    throw services.activeinstalls.lastError ?:
      new Exception("Active Installs 1 returned no result for extension [$extensionId]")
  }

  return result.getAsJsonObject('aggregations').getAsJsonObject('instanceId_count').getAsJsonPrimitive('value')
    .getAsLong()
}

def updateCount(extensionId, documentName, activeInstalls2Counts)
{
  def pxwiki = xwiki.getXWiki()
  def pxcontext = xcontext
  def document = pxwiki.getDocument(documentName, pxcontext)
  def extensionObject = document.getObject('ExtensionCode.ExtensionClass')

  // Extensions not displaying their install count don't need to be counted at all.
  if (extensionObject.getIntValue('showInstalledCount') == 0) {
    return
  }

  def count = (activeInstalls2Counts.get(extensionId) ?: 0L) + getActiveInstalls1Count(extensionId)

  if (extensionObject.getLongValue('installedCount') != count) {
    extensionObject.set('installedCount', count, pxcontext)

    if (document.comment == 'Update install count') {
      // Don't increment version (otherwise we could end up with a lot of those)
      document.setMetaDataDirty(false)
      document.setContentDirty(false)
    }

    // Save
    pxwiki.saveDocument(document, 'Update install count', true, pxcontext)
  }
}

def logger = org.slf4j.LoggerFactory.getLogger('ExtensionCode.UpdateInstalledExtensionCountScheduler')

logger.info('Starting updating installed extensions count')

// Fail the whole job rather than saving Active Installs 1 counts only, which would look like a massive drop.
def activeInstalls2Counts = getActiveInstalls2Counts()
logger.info('Retrieved Active Installs 2 counts for [{}] extensions', activeInstalls2Counts.size())

def extensions = services.query.xwql("select doc.fullName, extension.id from Document doc, doc.object(ExtensionCode.ExtensionClass) as extension").execute()
for (extension in extensions)
{
  logger.info('Updating installed extension [{}] count', extension[0])

  try {
    updateCount(extension[1], extension[0], activeInstalls2Counts)
  } catch (Exception e) {
    logger.error('Failed to update installed count for extension [{}]', extension[0], e)
  }
}

logger.info('Installed extensions count update is finished')

Back to the job list

Get Connected