Tutorial: Batch Context Configuration

Batch Context Configuration

Batch Context Configuration

DynamoBao provides flexible batch context behavior that can be configured based on your application's needs. This tutorial covers how to configure and use the batch context system for optimal performance and safety.

Overview

The batch context system in DynamoBao enables:

  • Efficient batching: Multiple database operations are combined into fewer requests
  • Automatic caching: Results are cached within the same request context
  • Request isolation: Ensures data doesn't leak between concurrent requests
  • Flexible enforcement: Configure whether batch context is required or optional

Configuration Options

Add the batchContext configuration to your config file:

// config.js or dynamo-bao.config.js
module.exports = {
  aws: {
    region: "us-west-2",
  },
  db: {
    tableName: "your-table-name",
  },
  batchContext: {
    requireBatchContext: false, // Default: allow fallback behavior
  },
  // ... other config
};

Behavior Modes

Default Mode (requireBatchContext: false)

Best for: Production environments, gradual migration, maximum flexibility

const manager = initModels({
  batchContext: { requireBatchContext: false },
});

// Works with direct execution (no batching/caching)
const user = await User.find("user123");

// Also works with batching + caching
await runWithBatchContext(async () => {
  const user = await User.find("user123");
});

Behavior:

  • Operations inside runWithBatchContext: Full batching and caching enabled
  • Operations outside runWithBatchContext: Direct execution without batching or caching
  • No errors thrown, maximum backward compatibility

Strict Mode (requireBatchContext: true)

Best for: Development, testing, ensuring consistent batch context usage

const manager = initModels({
  batchContext: { requireBatchContext: true },
});

// This throws an error
await User.find("user123");
// Error: Batch operations must be executed within runWithBatchContext()

// This works
await runWithBatchContext(async () => {
  const user = await User.find("user123"); // ✅
});

Behavior:

  • Operations inside runWithBatchContext: Full batching and caching enabled
  • Operations outside runWithBatchContext: Throws an error
  • Ensures all database operations use proper batch context

Context Detection API

You can check if code is currently running within a batch context:

const { User } = require("./models/user");

// Check if inside batch context
const isInBatchContext = User.isInsideBatchContext();

if (isInBatchContext) {
  console.log("Running with batching enabled");
  // Can use advanced batching features
} else {
  console.log("Running in direct execution mode");
  // Operations will be direct database calls
}

Environment Variable Support

Configure batch context behavior via environment variables:

# Enable strict mode globally
export DYNAMO_BAO_REQUIRE_BATCH_CONTEXT=true

# Your application will now require runWithBatchContext for all operations
node your-app.js

This is particularly useful for:

  • Development/Testing: Use strict mode to catch missing batch contexts early
  • Production: Use default mode for maximum flexibility
  • CI/CD: Different environments can have different enforcement levels

Usage Patterns

Web Application with Express.js

const express = require("express");
const { runWithBatchContext, initModels } = require("dynamo-bao");

const app = express();

// Initialize with development-friendly strict mode
const manager = initModels({
  batchContext: {
    requireBatchContext: process.env.NODE_ENV === "development",
  },
});

// Middleware to wrap requests in batch context
app.use((req, res, next) => {
  runWithBatchContext(() => {
    next();
  });
});

// Routes automatically benefit from batching
app.get("/api/users/:id/dashboard", async (req, res) => {
  const { User, Post } = manager.models;

  // These operations will be batched and cached
  const user = await User.find(req.params.id);
  const posts = await user.queryPosts();

  // Load related data efficiently
  await Promise.all(
    posts.items.map((post) => post.loadRelatedData(["userId"])),
  );

  res.json({ user, posts: posts.items });
});

AWS Lambda Functions

const { runWithBatchContext, initModels } = require("dynamo-bao");

// Configure based on environment
const manager = initModels({
  batchContext: {
    requireBatchContext: process.env.STAGE === "dev", // Strict in dev, flexible in prod
  },
});

exports.handler = async (event) => {
  return runWithBatchContext(async () => {
    const { User, Order } = manager.models;

    // Efficient batch operations
    const userId = event.pathParameters.userId;
    const user = await User.find(userId);
    const orders = await user.queryOrders();

    return {
      statusCode: 200,
      body: JSON.stringify({ user, orders: orders.items }),
    };
  });
};

Background Jobs

const { runWithBatchContext } = require("dynamo-bao");

async function processUserEmails() {
  await runWithBatchContext(async () => {
    const { User } = manager.models;

    // Iterate through users efficiently
    for await (const batch of User.iterateAll({ batchSize: 100 })) {
      await Promise.all(
        batch.map(async (user) => {
          if (user.emailPreferences.notifications) {
            await sendNotificationEmail(user);
          }
        }),
      );
    }
  });
}

Migration Strategies

Gradual Migration from Direct Calls

