BackgroundAccessor SPI
BackgroundAccessor is a family of three SPIs that bridge plugin code running outside an HTTP request — Kafka consumers, scheduled tasks, retry workers, crawler frontiers — back to host services that normally rely on MetaContext (tenant id, user id, language). Introduced during the Crawler V2 effort, the family follows a single pattern:
| Accessor | Resolves | Typical caller |
|---|---|---|
BackgroundTenantAccessor | Tenant id list / per-tenant config | Auto-mode workers iterating tenants |
BackgroundDataAccessor | Dynamic-table CRUD scoped by tenantId (each call binds the tenant context) | Kafka consumer writing to dynamic tables |
BackgroundConnectorCredentialAccessor | Connector credential lookup by PID | Fetch workers needing API keys / cookies |
All three are optional — plugins inject with @Autowired(required = false). When the host has not registered an implementation (older platforms or OSS-only deployments), the plugin falls back to V1 behaviour (typically: no tenant fan-out, single-tenant mode, no credential resolution).
Why optional injection
The pattern lets a plugin ship a single jar that works against both a host without a registered implementation and a host wired with TenantService. The bridge is declared by an interface in plugin-api (platform-plugin-api); an implementation is registered as a Spring bean in the host. This avoids hard-coupling plugin code to the platform's internal TenantService / DynamicDataService / ApiConnectorService classes.
Interface signatures
package com.auraboot.framework.plugin.extension;
public interface BackgroundTenantAccessor {
/** All active tenant ids. Empty list → single-tenant fallback. */
List<Long> listActiveTenantIds();
}
public interface BackgroundDataAccessor {
/**
* Each method takes tenantId as its first argument and pushes it onto the platform
* tenant context for the duration of the call. No `withTenantContext` wrapper —
* just call the CRUD method directly. Required when writing to dynamic tables:
* the platform resolves MetaContext from tenantId.
*/
Map<String, Object> create(long tenantId, String modelCode, Map<String, Object> data);
/**
* Idempotent insert. Returns Optional.empty() (instead of throwing) on a unique-constraint
* hit, so an at-least-once consumer (Kafka, retry) can replay the same logical record.
*/
Optional<Map<String, Object>> tryCreate(long tenantId, String modelCode, Map<String, Object> data);
/** Read a single record by primary key; returns null when not found. */
Map<String, Object> getById(long tenantId, String modelCode, String recordId);
/** Query by field-code → value exact match (all AND equality). */
List<Map<String, Object>> query(long tenantId, String modelCode, Map<String, Object> filters);
/** Update fields of an existing record. */
Map<String, Object> update(long tenantId, String modelCode, String recordId, Map<String, Object> data);
/** Delete a record by primary key. */
void delete(long tenantId, String modelCode, String recordId);
}
public interface BackgroundConnectorCredentialAccessor {
/**
* Look up a connector credential snapshot by PID (base URL / auth type / auth-config JSON /
* default headers). Returns Optional.empty() when the PID is unknown or no connector matches.
*/
Optional<ConnectorCredentials> lookupByPid(String connectorPid);
}Implementation example
A Kafka consumer in a plugin upserts crawled documents into a dynamic table, scoped to the tenant carried on the event:
package com.acme.crawler.consumer;
import com.auraboot.framework.plugin.extension.BackgroundDataAccessor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
public class CrawledDocumentUpsertConsumer {
@Autowired(required = false)
private BackgroundDataAccessor dataAccessor; // null on OSS
@KafkaListener(topics = "crawler.document.upsert")
public void onMessage(CrawledDocumentEvent event) {
Map<String, Object> row = Map.of(
"pid", event.getPid(),
"title", event.getTitle(),
"content_sha", event.getSha(),
"content", event.getContent()
);
if (dataAccessor != null) {
// tenantId is passed explicitly as the first argument; the platform binds the tenant context
dataAccessor.create(event.getTenantId(), "cr_crawled_document", row);
} else {
// V1 fallback: single-tenant mode, handled by the plugin's own write path
// ...
}
}
}A connector-credential example for a fetcher:
import com.auraboot.framework.plugin.extension.BackgroundConnectorCredentialAccessor;
import com.auraboot.framework.plugin.extension.BackgroundConnectorCredentialAccessor.ConnectorCredentials;
@Autowired(required = false)
private BackgroundConnectorCredentialAccessor credentials;
public Map<String, String> headersFor(String connectorPid) {
if (credentials == null) return Map.of();
return credentials.lookupByPid(connectorPid)
.map(ConnectorCredentials::getDefaultHeaders)
.orElse(Map.of());
}Registration
Host-side: register a Spring @Bean implementing the interface. Plugin-side: nothing beyond @Autowired(required = false). The platform ships default implementations backed by TenantService, MetaContext (DynamicDataService), and ApiConnectorService (BackgroundTenantAccessorImpl / BackgroundDataAccessorImpl / BackgroundConnectorCredentialAccessorImpl).
When you publish a plugin that uses one of these accessors, document the fallback behaviour in the plugin's README so operators understand what changes when they move from OSS to enterprise.
Common pitfalls
- Forgetting
required = false. The plugin fails to start on the OSS platform. - Omitting
tenantIdwhen writing to dynamic tables. Every CRUD method takestenantIdas its first argument, and the platform binds the tenant context from it; always pull the correct tenant from the event, never hard-code it. - Using
createinstead oftryCreatein an at-least-once consumer. Kafka / retries replay the same message;tryCreatereturnsOptional.empty()on a unique-constraint hit instead of throwing, which makes idempotent handling easy.createthrows. - Caching the accessor result across tenants. Each call is scoped by the passed
tenantId. Re-resolve per event; do not reuse the previous event's result. - Treating empty
listActiveTenantIds()as an error. It's the documented fallback signal (e.g. a fresh install before bootstrap) — log info, run single-tenant.
Related
- Crawler V2 design — first consumer of the SPI family
- DynamicDataService service
- EventListenerExtension — in-process equivalent for HTTP-driven events