ADR-063: Hub Storage Adapter Service

<!-- MADR 3.0 Template - Markdown Any Decision Records -->

Status

Proposed

Date

2025-12-19

Deciders

Context and Problem Statement

iDempiere Hub needs persistent storage for various services beyond just centralized ID management. Currently, each service implements its own storage mechanism, leading to:

Services requiring persistent storage include:

A generic storage adapter would provide a unified interface for all these use cases while allowing pluggable backends (PostgreSQL, AWS S3/DynamoDB, Redis, etc.).

Decision Drivers

Considered Options

  1. Service-Specific Storage - Each service manages its own storage
  2. Generic Key-Value Adapter - Unified interface with pluggable backends
  3. Full ORM Integration - Use Hibernate/Panache for everything
  4. External Service - Separate microservice for storage

Decision Outcome

Chosen option: "Generic Key-Value Adapter", because it provides the right level of abstraction for Hub's needs without the complexity of a full ORM or the overhead of a separate service.

Confirmation

Pros and Cons of the Options

Option 1: Service-Specific Storage

Each service implements its own storage mechanism.

Option 2: Generic Key-Value Adapter (Chosen)

Unified interface with pluggable backends.

Option 3: Full ORM Integration

Use Hibernate/Panache for all storage.

Option 4: External Service

Separate microservice for storage.

Architecture

Interface Design

/**
 * Generic storage adapter for Hub services.
 * Provides key-value operations with namespace isolation.
 */
public interface StorageAdapter {

    // ========== Key-Value Operations ==========

    /**
     * Store a value.
     * @param namespace Logical grouping (e.g., &quot;ids&quot;, &quot;config&quot;, &quot;cache&quot;)
     * @param key Unique key within namespace
     * @param value Value to store (serialized to JSON)
     */
    void put(String namespace, String key, Object value);

    /**
     * Retrieve a value.
     * @return Value or empty if not found
     */
    &lt;T&gt; Optional&lt;T&gt; get(String namespace, String key, Class&lt;T&gt; type);

    /**
     * Delete a value.
     * @return true if value existed
     */
    boolean delete(String namespace, String key);

    /**
     * Check if key exists.
     */
    boolean exists(String namespace, String key);

    /**
     * Get all keys in namespace.
     */
    Set&lt;String&gt; keys(String namespace);

    // ========== Atomic Sequence Operations ==========

    /**
     * Atomically increment and return new value.
     * Creates sequence starting at initialValue if not exists.
     * @param namespace Namespace for isolation
     * @param sequenceName Sequence identifier
     * @param initialValue Starting value if sequence doesn&#39;t exist
     * @return New value after increment
     */
    long incrementAndGet(String namespace, String sequenceName, long initialValue);

    /**
     * Get current sequence value without incrementing.
     */
    long getCurrentValue(String namespace, String sequenceName);

    /**
     * Set sequence to specific value (use with caution).
     */
    void setSequenceValue(String namespace, String sequenceName, long value);

    // ========== Batch Operations ==========

    /**
     * Get all entries in namespace.
     */
    &lt;T&gt; Map&lt;String, T&gt; getAll(String namespace, Class&lt;T&gt; type);

    /**
     * Delete all entries in namespace.
     */
    void clearNamespace(String namespace);

    // ========== Health &amp; Info ==========

    /**
     * Check if storage is available.
     */
    boolean isHealthy();

    /**
     * Get storage backend type.
     */
    String getBackendType();
}

Namespace Convention

Namespace Purpose Example Keys
ids:sequences ID sequences per table AD_Table:CE, AD_Column:CE
ids:registry Entity type registry CE, XX, YY
config:hub Hub configuration default_entity_type, api_timeout
config:user User preferences theme, language
cache:query Query result cache hash(sql)
cache:rag RAG embedding cache doc_id
state:wizard Wizard session state session_id

Backend Implementations

PostgreSQL Adapter (Default)

-- Schema for PostgreSQL adapter
CREATE TABLE hub_storage (
    namespace VARCHAR(100) NOT NULL,
    key VARCHAR(255) NOT NULL,
    value JSONB NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (namespace, key)
);

CREATE TABLE hub_sequences (
    namespace VARCHAR(100) NOT NULL,
    sequence_name VARCHAR(255) NOT NULL,
    current_value BIGINT NOT NULL DEFAULT 0,
    PRIMARY KEY (namespace, sequence_name)
);

-- Index for namespace queries
CREATE INDEX idx_hub_storage_namespace ON hub_storage(namespace);

AWS DynamoDB Adapter

Table: hub-storage
  Partition Key: namespace (String)
  Sort Key: key (String)
  Attributes: value (Map), updated_at (Number)

Table: hub-sequences
  Partition Key: namespace (String)
  Sort Key: sequence_name (String)
  Attributes: current_value (Number)

  -- Uses DynamoDB atomic counter for incrementAndGet

AWS S3 Adapter