Step 1: Start with default mode (no errors)

// config.js
module.exports = {
  batchContext: { requireBatchContext: false }, // Start permissive
};

Step 2: Wrap critical paths

// Start with high-traffic endpoints
app.get("/api/dashboard", async (req, res) => {
  await runWithBatchContext(async () => {
    // Your existing code here - now with batching!
  });
});

Step 3: Enable detection and monitoring

app.use((req, res, next) => {
  const isInBatch = User.isInsideBatchContext();
  if (!isInBatch) {
    console.warn(`Route ${req.path} not using batch context`);
  }
  next();
});

Step 4: Gradually enable strict mode

// config.js
module.exports = {
  batchContext: {
    requireBatchContext: process.env.NODE_ENV === "development",
  },
};

Performance Considerations

With Batch Context (Recommended)

await runWithBatchContext(async () => {
  // These 10 finds become 1 DynamoDB BatchGet operation
  const users = await Promise.all([
    User.find("user1"),
    User.find("user2"),
    // ... 8 more
  ]);

  // Subsequent finds for same users return cached instances
  const user1Again = await User.find("user1"); // Returns same object
  expect(user1Again).toBe(users[0]); // Same object reference
});

How finds are batched

Inside a batch context, every find() issued in the same tick is sent as one BatchGetItem on the next macrotask, after pending promise callbacks have run. There is no added delay: a Promise.all fan-out, or loadRelatedData over a page of query results, lands in a single request, while a sequential await User.find(...) costs only its own round trip. A find by object id and one by the equivalent string id share a single batch entry.

The batchDelay option changes the schedule when you need to:

// Default: flush on the next tick, no added latency
await User.find(id);

// Hold the batch open for 10 ms so later callers can join it
await User.find(id, { batchDelay: 10 });

// Skip batching and send an individual GetItem right away
await User.find(id, { batchDelay: 0 });

// Skip both the batch and the request cache (always reads DynamoDB)
await User.find(id, { bypassCache: true });

Write round trips

Writes read the current row first only when they need it. Model.update(id, changes) goes straight to a single UpdateItem unless the changed fields touch a GSI key, a unique constraint, a StringSetField, an indexed counter, or a searchable field (or forceReindex is set), in which case the row is read first so the index keys and constraints stay consistent. Model.delete(id) is a single conditional DeleteItem on models without unique constraints. In both cases a missing row still raises ItemNotFoundError. Updates that change a unique value run as one read plus one transaction; the saved instance is built locally rather than read back.

Without Batch Context (Direct Mode)

// These become 10 separate DynamoDB Get operations
const users = await Promise.all([
  User.find("user1"), // Individual DynamoDB call
  User.find("user2"), // Individual DynamoDB call
  // ... 8 more individual calls
]);

// No caching - each find creates a new request
const user1Again = await User.find("user1"); // Another DynamoDB call
expect(user1Again).not.toBe(users[0]); // Different object instances

Best Practices

  1. Use strict mode in development to catch missing batch contexts early
  2. Use default mode in production for maximum deployment flexibility
  3. Always wrap request handlers with runWithBatchContext for optimal performance
  4. Monitor batch context usage in production to identify optimization opportunities
  5. Use environment variables to configure behavior per environment
  6. Gradually migrate existing codebases using the permissive default mode

Error Handling

When requireBatchContext: true, operations outside batch context throw descriptive errors:

try {
  await User.find("user123"); // Outside batch context
} catch (error) {
  console.log(error.message);
  // "Batch operations must be executed within runWithBatchContext().
  //  Wrap your database operations in runWithBatchContext() to enable batching and caching."
}

Integration with Other Features

Multi-Tenancy

Batch context works seamlessly with multi-tenancy:

await TenantContext.runWithTenant("tenant-123", async () => {
  await runWithBatchContext(async () => {
    // Operations are both tenant-scoped AND batched
    const users = await User.queryByIndex("byStatus", "active");
  });
});

Testing

Batch context is perfect for test isolation:

// Each test gets its own isolated batch context
test("user creation", async () => {
  await runWithBatchContext(async () => {
    const user = await User.create({ name: "Test User" });
    expect(user.name).toBe("Test User");
  });
});

Request-Scoped Capacity Tracking

DynamoBao provides getBatchContextCapacity() to retrieve the total DynamoDB consumed capacity (RCUs/WCUs) for all operations within the current runWithBatchContext scope.

Basic Usage

const {
  runWithBatchContext,
  getBatchContextCapacity,
} = require("dynamo-bao");

await runWithBatchContext(async () => {
  // Perform various operations
  const user = await User.find(userId);
  const posts = await Post.queryByIndex("postsForUser", userId);
  await user.save();

  // Get total capacity consumed
  const capacity = getBatchContextCapacity();
  console.log(`Read: ${capacity.read} RCUs, Write: ${capacity.write} WCUs`);
  // Example output: "Read: 1.5 RCUs, Write: 2.0 WCUs"
});

