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:

AccessorResolvesTypical caller
BackgroundTenantAccessorTenant id list / per-tenant configAuto-mode workers iterating tenants
BackgroundDataAccessorDynamic-table CRUD scoped by tenantId (each call binds the tenant context)Kafka consumer writing to dynamic tables
BackgroundConnectorCredentialAccessorConnector credential lookup by PIDFetch 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 tenantId when writing to dynamic tables. Every CRUD method takes tenantId as 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 create instead of tryCreate in an at-least-once consumer. Kafka / retries replay the same message; tryCreate returns Optional.empty() on a unique-constraint hit instead of throwing, which makes idempotent handling easy. create throws.
  • 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