Alfresco Community 26.2, released in July 2026, drops support for Apache Solr and introduces Alfresco Search Community, a search module that indexes the repository into a standard OpenSearch or Elasticsearch cluster. This post covers it at the level of detail you need in order to actually deploy it: every configuration property, where the artifacts come from, which Docker images to pin, how to migrate an existing repository, and what happens to the queries you already have.
Status: generally available. Alfresco Search Community ships with the 26.2 GA release and is intended for production use. It is distributed as binaries only, a Docker image and a JAR, and the final license text has not been published yet. Both points are covered at the end.
A note on names. The product is called Alfresco Search Community, but every technical identifier keeps the legacy vocabulary: the repository subsystem is
elasticsearch, the shared-secret property issolr.sharedSecret, the text-extraction endpoint is/alfresco/service/api/solr/textContent, and the artifact isalfresco-elasticsearch-batch-indexing. That is deliberate and stable. It also means the word "elasticsearch" in a property name does not imply Elasticsearch the product: OpenSearch is configured through exactly the same properties.
Two things, and the second exists because of the first.
elasticsearch repository subsystem that Alfresco Search Enterprise uses, and it is distributed as binaries only. Source code is not published today; the stated intention is 27.1 or later.Functionally it is the equivalent of Alfresco Search Enterprise, but the indexing mechanism is different: instead of consuming repository events, it polls the repository database for changed transactions. That distinction drives most of the operational detail in this post, and it gets its own section further down.
All three of the usual deployment routes already support it:
| Target | Project |
|---|---|
| Linux | alfresco-ubuntu-installer |
| Docker | alfresco-docker-installer and alfresco-docker-extension |
| Kubernetes | alfresco-helm-charts |
It helps to see where this sits historically, because the 2026 change is smaller than it looks in one respect and much bigger in another.
| Year | Engine | What it introduced |
|---|---|---|
| 2005 | Embedded Lucene | Index local to the repository. No query language of its own. |
| 2011 | Solr 1 | Search moves out of process. Asynchronous tracking by polling, every 15 seconds. |
| 2014 | Solr 4 | Basic sharding. ACL indexing. |
| 2018 | Solr 6 | Extended sharding. High availability through master/slave replication. |
| 2026 | Standard engine (OpenSearch or Elasticsearch) | Continuous polling, no events. One repository subsystem, two interchangeable engines. |
The smaller-than-it-looks part: polling-based tracking is not new. Alfresco has pulled changes on a timer since 2011, and Alfresco Search Community keeps doing exactly that, just with a 30 second default instead of 15 and against a different target. The bigger-than-it-looks part: for the first time the index lives in an engine that Alfresco does not ship, patch, or wrap. There is no Alfresco plugin on the search cluster. You run stock OpenSearch or stock Elasticsearch, and you can run it as a managed service.
There is a community answer, and it is worth knowing about even if you are moving to OpenSearch. jecicorp/AlfrescoSearchServices is a fork of Alfresco Search Services maintained by Jeci, modernising the project onto Apache Solr 9 and Java 17.
You can stand it up with alfresco-docker-installer if you want to compare the two engines side by side. There is a longer write-up of that project on this blog: Bring Alfresco Search to 2026: Vanilla Solr 9, Java 17, and an invitation to the Community.
Four products, two repository subsystems. The subsystem is what matters technically, because it determines which query languages and which fields are available.
| Product | Subsystem | Before 7.1 | 7.1+ | 23.2 | 26.2 |
|---|---|---|---|---|---|
| Alfresco Search Services | solr6 |
Community | Community | Community | - |
| Alfresco Search and Insight Engine | solr6 |
Enterprise | Enterprise | - | - |
| Alfresco Search Community | elasticsearch |
- | - | - | Community |
| Alfresco Search Enterprise | elasticsearch |
- | Enterprise | Enterprise | Enterprise |
The practical consequence: if you are on Community 26.2 you are on the elasticsearch subsystem, which is the same subsystem Enterprise customers have been using since 7.1. Everything documented about the query behaviour of that subsystem applies to you now, including its limitations.
The official documentation splits it into two cooperating parts, and that split is the single most useful thing to internalise:
Those two halves are deployed, configured, versioned, and monitored separately. Half the configuration properties in this post belong to the repository and half belong to the indexer, and mixing them up is the most common early mistake.
| Component | Supported version | Notes |
|---|---|---|
| Alfresco Content Services (Community Edition) | 26.2 and later | Provides the search query path and the text-content endpoint used for content indexing. |
| OpenSearch | 2.11.1 | Supported as an alternative to Elasticsearch. |
| Elasticsearch | 8.17.x | Use a patch release from the supported 8.17.x family. |
| Alfresco Transform Core (AIO) | 5.4.x | Required for content transformation and text extraction. |
| Java runtime | 17 | Required to run the batch-indexing application. |
No Alfresco plugin is installed on the search cluster. That is what makes a managed OpenSearch or Elasticsearch service viable.
This is the correction most worth flagging, because the 26.2-preview announcement said PostgreSQL only and that is no longer accurate. The batch-indexing application reads node metadata directly from the repository database, and the supported list is:
| Database | JDBC driver |
|---|---|
| PostgreSQL | Bundled with the batch-indexing application. |
| MySQL | Supply the driver at startup. |
| MariaDB | Supply the driver at startup. |
For anything other than PostgreSQL, drop the driver JAR into /opt/db-drivers. The Docker image declares that path as a volume and the application loads drivers from it at startup, through a Spring Boot PropertiesLauncher with Loader-Path: db-drivers/. Mount the directory read-only.
services:
alfresco-elasticsearch-batch-indexing:
image: alfresco/alfresco-elasticsearch-batch-indexing:5.7.1
volumes:
- ./db-drivers:/opt/db-drivers:ro
environment:
SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/alfresco
The read-only mount is not decoration. The indexer's own threat model calls it out: the driver directory is executable code loaded at startup, so it should not be writable by the running container.
The whole system, with the numbered hops you will see in the logs and in the metrics:
Client applications (Digital Workspace, Share, REST API)
|
| (1) search request
v
Alfresco Content Services
"elasticsearch" subsystem
|
| (2) translated query + permission filter
v
+===================================================+
| OpenSearch 2.11.1 / Elasticsearch 8.17.x |
| |
| alfresco <- searchable docs |
| alfresco-reindex-state <- cursor (hidden) |
| alfresco-reindex-dead-letter <- errors (hidden) |
+===================================================+
^
| (6) bulk index/update, then advance the cursor
|
alfresco-elasticsearch-batch-indexing
ContinuousReindexingService
(polls every 30 seconds)
| | |
(3) read | (4) fetch | (5) ask which |
changed | extracted | media types |
nodes | text | can transform |
read-only | | |
JDBC | | |
v v v
ACS database ACS textContent Transform Core
endpoint /transform/config
Some properties of this design that are easy to miss:
/alfresco/service/api/solr/textContent over HTTP, authenticated with the X-Alfresco-Search-Secret header. It never touches alf_data.jdbc:hsqldb:mem:batch). Job history is therefore ephemeral and does not survive a restart, which is intentional but has an operational consequence covered later.| Index | Default name | Purpose |
|---|---|---|
| Main search index | alfresco |
Holds the searchable documents: metadata, content, and path. This is the index Content Services queries. |
| State index | alfresco-reindex-state |
Holds the indexing cursor, a single watermark document. Hidden. |
| Dead-letter index | alfresco-reindex-dead-letter |
Records items that could not be indexed. Hidden. |
Hidden is not access control. The state and dead-letter indexes are created with
index.hidden: trueso that they do not show up in index listings. That is a convenience, not a security boundary. Use search-cluster permissions to protect them.
Because they are hidden, the obvious command does not show them. You need expand_wildcards=all:
# shows only "alfresco"
GET _cat/indices?v&s=index
# shows all three
GET _cat/indices/alfresco*?v&s=index&expand_wildcards=all
# proves the hidden flag
GET alfresco*/_settings?expand_wildcards=all&filter_path=**.hidden
There is also a fourth index name on the repository side, elasticsearch.archive.indexName, defaulting to alfresco-archive, used for deleted (archived) nodes. The batch indexer itself only deals with the three above.
One non-obvious detail that will save you an afternoon. Alfresco property names are escaped in the index mapping: : becomes %3A and . becomes %2E. So cm:name is stored as the field cm%3Aname, and cm:content.mimetype is stored as cm%3Acontent%2Emimetype.
In a JSON request body you write the single-escaped form. In a URL path or query parameter you have to double-escape it, or you get an empty result with no error:
# in a body: single escape
GET alfresco/_search
{"query":{"term":{"cm%3Aname":"budget.xls"}}}
# in a URL: double escape, or you silently get nothing
GET alfresco/_search?q=cm%253Aname:budget.xls
Alongside the escaped property fields there is a set of uppercase system fields, around 32 of them in a stock index, including TYPE, ASPECT, PATH, UNPREFIXED_PATH, ANCESTOR, PARENT, PRIMARYPARENT, READER, DENIED, OWNER, ALIVE, SITE, TAG and PROPERTIES. A stock 26.2 index with the sample sites carries roughly 950 mapped fields and six custom analyzers (locale_text_index, locale_text_query, locale_content, path_emulator and two cross-locale variants).
One more subtlety on paths, which trips people up when they try to hand-write a subtree query: a match_phrase on PATH matches the node itself, not its descendants. Descendant queries use a prefix on UNPREFIXED_PATH. In practice you should let AFTS build these, but it explains why a hand-rolled PATH query returns 1 hit where you expected 69.
This is the loop that replaces Solr's trackers. It runs inside ContinuousReindexingService, every pollingInterval (30 seconds by default), and the whole behaviour of the system falls out of it:
+--> (1) read the cursor from alfresco-reindex-state
| |
| v
| (2) compute this cycle's window
| from = cursor - overlap (10m default)
| to = min(now, from + maxWindow) (30m default)
| |
| v
| (3) SELECT nodes changed in [from, to) from the ACS database
| |
| v
| (4) fetch extracted text for those nodes from ACS
| |
| v
| (5) bulk-write metadata + content + path documents
| |
| +----+----------------+
| | |
| success failure
| | |
| v v
| (6) advance cursor stays put, back off
| the cursor 30s -> 60s -> ... -> 5m (maxPollingInterval)
| | |
+-----+---------------------+
wait pollingInterval (30s), or catchUpPollingInterval (1s) if behind
Four consequences worth stating explicitly, because each one turns into a support question:
overlap (10 minutes) further than strictly necessary, so a change committed right on a window boundary is not missed. It also means documents get rewritten harmlessly on every cycle they fall inside.now - overlap. This is the single most important default in the whole product: pre-existing content is not indexed automatically. A fresh indexer pointed at a repository with ten years of history will index the last ten minutes and consider itself up to date. Getting the history in is a manual procedure, covered in the migration section.OpenSearch does not know about Alfresco content models, and the alfresco index is created with dynamic: false, meaning unmapped fields are silently ignored rather than auto-mapped. So something has to translate model changes into mapping updates. That something lives in the repository, not in the indexer:
A property is added or changed in an Alfresco content model
|
v
ContentModelSynchronizer loads the supported and per-locale analyzers
|
v
Indexable? Tokenized? Needs an _untokenized alias?
| |
not indexable indexable
| |
v v
ignored, never added FieldMappingBuilder builds the
to the mapping mapping update request
|
v
total_fields.limit exceeded? (7500)
| |
no yes
| |
v v
mapping extended OpenSearch rejects
automatically the update with a
| native error
v
the property is searchable with
no manual reindex
Two things follow. First, adding a property to a custom model does not require a reindex; the mapping is extended and new documents pick it up. Second, elasticsearch.index.mapping.total_fields.limit (default 7500) is a real ceiling, and because a stock index already uses around 950 fields, a model with a few thousand properties can hit it. When it does, the failure comes from OpenSearch as a native mapping error, not from Alfresco.
Note that this synchronizer is part of the elasticsearch subsystem in the repository. It only runs when that subsystem is active, which matters for the migration ordering discussed later.
The distribuction can be downloaded from Nexus Alfresco: alfresco-elasticsearch-batch-indexing-distribution-5.7.1.zip. Inside the zip you get the executable Spring Boot JAR.
The image is alfresco/alfresco-elasticsearch-batch-indexing, published on Docker Hub. The image is deliberately minimal:
FROM alfresco/alfresco-base-java:jre17-rockylinux9
WORKDIR /opt
COPY *-app.jar app.jar
RUN mkdir -p /opt/db-drivers
VOLUME /opt/db-drivers
ENTRYPOINT exec java $JAVA_OPTS -jar app.jar
JRE 17 on Rocky Linux 9, one JAR, one volume for JDBC drivers, and JAVA_OPTS as the escape hatch for JVM flags such as a custom truststore. Note that the batch indexer is published on Docker Hub under alfresco/, whereas the Enterprise live-indexing images live on quay.io/alfresco/.
| Component | Image and tag |
|---|---|
| Repository | alfresco/alfresco-content-repository-community:26.2.0 |
| Batch indexer | alfresco/alfresco-elasticsearch-batch-indexing:5.7.1 |
| Transform Core | alfresco/alfresco-transform-core-aio:5.4.3 |
| Search engine | opensearchproject/opensearch:2.11.1 |
| Search engine UI (optional) | opensearchproject/opensearch-dashboards:2.11.1 |
| Database | postgres:15.6 |
| Solr 6, if you keep it during migration | alfresco/alfresco-search-services:2.0.21 |
Pin the OpenSearch tag. A floating opensearchproject/opensearch:2 will drift off the one supported patch release, 2.11.1.
If you just want a running 26.2 stack with OpenSearch to poke at, the generator does all of the above for you:
npm install -g yo generator-alfresco-docker-installer
yo alfresco-docker-installer \
--acsVersion=26.2 \
--searchType=opensearch \
--ram=16 \
--serverName=localhost \
--port=8080
docker compose up -d --build
That produces a full stack: repository, Share, Content App, Transform Core, PostgreSQL, OpenSearch, OpenSearch Dashboards, the batch indexer, and an nginx proxy. Two details from the generated output that are instructive in themselves. The proxy explicitly blocks the text-extraction endpoint from outside:
location ~ ^(/.*/service/api/solr/.*)$ { return 403; }
And OpenSearch itself is not published to the host at all, so the way in is OpenSearch Dashboards on port 5601 (/app/dev_tools#/console), which is a much nicer place to explore the index than curl anyway.
The documented invocation, with credentials on the command line so they are not left on the filesystem:
java -jar alfresco-elasticsearch-batch-indexing-5.7.1-app.jar \
--spring.elasticsearch.uris=http://localhost:9200 \
--spring.datasource.url=jdbc:postgresql://localhost:5432/alfresco \
--spring.datasource.username=alfresco \
--spring.datasource.password=<database-password> \
--alfresco.acs.url=http://localhost:8080 \
--alfresco.acceptedContentMediaTypesCache.baseurl=http://localhost:8090/transform/config \
--alfresco.content.transform.sharedSecret=<shared-secret>
Six arguments and you are indexing. The one that has no sane default is sharedSecret, and it has to match solr.sharedSecret in the repository or every content fetch returns 401.
A complete, working minimal stack. Replace the two placeholder secrets before using it anywhere real:
services:
postgres:
image: postgres:15.6
environment:
POSTGRES_USER: alfresco
POSTGRES_PASSWORD: <database-password>
POSTGRES_DB: alfresco
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U alfresco"]
interval: 10s
retries: 5
transform-core-aio:
image: alfresco/alfresco-transform-core-aio:5.4.3
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8090/ready"]
interval: 15s
retries: 10
start_period: 30s
opensearch:
image: opensearchproject/opensearch:2.11.1
environment:
discovery.type: single-node
DISABLE_SECURITY_PLUGIN: "true"
OPENSEARCH_JAVA_OPTS: -Xms1g -Xmx1g
volumes:
- opensearch-data:/usr/share/opensearch/data
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
interval: 30s
retries: 5
start_period: 1m
alfresco:
image: alfresco/alfresco-content-repository-community:26.2.0
depends_on:
postgres:
condition: service_healthy
transform-core-aio:
condition: service_healthy
environment:
JAVA_OPTS: >-
-Ddb.driver=org.postgresql.Driver
-Ddb.username=alfresco
-Ddb.password=<database-password>
-Ddb.url=jdbc:postgresql://postgres:5432/alfresco
-Dindex.subsystem.name=elasticsearch
-Delasticsearch.host=opensearch
-Delasticsearch.port=9200
-Delasticsearch.indexName=alfresco
-Delasticsearch.createIndexIfNotExists=true
-Dsolr.secureComms=secret
-Dsolr.sharedSecret=<shared-secret>
-DlocalTransform.core-aio.url=http://transform-core-aio:8090/
volumes:
- alf-data:/usr/local/tomcat/alf_data
ports:
- "8080:8080"
batch-indexer:
image: alfresco/alfresco-elasticsearch-batch-indexing:5.7.1
depends_on:
alfresco:
condition: service_started
opensearch:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/alfresco
SPRING_DATASOURCE_USERNAME: alfresco
SPRING_DATASOURCE_PASSWORD: <database-password>
SPRING_ELASTICSEARCH_URIS: http://opensearch:9200
ELASTICSEARCH_INDEXNAME: alfresco
ALFRESCO_ACS_URL: http://alfresco:8080
ALFRESCO_ACCEPTEDCONTENTMEDIATYPESCACHE_BASEURL: http://transform-core-aio:8090/transform/config
ALFRESCO_CONTENT_TRANSFORM_SHAREDSECRET: <shared-secret>
ports:
- "8091:8080"
volumes:
db-data:
alf-data:
opensearch-data:
Then:
docker compose up -d
curl "http://localhost:9200/_cat/indices?v&expand_wildcards=all"
docker compose logs -f batch-indexer
Two things in that file that are not obvious.
solr.secureComms=secretis mandatory, not optional: the X509 filter on the text-extraction endpoint no longer acceptsnonein 26.2, so leaving it unset breaks content indexing while metadata keeps working. AndDISABLE_SECURITY_PLUGINon OpenSearch is a completely separate concern fromsolr.secureComms; the first is about the search cluster's own authentication, the second is about the repository's endpoint. Turning one on does not affect the other.
These go in alfresco-global.properties (or the Repository Admin Console). They configure the query path: how Content Services talks to the search cluster and how it builds queries. The canonical file with all of the defaults is elasticsearch.properties in alfresco-community-repo, and unlike the indexer it is open source, so you can always check a default yourself.
| Property | Value | Description |
|---|---|---|
index.subsystem.name |
elasticsearch |
Selects Alfresco Search Community instead of solr6. This is the switch. |
solr.secureComms |
secret |
Required. Protects the text-extraction endpoint with a shared-secret header. |
solr.sharedSecret |
(no default) | Must match alfresco.content.transform.sharedSecret on the indexer. |
| Property | Default | Description |
|---|---|---|
elasticsearch.host |
localhost |
Search cluster host. |
elasticsearch.port |
9200 |
Search cluster port. |
elasticsearch.baseUrl |
/ |
Path prefix, for clusters served behind a sub-path. |
elasticsearch.indexName |
alfresco |
Main index. Must match the indexer's elasticsearch.indexName. |
elasticsearch.archive.indexName |
alfresco-archive |
Index used for deleted (archived) nodes. |
elasticsearch.createIndexIfNotExists |
false |
Create the index and its mapping at startup if absent. Note the shipped default is false, while the sample configuration in the Install documentation sets it to true. For a first deployment you want true. |
| Property | Default | Description |
|---|---|---|
elasticsearch.secureComms |
none |
none for plain HTTP, https for one-way TLS. With https the server certificate is verified against the repository truststore (encryption.ssl.truststore.*). |
elasticsearch.ssl.host.name.verification |
false |
Set to false when the server certificate has no CN matching the DNS hostname. |
elasticsearch.auth.mode |
basic |
basic or aws-iam. These are the only two values. |
elasticsearch.user |
(empty) | HTTP Basic user name. |
elasticsearch.password |
(empty) | HTTP Basic password. |
elasticsearch.aws.region |
(empty) | AWS region for SigV4 signing when auth.mode=aws-iam. |
elasticsearch.aws.service |
es |
AWS signing service name. |
| Property | Default |
|---|---|
elasticsearch.max.total.connections |
30 |
elasticsearch.max.host.connections |
30 |
elasticsearch.http.socket.timeout |
30000 ms |
elasticsearch.http.connection.timeout |
1000 ms |
elasticsearch.http.response.timeout |
30000 ms |
elasticsearch.retryPeriodSeconds |
10 |
elasticsearch.retryAttempts |
3 |
elasticsearch.lockRetryAttempts |
3 |
elasticsearch.lockRetryPeriodSeconds |
10 |
| Property | Default | Description |
|---|---|---|
elasticsearch.index.locale |
(empty) | Locale used to select the language analyzer for text fields. Set it to your primary content language for better tokenization and matching. |
elasticsearch.index.custom.analyzer.config.files |
(empty) | Comma-separated file: or classpath: locations for custom analyzer definitions. |
| Property | Default | Description |
|---|---|---|
elasticsearch.index.mapping.total_fields.limit |
7500 |
Maximum number of fields in the index mapping. |
elasticsearch.index.max_result_window |
10000 |
Maximum results a single query can return. |
elasticsearch.scroll.api_time |
10s |
Scroll context lifetime. |
elasticsearch.scroll.batch_size |
100 |
Documents per scroll batch. |
elasticsearch.defaultFacetLimit |
100 |
Maximum facets returned by a single query. |
elasticsearch.query.analyzeWildcard.fields |
cm:content,cm:persondescription,cm:preferenceValues,cm:tagScopeCache,sys:keyStore,sys:versionEdition,sys:versionProperties |
Fields where wildcard terms are analyzed. Fields using the locale_content analyzer. See the query-compatibility section, because this list has real consequences. |
elasticsearch.query.includeGroupsForRoleAdmin |
false |
Whether admin queries expand group membership. |
| Property | Default | Description |
|---|---|---|
query.fts.queryConsistency |
TRANSACTIONAL_IF_POSSIBLE |
Runs an AFTS query against the database when it is eligible, and against the search cluster otherwise. |
query.cmis.queryConsistency |
TRANSACTIONAL_IF_POSSIBLE |
The same for CMIS. |
query.hybrid.enabled |
false |
Hybrid database/index execution. |
This is Transactional Metadata Query, and it is unchanged from solr6. It matters here for a debugging reason: a metadata-only query can be answered entirely from the database and never reach OpenSearch at all. If you are testing whether the index behaves a certain way, a plain metadata query may be a false negative. The switching logic skips the database path when the request has faceting, so attaching any facet forces index execution. That is exactly the trick the compatibility test script described later relies on.
| Property | Default |
|---|---|
system.acl.maxPermissionCheckEnabled |
false |
system.acl.maxPermissionChecks |
1000 |
system.acl.maxPermissionCheckTimeMillis |
10000 |
Worth knowing because "the query returns fewer results than I expected" is usually either permission filtering or these caps.
| Property | Default | Description |
|---|---|---|
search.solrTrackingSupport.enabled |
false |
Under the elasticsearch subsystem this defaults to false, which disables the tracking endpoints Solr uses to pull changes. Set it to true during a migration if you want to keep a Solr instance current as a rollback target. |
search.solrTrackingSupport.ignorePathsForSpecificTypes |
false |
Legacy tracking tuning. |
search.solrTrackingSupport.ignorePathsForSpecificAspects |
false |
Legacy tracking tuning. |
These configure the indexing path. Every one of them can be supplied three ways, and this is where a lot of confusion comes from, so it is worth stating the rule once.
One property, three spellings. The indexer is a Spring Boot application, so relaxed binding applies: to turn a property name into an environment variable, uppercase it, replace
.with_, and delete any-. Soalfresco.reindex.continuous.maxWindowisALFRESCO_REINDEX_CONTINUOUS_MAXWINDOW, andalfresco.accepted-content-media-types-cache.base-urlisALFRESCO_ACCEPTEDCONTENTMEDIATYPESCACHE_BASEURL. The documentation shows the kebab-case form in the property table, the camelCase form on the command line, and the uppercase form in Compose. They are all the same property, not three different ones.
| Property | Default | Description |
|---|---|---|
spring.datasource.url |
jdbc:postgresql://localhost:5432/alfresco |
Repository database URL. |
spring.datasource.username |
alfresco |
Use a read-only user. The indexer never writes here. |
spring.datasource.password |
alfresco |
Override this. The shipped default is a placeholder. |
spring.datasource.hikari.maximumPoolSize |
20 |
Connection pool ceiling. |
alfresco.db.minimum.schema.version |
14002 |
Refuses to start against an older repository schema. |
| Property | Default | Description |
|---|---|---|
spring.elasticsearch.uris |
http://localhost:9200 |
Comma-separated node URIs. Requests are load-balanced across them and fail over when a node is unavailable. Credentials may be embedded, for example https://user:password@host:9200. Use this property for new configurations. spring.elasticsearch.rest.uris is a legacy alias that still works, and it is what a lot of older Compose files use. |
spring.elasticsearch.username |
(empty) | HTTP Basic user name. |
spring.elasticsearch.password |
(empty) | HTTP Basic password. |
spring.elasticsearch.auth-mode |
basic |
basic or aws-iam. An unsupported value fails fast at property binding. |
spring.elasticsearch.aws-region |
(empty) | Region for SigV4 signing when auth-mode=aws-iam. |
spring.elasticsearch.aws-service |
es |
AWS signing service name. |
spring.elasticsearch.path-prefix |
(empty) | Path prefix for clusters behind a sub-path. |
spring.elasticsearch.restclient.sniffer.enabled |
false |
Automatic node discovery. See the warning below. |
elasticsearch.indexName |
alfresco |
Must match the repository's elasticsearch.indexName. |
The sniffing trap. Sniffing is off by default for a good reason. When enabled, the client discards the URIs you configured and replaces them with the addresses the cluster advertises. In a container network those advertised addresses are frequently unreachable, so the connection succeeds, then fails immediately after discovery. Leave it off unless you have verified the cluster publishes routable addresses.
The most important table in this post. Durations accept 30s, 10m, 24h style values.
Property (prefix alfresco.reindex.continuous.) |
Default | Description |
|---|---|---|
autoStart |
true |
Start scheduled indexing at application startup. Set false to deploy without indexing yet. |
pollingInterval |
30s |
Fixed delay between completed cycles. Lower means fresher results and more load on the database and the cluster. |
maxPollingInterval |
5m |
Upper bound for exponential backoff after consecutive failures. |
catchUpPollingInterval |
1s |
Delay between chunks while working through a backlog. |
overlap |
10m |
Look-back applied to every cycle so boundary changes are not missed. |
maxWindow |
30m |
Maximum time span a single cycle processes. Larger gaps are processed in chunks of this size. |
maxGapAge |
24h |
Maximum age for automatic gap recovery. Anything older is skipped with a warning. 0 removes the cap, which is what you need for a full initial index. |
watermarkIndexName |
alfresco-reindex-state |
Index holding the cursor. |
watermarkRetryMaxAttempts |
3 |
Retries for cursor reads and writes. |
watermarkRetryDelay |
2s |
Initial delay between cursor retries. |
stuckThreshold |
10m |
How long the scheduler may be silent (no cycle start, no chunk commit, no cycle finish) before the liveness endpoint reports DOWN. Must be greater than the worst-case single-chunk wall-clock time, otherwise a legitimate backoff triggers a spurious restart. The application validates that it exceeds maxPollingInterval and refuses to start if it does not. |
autoCreateStateIndex |
true |
Create the state index at startup if missing. Set false to refuse to start when the state index has vanished, which protects you from an accidental full rescan. |
bootstrapFromAlfrescoIndex |
true |
When there is no cursor, seed it from the latest indexed change in the main index, minus the overlap. |
requireAlfrescoIndex |
false |
When the cursor records real progress but the main index is missing, refuse to start rather than silently re-indexing nothing. |
Those last three are the guard rails, and they deserve more attention than they usually get. The failure mode they prevent is quiet and expensive: the state index disappears, the indexer helpfully recreates it, starts from now - overlap, and you have a silently stale index that looks healthy. Setting autoCreateStateIndex=false in production turns that into a loud startup failure with an actionable message instead.
| Property | Default | Description |
|---|---|---|
alfresco.reindex.metadataIndexingEnabled |
true |
Index node metadata. |
alfresco.reindex.contentIndexingEnabled |
true |
Index extracted full text. Turning this off removes all Transform Core dependency. |
alfresco.reindex.pathIndexingEnabled |
true |
Index paths, which is what makes PATH and site-scoped queries work. |
alfresco.reindex.pathCacheSize |
10000 |
Path resolution cache size. |
| Property | Default | Description |
|---|---|---|
alfresco.reindex.jobName |
reindexByDate |
The Spring Batch job name. You will see it in the logs. |
alfresco.reindex.batchSize |
1000 |
Items per batch write. |
alfresco.reindex.pagesize |
1000 |
Rows read per database page. Note the lowercase s, unlike every sibling property. |
alfresco.reindex.concurrentProcessors |
10 |
Worker threads for the multi-threaded step. |
alfresco.reindex.multithreadedStepEnabled |
true |
Spread chunks across worker threads. |
alfresco.reindex.skipLimit |
100 |
How many items a cycle may skip before the step fails. Skipped items land in the dead-letter index. The shared reindexing module defaults this to 0; the batch indexer opts in to 100 so that a single poison node cannot stall continuous indexing. |
alfresco.reindex.retryingEnabled |
true |
Retry transient failures. |
alfresco.reindex.retryingMaxCount |
3 |
Retry attempts. |
alfresco.reindex.retryingInitialDelay |
1000 ms |
First backoff delay. |
alfresco.reindex.retryingDelayIntervalMultiplier |
2 |
Backoff multiplier. |
alfresco.reindex.retryingMaxDelay |
30000 ms |
Backoff ceiling. |
alfresco.reindex.writerRetryCount |
3 |
Retries for an individual bulk write. |
alfresco.reindex.writerRetryDelay |
1000 ms |
Delay between bulk-write retries. |
| Property | Default | Description |
|---|---|---|
alfresco.acs.url |
http://localhost:8080 |
Base URL of Content Services. |
alfresco.content.transform.urlPath |
/alfresco/service/api/solr/textContent |
The text-extraction endpoint. |
alfresco.content.transform.sharedSecret |
(empty) | Must match solr.sharedSecret. Sent as X-Alfresco-Search-Secret. |
alfresco.content.transform.timeout |
20s |
Per-request timeout. |
alfresco.content.transform.retryMaxAttempts |
2 |
Retries per content fetch. |
alfresco.content.transform.retryDelay |
1s |
Delay between those retries. |
alfresco.content.transform.writeConcurrency |
16 |
Parallel content fetches per chunk, on a dedicated fork-join pool. Sized to roughly match Transform Core's worker count. |
alfresco.content.transform.maxResponseSizeMb |
10 |
Caps the extracted-text response buffer. See the warning below. |
alfresco.accepted-content-media-types-cache.base-url |
http://localhost:8090/transform/config |
Transform Core config endpoint, used to learn which media types can be transformed. |
alfresco.accepted-content-media-types-cache.enabled |
true |
Cache that response. |
alfresco.cache.timeout.seconds |
120 |
Metadata cache lifetime. |
The 10 MB text cap is worth knowing about.
maxResponseSizeMblimits the extracted text, not the original file. A 400 MB video is fine because its text extraction is tiny; a 30 MB plain-text log file is not. Documents whose extracted text exceeds the cap keep failing on every overlap and stay silently absent from full-text search, while their metadata and path index normally, so they still appear in metadata queries. Those give-ups are recorded in the dead-letter index perdbId, which is how you find them. Worst-case in-flight memory iswriteConcurrencymultiplied bymaxResponseSizeMb, so raising both at once is how you run the indexer out of heap.
| Property | Default | Description |
|---|---|---|
alfresco.reindex.dead-letter.enabled |
true |
Record give-up events. Writes are best effort and rate-limited to one per minute per failure type, so a broken dead-letter index never blocks indexing. |
alfresco.reindex.dead-letter.indexName |
alfresco-reindex-dead-letter |
Index name. |
alfresco.reindex.failOnMissingTag |
true |
Tag handling. |
alfresco.reindex.addEmptyTagAttribute |
true |
Tag handling. |
alfresco.reindex.removeTaggableAttribute |
true |
Tag handling. |
alfresco.metrics.reindex.max-retained-jobs |
50 |
How many recent cycles keep their per-cycle metrics. A new cycle starts every polling interval, so this bounds meter growth. 0 or negative retains all cycles, unbounded. |
server.port |
8080 |
Actuator port. |
management.endpoints.web.exposure.include |
health,info,prometheus,metrics |
Exposed Actuator endpoints. |
management.endpoint.health.show-details |
never |
Health detail visibility. |
Start from an honest baseline: the reference deployment is not hardened. The Compose files that ship as examples, and the ones the installer generates, run OpenSearch with DISABLE_SECURITY_PLUGIN=true. The only authentication actually configured out of the box is the shared-secret header protecting the repository's text-extraction endpoint. That is fine for a laptop and not fine for anything else.
Here is what is genuinely available, separated from what is not, because this is an area where it is easy to assume more than the product offers.
| Option | Repository side | Batch indexer side |
|---|---|---|
| Plain HTTP | elasticsearch.secureComms=none |
http:// in spring.elasticsearch.uris |
| One-way TLS | elasticsearch.secureComms=https, server certificate verified against the repository truststore (encryption.ssl.truststore.*) |
https:// in spring.elasticsearch.uris, trust configured through the JVM (-Djavax.net.ssl.trustStore) |
| HTTP Basic | elasticsearch.user and elasticsearch.password |
spring.elasticsearch.username and password, or credentials embedded in the URI |
| AWS SigV4 | elasticsearch.auth.mode=aws-iam plus elasticsearch.aws.region and elasticsearch.aws.service |
spring.elasticsearch.auth-mode=aws-iam plus aws-region and aws-service |
What is not there. The authentication mode enum has exactly two values,
basicandaws-iam, on both sides. There is no JWT or bearer-token configuration anywhere in the batch indexer or in the repository subsystem, so if your OpenSearch cluster authenticates via an identity provider token you will need a proxy in front of it. mTLS is not a configurable auth mode either; the client inherits mTLS support from the shared indexing library, and in practice you configure it with JVM keystore and truststore flags rather than with application properties. LDAP and Active Directory need no special client configuration at all, because they end up as a user name and a password over Basic auth, but they do require user synchronisation on the Alfresco side so that permission filtering resolves the right authorities.
If you want the security plugin on and TLS 1.3 enforced, this is the shape of it. On the OpenSearch side, a config fragment appended at container start:
plugins.security.ssl.http.enabled: true
plugins.security.ssl.http.enabled_protocols:
- TLSv1.3
plugins.security.allow_unsafe_democertificates: false
plugins.security.authcz.admin_dn: [ CN=admin ]
plugins.security.nodes_dn: [ CN=opensearch ]
opensearch:
image: opensearchproject/opensearch:2.11.1
environment:
DISABLE_INSTALL_DEMO_CONFIG: "true"
OPENSEARCH_JAVA_OPTS: -Xms1g -Xmx1g
command:
- sh
- -c
- "cat /usr/share/opensearch/config/opensearch-security-tls.yml
>> /usr/share/opensearch/config/opensearch.yml &&
/usr/share/opensearch/opensearch-docker-entrypoint.sh"
On the repository side, point the subsystem at HTTPS and import the CA into a JCEKS truststore:
-Delasticsearch.secureComms=https
-Delasticsearch.ssl.host.name.verification=false
-Delasticsearch.user=admin
-Delasticsearch.password=<opensearch-password>
-Dencryption.ssl.truststore.location=/usr/local/tomcat/shared/classes/alfresco/extension/ssl-keystore/ssl.truststore
-Dencryption.ssl.truststore.type=JCEKS
keytool -importcert -noprompt -alias root-ca -file root-ca.pem \
-keystore ssl.truststore -storetype JCEKS -storepass <truststore-password>
And on the indexer, credentials in the URI plus a JKS truststore passed through JAVA_TOOL_OPTIONS:
batch-indexer:
image: alfresco/alfresco-elasticsearch-batch-indexing:5.7.1
environment:
SPRING_ELASTICSEARCH_URIS: https://admin:<opensearch-password>@opensearch:9200
JAVA_TOOL_OPTIONS: >-
-Djavax.net.ssl.trustStore=/certs/batch-indexer-truststore.jks
-Djavax.net.ssl.trustStorePassword=<truststore-password>
volumes:
- ./certs:/certs:ro
Then prove it, in both directions. A TLS 1.3 request must succeed and a TLS 1.2 request must be refused. If the second one succeeds, your protocol restriction is not actually applied:
# must work
curl -sk -u admin:'<opensearch-password>' --tlsv1.3 \
https://localhost:9200/_cluster/health
# must fail with a protocol version alert
curl -sk -u admin:'<opensearch-password>' --tlsv1.2 --tls-max 1.2 \
https://localhost:9200/_cluster/health
application.properties on disk.The two products share a source base, and that is the direct reason the Community source is not published: it reuses Enterprise code. But sharing a subsystem does not mean sharing an indexing mechanism, and the difference is fundamental.
| Alfresco Search Enterprise | Alfresco Search Community | |
|---|---|---|
| Mechanism | Live indexing, event-driven | Batch indexing, polling |
| Transport | ActiveMQ events from the repository | Read-only JDBC against the repository database |
| Unit of work | Nodes | Transactions and database identifiers, processed incrementally by time window |
| Latency | Near real time | One polling interval, 30 seconds by default |
| Query path | Same elasticsearch subsystem |
Identical: same query languages, same field support, same limitations |
The query path being identical is the good news: everything you know about querying Alfresco Search Enterprise transfers directly. The indexing path being different is where the operational differences live, and one of them deserves its own callout.
Only one indexer may write to a cluster at a time. The application uses optimistic concurrency control on the cursor document as a lock. If a second instance detects a conflicting write, it terminates rather than continuing with interleaved writes. So you cannot scale indexing horizontally, and you cannot run active-active. For Kubernetes this means a single replica, and it means a rolling update briefly has no indexer running, which is harmless because the cursor guarantees the new pod resumes exactly where the old one stopped.
There is also one asymmetry in the query surface that is genuinely surprising given the shared code: the fields that describe the indexing mechanism itself, DBID, TXID, TXCOMMITTIME and friends, are not queryable, even though the Community mechanism is the one that works in terms of transactions and database identifiers. More on that next.
When the indexer starts, or resumes after an outage, it compares the cursor against the present and picks one of three behaviours:
service starts / resumes after an outage
|
v
read the last successful cursor
|
v
how big is the gap?
|
+-------------+-------------------+---------------------------+
| | |
gap <= maxWindow gap <= maxGapAge gap > maxGapAge
(30m) (24h) (24h)
| | |
v v v
one normal window catch-up mode: FAST-FORWARD to
every pollingInterval maxWindow chunks now - maxGapAge,
(30s) back to back every log a warning,
catchUpPollingInterval and SKIP the
(1s) intervening history
| | |
+---------------+-----------------+---------------------------+
|
v
cursor reaches the present
Treat
maxGapAgeas a data-loss boundary. If the indexer has been down longer thanmaxGapAge(24 hours by default), it does not catch up. It fast-forwards tonow - maxGapAge, logs a warning, and everything in the skipped interval is simply never indexed. Nothing errors, nothing retries, and the index looks healthy. Set it wide enough to cover a realistic worst-case outage, including a long weekend, and alert on the warning.
Spring Boot Actuator on port 8080:
| Endpoint | Purpose |
|---|---|
/actuator/health |
Overall health. |
/actuator/health/liveness |
Liveness probe. Includes continuousReindexingMonitor, so a scheduler stuck for longer than stuckThreshold reports DOWN and Kubernetes restarts the pod. |
/actuator/info |
Build information. |
/actuator/metrics |
Application and JVM metrics. |
/actuator/prometheus |
The same metrics in Prometheus text format, for scraping. |
The single most useful thing to monitor is not any of those, though. It is whether the cursor is keeping up:
curl -s http://localhost:9200/alfresco-reindex-state/_doc/reindexByDate-watermark \
| python3 -m json.tool
That document is the whole state of the system. Its fields are lastSuccessfulFromTimeEpochMs, lastSuccessfulToTimeEpochMs, lastRunStatus, lastRunReadCount, lastRunWriteCount, lastRunSkipCount and updatedAt. If lastSuccessfulToTimeEpochMs stops closing on the present, you have a problem, and the size of the gap is your indexing lag in milliseconds. Alert on that.
Every give-up is recorded in alfresco-reindex-dead-letter, keyed by node or by failed window, with the failure stage (reader, processor, or one of the writers), the failure type, a failure count, and first and last failure times. Note that the index is created with no mapping at all, so it looks empty and schemaless until the first failure arrives and OpenSearch infers a schema.
Recovery is a manual task. There is no retry endpoint and no automatic replay. You fix the upstream cause and let a later cycle re-index the affected nodes. In practice that means the dead-letter index is an audit trail that outlives log rotation and container restarts, which is exactly what it was designed to be.
INFO by default. Watch for startup, cursor bootstrap, and cycle-completion messages. Raise org.alfresco.elasticsearch.batchindexing to DEBUG only for incident triage: debug output includes node identifiers and window bounds, but never content.A useful marker while a backlog is being processed: catch-up cycles log [chunked gap recovery - more chunks pending], so docker compose logs -f batch-indexer | grep "reindexByDate cycle" gives you a live view of progress through history.
Two facts to set expectations before anything else. There is no data migration between Solr and OpenSearch. The schemas have nothing in common and no conversion tool exists. The path is always a full reindex. And the default configuration will not do it for you: as covered above, a fresh indexer starts at now - overlap and never looks at history. Getting ten years of repository into a new index is a deliberate, manual procedure.
1. Provision the OpenSearch / Elasticsearch cluster
(+ back up the repository database and the ACS configuration)
|
v
2. Configure the elasticsearch subsystem in Content Services
index.subsystem.name=elasticsearch
|
v
3. Deploy the batch-indexing application
|
v
4. Perform the initial index of the existing repository
maxGapAge=0, seed the cursor, process history in maxWindow chunks
|
v
5. Verify and complete the switch
cursor at the present, all three document types present,
dead-letter reviewed, then restore maxGapAge=24h
The ordering of steps 2 and 3 is not arbitrary, and this is the part that is easy to get wrong. The index and its mapping are created by Content Services, not by the indexer: elasticsearch.createIndexIfNotExists and the ContentModelSynchronizer both live in the repository's elasticsearch subsystem. So the subsystem has to be active before the indexer has anything to write into.
The consequence is that from step 2 onwards, searches are served from an index that is still catching up, and results will be incomplete until step 4 finishes. You have two ways to handle that: run the whole thing in a maintenance window, or keep Solr alive as a rollback target.
Remember that search.solrTrackingSupport.enabled defaults to false under the elasticsearch subsystem. That default quietly kills the tracking endpoints Solr polls, so if you leave it alone, your Solr index starts going stale the moment you switch subsystems, and by the time you discover a problem the rollback target is useless. Force it on for the duration of the migration:
-Dindex.subsystem.name=elasticsearch
-Dsearch.solrTrackingSupport.enabled=true
Now both engines are live: OpenSearch answers queries while it catches up, and Solr keeps tracking so it stays current. If you need to roll back, point index.subsystem.name at solr6 and restart. One caveat: while you are on solr6, the content-model synchronizer is not running, so content-model changes made during that period will not reach the OpenSearch mapping.
This is the step the official documentation describes in prose but does not give you an API call for, so here it is. Two properties and one HTTP request.
First, remove the gap-age cap, otherwise the historical cursor you are about to write is older than maxGapAge and gets discarded on the very first cycle:
ALFRESCO_REINDEX_CONTINUOUS_MAXGAPAGE: 0
Then write a starting cursor. The simple version, using a fixed date safely before your repository existed:
# 1262304000000 = 2010-01-01T00:00:00Z
curl -X PUT "$OS/alfresco-reindex-state/_doc/reindexByDate-watermark" \
-H 'Content-Type: application/json' \
-d '{"schemaVersion":1,"lastSuccessfulToTimeEpochMs":1262304000000}'
Do not use a fixed date on a real repository. Catch-up walks the calendar, not the documents: one
maxWindowchunk per cycle regardless of whether that window contains any changes. Seed at 2010 with the default 30 minute window and you have committed to roughly 280,000 cycles of empty windows before the indexer reaches 2026. Seed from the repository's own first transaction instead, and the catch-up lasts in proportion to the history that actually exists.
The better version derives the seed from the database, and is idempotent so that a re-run never restarts a migration in progress:
set -e
# The earliest real transaction in the repository, minus 1ms so the
# first window includes it. This is the same column the indexer walks.
seed=$(psql -h postgres -U alfresco -d alfresco -tAc \
"SELECT COALESCE(MIN(commit_time_ms), -1) FROM alf_transaction;")
if [ "$seed" -lt 0 ]; then
echo "Repository has no transactions: nothing to seed."
exit 0
fi
seed=$((seed - 1))
# Create the state index hidden, as the application would.
curl -s -o /dev/null -X PUT "$OS/alfresco-reindex-state" \
-H 'Content-Type: application/json' \
-d '{"settings":{"index.hidden":true}}'
# _create, not _doc: 201 means seeded, 409 means a cursor already
# exists and must not be overwritten.
code=$(curl -s -o /dev/null -w '%{http_code}' \
-X PUT "$OS/alfresco-reindex-state/_create/reindexByDate-watermark" \
-H 'Content-Type: application/json' \
-d "{\"schemaVersion\":1,\"lastSuccessfulToTimeEpochMs\":$seed,\"lastRunStatus\":\"SEEDED\"}")
case "$code" in
201) echo "Cursor seeded at $seed" ;;
409) echo "Cursor already exists, preserved" ;;
*) echo "Failed to seed cursor (HTTP $code)"; exit 1 ;;
esac
For a repository with years of history, raise the window so you are not spending a cycle per half hour of calendar time. MAX_WINDOW=7d is a reasonable starting point, and the documentation also suggests sizing batchSize and concurrentProcessors up for the initial load, since it reads and writes far more than steady state.
Attaching to an already-populated index? Then do not seed anything. Leave
bootstrapFromAlfrescoIndexat its default oftrueand the cursor resumes near the existing index progress instead of re-indexing from the start.
Four checks, in order:
lastSuccessfulToTimeEpochMs stops trailing and starts tracking within one polling interval of now.Some commands for those:
# cursor position
curl -s "$OS/alfresco-reindex-state/_doc/reindexByDate-watermark" | python3 -m json.tool
# document count in the main index
curl -s "$OS/alfresco/_count"
# anything in the dead-letter index?
curl -s "$OS/alfresco-reindex-dead-letter/_search?size=20&expand_wildcards=all"
# a real search, through Content Services
curl -s -u admin:admin -X POST \
"http://localhost:8080/alfresco/api/-default-/public/search/versions/1/search" \
-H 'Content-Type: application/json' \
-d '{"query":{"language":"afts","query":"budget"}}'
Then return the scheduling properties to steady state, which mainly means putting the gap cap back:
ALFRESCO_REINDEX_CONTINUOUS_MAXGAPAGE: 24h
ALFRESCO_REINDEX_CONTINUOUS_MAXWINDOW: 30m
And only then stop Solr and set search.solrTrackingSupport.enabled back to its default.
Do not skip the volumes. Phase 2 recreates the repository container. If
alf_datawas not on a named volume, the content store is gone while the database still references it, and you getCONTENT INTEGRITY ERRORon every node. This is a very easy mistake to make when the migration is rehearsed with Compose.
For scale: a demo repository of roughly 870 nodes catches up in about a minute. Real numbers depend almost entirely on how much text there is to extract, because content transformation, not indexing, is the bottleneck.
Mostly yes, and the exceptions matter more than the count suggests. AFTS, Lucene and CMIS are all supported on the elasticsearch subsystem. What changes is the set of pseudo-fields available, and, critically, how the system behaves when you use one that is not.
| Supported | Silently ignored |
|---|---|
TYPE, EXACTTYPE, ASPECT, EXACTASPECT, CLASS, PATH, ANCESTOR, PARENT, PRIMARYPARENT, TEXT, ALL, ID, ISNODE, ISNOTNULL, EXISTS, ISNULL, ISUNSET, OWNER, READER, DENIED, AUTHORITY, TAG, SITE |
PATHWITHREPEATS, PNAME, NPATH, QNAME, PRIMARYASSOCQNAME, PRIMARYASSOCTYPEQNAME, OWNERSET, READERSET, DENYSET, AUTHORITYSET (AUTHSET), DBID, TX, TXID, INTXID, TXCOMMITTIME, ACLID, INACLTXID, ACLTXID, ACLTXCOMMITTIME, FTSSTATUS, ISROOT, ISCONTAINER, TENANT, FINGERPRINT, id, CASCADETX |
A few of the supported ones come with caveats: CLASS works for term queries only, PATH silently ignores invalid XPath, and ANCESTOR and PARENT do not support category paths. TEXT expands to cm:name, cm:title, cm:description and cm:content. Three more fields, ASSOCTYPEQNAME, ISCATEGORY and LINKASPECT, are not special-cased at all and get treated as ordinary property names.
The loss of FINGERPRINT is the one most likely to break a feature rather than a query: that is document-similarity search, and there is no equivalent.
This is the single most important thing in this post. An unsupported condition does not raise an error. The parser accepts it, because the AFTS and CMIS grammars are shared between both subsystems, and then the query builder returns
nullfor that condition, logs a WARN, and the query proceeds without it. So a query written to narrow a result set can silently widen it instead.banana AND TXCOMMITTIME:taxireturns every document matchingbanana, with HTTP 200 and no indication that half of your query was discarded.
Think about what that means for anything that uses search as a filter: a rule, a scheduled job, a report, a permission-adjacent listing. Under solr6 a broken query returned nothing and somebody noticed. Under elasticsearch it returns too much and nobody does.
The only warning you get is in the repository log, so grep for it:
docker compose logs -f alfresco \
| grep -E "Ignoring query condition|Ignorning sort on field"
The second pattern is misspelled in the product source, so match both spellings rather than the correct one.
Because silent drops return HTTP 200, you cannot detect them from a single response. You have to test differentially: run a baseline query and a supposedly narrower one, and if the counts are identical, the restriction was dropped. There is a companion script for this that runs 25 documented cases as 28 checks against a live instance, grouped into loud failures, silent drops, missing response sections, and constrained behaviour. Two traps it has to work around are worth knowing about even if you write your own: every probe attaches a facet, because otherwise Transactional Metadata Query answers from the database and never exercises the index at all; and every differential check asserts a non-zero baseline first, because zero-versus-zero proves nothing.
| Construct | Example | Status on elasticsearch |
|---|---|---|
| Wildcard | budg*, budge? |
Works, but only analyzed on the fields listed in elasticsearch.query.analyzeWildcard.fields. Rejected by design on a long list of fields including PATH, ANCESTOR, PARENT, QNAME, DBID and CLASS. |
| Prefix | budg* as a prefix term |
Same reject list as wildcard. Works on TYPE, EXACTTYPE, ASPECT, EXACTASPECT, SITE, TEXT, ALL and ID. |
| Fuzzy | budget~0.8 |
Rejects more than prefix does: the prefix list plus SITE, TYPE, EXACTTYPE, ASPECT, EXACTASPECT and ISNODE. |
Fuzzy on CLASS |
CLASS:cm\:content~0.8 |
Throws. The one construct that fails loudly instead of being dropped. |
| Range | cm:created:[2024 TO 2025] |
Works on TEXT, ALL, resolvable properties and datatypes. Everything else is silently ignored. |
| Exact term | =running |
Disabled by default, as it is on Solr. Requires enabling datatypes or properties in exactTermSearch.properties, and because that changes the index mapping, it requires a reindex. |
| Regexp | /bud.*/ |
Never works. Always returns "Regexp queries are not supported". |
| Phrase, boolean, proximity, boost, date math | "quarterly budget"~2, budget^4, cm:modified:[NOW/DAY-7DAYS TO NOW/DAY] |
Supported. |
Note the asymmetry in that table: TYPE accepts a prefix query but silently drops a fuzzy one. Field support and construct support are two separate lists, and a field being in one does not put it in the other.
| Feature | Status |
|---|---|
SELECT ... FROM ... WHERE |
Supported. |
CONTAINS() |
Supported, but the AFTS limits above apply inside it. |
LIKE with % and _ |
Supported. % is translated to * unless escaped. |
IN_FOLDER(), IN_TREE() |
Supported. |
ORDER BY on a property |
Supported. |
cmis:objectId with a version label |
Supported, including private working copies and unversioned nodes. |
ORDER BY on a function, for example SCORE() |
Not supported. Throws. Fails loudly. |
| Exact search on tokenized-only fields | Not supported. Throws. |
JOIN |
Parses, but join support is advertised as NONE on both subsystems, so it is not executable on either. Not a regression. |
Three language bindings are wired to UnsupportedQueryLanguage in the Community context, so they fail rather than degrade:
xpath. This is the one to audit for. Code calling searchService.query with language xpath, and selectNodes-style usage that resolves through the search subsystem, needs review before migrating. XPath internals survive only to serve PATH: conversion, and even there only a basic subset (root, absolute, relative, child, descendant steps).index-sql, the Solr SQL endpoint. Also, there is no stats bean and no suggester bean registered on this subsystem at all, so those features are simply absent.index-alfresco and the legacy solr-* aliases.Verify range facets before you rely on them. The official "Using" page states that supported facets include field facets, range facets, and query facets. The
alfresco-community-reposource disagrees on the middle one:ElasticsearchResultSet.getFacetRanges()returns an empty map unconditionally, andRangeParametersis never consumed. Facet intervals, pivot facets, stats and spellcheck likewise come back empty. Test the specific facet types your application depends on against a real instance rather than trusting either the documentation or this post.
DBID, TXID and PNAME are the common ones in custom code.language values of xpath, index-sql and index-alfresco.FINGERPRINT and for ORDER BY SCORE().Ignoring query condition. Anything that logs is a query whose meaning changed even if the test still passes.Alfresco Search Community is distributed as a binary only: a Docker image and a JAR, with the source code private. As covered earlier, the reason is that the module reuses Alfresco Search Enterprise code.
The final license text has not been approved or published yet. What follows is the announced intent, and the published text is what will actually govern your use. Check it when it appears.
As announced, the terms will permit:
And will not permit:
The stated longer-term intention is to publish the source code and license it under LGPLv3, which is the license the rest of Alfresco Community uses. No date has been committed to; 27.1 has been mentioned as a possibility for the source release.
Worth addressing because the question comes up immediately once OpenSearch is in the picture: the Hyland AI Ready Index (AIR Index) also uses OpenSearch natively, including vector indexes for embeddings. So can they share a cluster?
Infrastructure yes, data no. The AIR Index indexes are entirely independent of Alfresco's. You can point both at the same OpenSearch instance to save on infrastructure, but there is no interoperability between the data: Alfresco cannot query the vector indexes and the AIR Index does not read the alfresco index. Treat it as co-tenancy, not integration.
If you do co-locate them, three things need to line up:
3.5.0, which is newer than the 2.11.1 that Alfresco Search Community lists as supported. In practice ACS 26.2 and the batch indexer work unchanged against 3.5.0, which reports a minimum wire compatibility version of 2.19.0. That is a tested observation, not a supported configuration, so verify it for your own deployment.plugins.security.disabled: "true" or the equivalent DISABLE_SECURITY_PLUGIN.knn.derived_source bug in OpenSearch 3.5, applied to the embeddings indexes:curl -X PUT "http://localhost:9200/_index_template/nuxeo-embeddings-noderivedsource" \
-H 'Content-Type: application/json' \
--data-binary '{
"index_patterns":["nuxeo_embeddings","nuxeo_embeddings_*"],
"priority":500,
"template":{"settings":{"index":{"knn":true,"knn.derived_source.enabled":false}}}
}'
The broader point is that 26.2 moves Alfresco onto the same search substrate that the AI-facing parts of the Hyland platform already use. That does not create integration on its own, but it removes the reason there could not be any.
If you read one section, read the query compatibility one. But the short version of everything above:
elasticsearch subsystem Enterprise has used since 7.1.maxGapAge=0.maxGapAge: exceed it and history is skipped silently.You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.