Multi-Tenant Usage Metering

A primary use case is billing tenants for their DynamoDB usage:

const {
  runWithBatchContext,
  getBatchContextCapacity,
  TenantContext,
} = require("dynamo-bao");

// Express middleware for usage tracking
app.use(async (req, res, next) => {
  const tenantId = req.headers["x-tenant-id"];

  await TenantContext.runWithTenant(tenantId, async () => {
    await runWithBatchContext(async () => {
      // Store original end function
      const originalEnd = res.end;

      // Override to capture capacity before response completes
      res.end = function (...args) {
        const capacity = getBatchContextCapacity();

        // Record usage for billing
        usageTracker.record({
          tenantId,
          endpoint: req.path,
          method: req.method,
          readCapacityUnits: capacity.read,
          writeCapacityUnits: capacity.write,
          timestamp: new Date().toISOString(),
        });

        return originalEnd.apply(this, args);
      };

      await next();
    });
  });
});

AWS Lambda with Usage Logging

const {
  runWithBatchContext,
  getBatchContextCapacity,
  initModels,
} = require("dynamo-bao");

exports.handler = async (event) => {
  return runWithBatchContext(async () => {
    const { User, Order } = initModels(config).models;

    // Your business logic
    const userId = event.pathParameters.userId;
    const user = await User.find(userId);
    const orders = await user.queryOrders();

    // Get capacity for logging/monitoring
    const capacity = getBatchContextCapacity();

    // Log for CloudWatch metrics
    console.log(
      JSON.stringify({
        metric: "DynamoDBCapacity",
        userId,
        readCapacityUnits: capacity.read,
        writeCapacityUnits: capacity.write,
      })
    );

    return {
      statusCode: 200,
      body: JSON.stringify({
        user,
        orders: orders.items,
      }),
    };
  });
};

Tracking Capacity for Specific Operations

You can check capacity at any point within a batch context:

await runWithBatchContext(async () => {
  const capacityBefore = getBatchContextCapacity();

  // Expensive operation
  await User.batchFind(userIds); // Load 100 users

  const capacityAfter = getBatchContextCapacity();
  const operationCost = {
    read: capacityAfter.read - capacityBefore.read,
    write: capacityAfter.write - capacityBefore.write,
  };

  console.log(`Batch find consumed ${operationCost.read} RCUs`);
});

What Operations Are Tracked

Operation Capacity Type Notes
find() Read Both direct and batched finds
batchFind() Read Total capacity for the batch
queryByIndex Read Including count-only queries
create() Write Includes unique constraint writes
update() Read + Write Read for existing item check
delete() Write Includes unique constraint cleanup
save() Read + Write Depends on whether item exists

Behavior Outside Batch Context

When called outside a batch context, getBatchContextCapacity() returns zeros:

// Outside batch context
const capacity = getBatchContextCapacity();
console.log(capacity); // { read: 0, write: 0 }

// Inside batch context
await runWithBatchContext(async () => {
  await User.find("user123");
  const capacity = getBatchContextCapacity();
  console.log(capacity); // { read: 0.5, write: 0 }
});

// After batch context ends, back to zeros
const capacityAfter = getBatchContextCapacity();
console.log(capacityAfter); // { read: 0, write: 0 }

Context Isolation

Each batch context has its own isolated capacity accumulator:

// First request
await runWithBatchContext(async () => {
  await User.create({ name: "User 1", email: "user1@example.com" });
  console.log(getBatchContextCapacity()); // { read: 0, write: 7 }
});

// Second request - starts fresh
await runWithBatchContext(async () => {
  console.log(getBatchContextCapacity()); // { read: 0, write: 0 }
  await User.find("user123");
  console.log(getBatchContextCapacity()); // { read: 0.5, write: 0 }
});

Combining with Per-Instance Capacity

DynamoBao also tracks capacity per model instance via getNumericConsumedCapacity(). Use both for different purposes:

await runWithBatchContext(async () => {
  const user = await User.find(userId);

  // Per-instance: capacity for this specific object's operations
  const instanceCapacity = user.getNumericConsumedCapacity("read");

  // Request-scoped: total capacity for all operations in this request
  const requestCapacity = getBatchContextCapacity();

  console.log(`Instance read: ${instanceCapacity} RCUs`);
  console.log(`Total request: ${requestCapacity.read} RCUs`);
});

Best Practices

  1. Call at the end of requests: Get capacity just before sending the response
  2. Use for billing/metering: Aggregate capacity by tenant, endpoint, or user
  3. Monitor in production: Log capacity to CloudWatch or your monitoring system
  4. Set up alerts: Detect unexpected capacity spikes early
  5. Combine with per-instance tracking: Use request-scoped for billing, per-instance for debugging