Bucket: cloudempiere-hub-storage
  /{namespace}/{key}.json

  -- For sequences: /{namespace}/_sequences/{sequence_name}.json
  -- Note: S3 doesn&#39;t support atomic increment, use DynamoDB for sequences

In-Memory Adapter (Testing)

@Alternative
@Priority(1)
public class InMemoryStorageAdapter implements StorageAdapter {
    private final Map&lt;String, Map&lt;String, Object&gt;&gt; storage = new ConcurrentHashMap&lt;&gt;();
    private final Map&lt;String, Map&lt;String, AtomicLong&gt;&gt; sequences = new ConcurrentHashMap&lt;&gt;();
    // ... implementation
}

Configuration

# Storage adapter configuration
hub.storage.backend=${HUB_STORAGE_BACKEND:postgresql}

# PostgreSQL (default)
hub.storage.postgresql.datasource=default

# AWS DynamoDB
hub.storage.dynamodb.region=${AWS_REGION:us-east-1}
hub.storage.dynamodb.table-prefix=hub-

# AWS S3
hub.storage.s3.bucket=${HUB_S3_BUCKET:cloudempiere-hub-storage}
hub.storage.s3.region=${AWS_REGION:us-east-1}

# In-Memory (testing only)
hub.storage.inmemory.enabled=false

Integration with CentralizedIdService

@ApplicationScoped
public class CentralizedIdServiceImpl implements CentralizedIdService {

    @Inject
    StorageAdapter storage;

    @Inject
    CentralizedIdConfig config;

    @Override
    public int allocateId(String tableName, String entityType, String comment, boolean localOverride) {
        IdAllocationMode mode = detectMode(entityType, localOverride);

        return switch (mode) {
            case LOCAL_SYSTEM -&gt; generateLocalId(tableName);

            case CENTRALIZED_REQUIRED -&gt; {
                // Check scope: internal (CE_*) vs community
                if (isCloudEmpiereEntityType(entityType)) {
                    // Use StorageAdapter for internal IDs
                    yield allocateFromStorage(tableName, entityType);
                } else {
                    // Use HTTP client for community server
                    yield allocateFromCommunityServer(tableName, entityType, comment);
                }
            }

            case LOCAL_OVERRIDE -&gt; generateLocalId(tableName);
        };
    }

    private int allocateFromStorage(String tableName, String entityType) {
        String sequenceKey = tableName + &quot;:&quot; + entityType;
        long nextId = storage.incrementAndGet(&quot;ids:sequences&quot;, sequenceKey, 2000000L);
        LOG.infof(&quot;Allocated ID %d from StorageAdapter for %s&quot;, nextId, sequenceKey);
        return (int) nextId;
    }

    private boolean isCloudEmpiereEntityType(String entityType) {
        return entityType != null &amp;&amp; entityType.startsWith(&quot;CE&quot;);
    }
}

Implementation Plan

Phase 1: Core Interface & PostgreSQL Adapter (1-2 days)

Phase 2: CentralizedIdService Integration (1 day)

Phase 3: AWS Adapters (Future)

Phase 4: Additional Services (Future)

Usage Examples

Centralized IDs (CloudEmpiere Internal)

// Entity type CE_* uses StorageAdapter
storage.incrementAndGet(&quot;ids:sequences&quot;, &quot;AD_Table:CE&quot;, 2000000L);
// Returns: 2000001, 2000002, 2000003, ...

// Register entity type
storage.put(&quot;ids:registry&quot;, &quot;CE&quot;, Map.of(
    &quot;owner&quot;, &quot;cloudempiere&quot;,
    &quot;description&quot;, &quot;CloudEmpiere internal&quot;,
    &quot;created&quot;, Instant.now()
));

Configuration Storage

// Store hub configuration
storage.put(&quot;config:hub&quot;, &quot;default_entity_type&quot;, &quot;U&quot;);
storage.put(&quot;config:hub&quot;, &quot;api_timeout&quot;, 30000);

// Retrieve
Optional&lt;String&gt; entityType = storage.get(&quot;config:hub&quot;, &quot;default_entity_type&quot;, String.class);

Query Cache

// Cache query result
String cacheKey = DigestUtils.sha256Hex(sqlQuery);
storage.put(&quot;cache:query&quot;, cacheKey, Map.of(
    &quot;result&quot;, queryResult,
    &quot;cached_at&quot;, Instant.now(),
    &quot;ttl_seconds&quot;, 300
));

Security Considerations

  1. Namespace Isolation: Each namespace is logically isolated
  2. No Direct SQL: Adapter prevents SQL injection via parameterized queries
  3. Credential Storage: Sensitive values should use separate secrets management
  4. AWS IAM: DynamoDB/S3 adapters use IAM roles, not embedded credentials

References


Note: This ADR defines a generic storage abstraction. Domain-specific logic (ID allocation rules, entity type validation) remains in the consuming services.

Path: /docs/developers/architecture/idempiere-hub/063-hub-storage-adapter-service