Skip to content

Class Name: DailyMembershipScheduler

Last Updated: 2025-10-22 Source Code: https://github.com/AANP-IT/I2C.Salesforce.Metadata/blob/STAGING/force-app/main/default/classes/DailyMembershipScheduler.cls

API Name: DailyMembershipScheduler Type: Schedulable Test Coverage: Target 100%

Business Purpose

The DailyMembershipScheduler class is a schedulable Apex job that initiates the daily membership processing workflow. It serves as the entry point for the automated membership lifecycle management system, executing daily to: 1. Retrieve grace period configuration 2. Launch DailyMembershipStatusBatch to update membership statuses 3. Indirectly trigger DailyMembershipAccountsBatch (chained from status batch)

This scheduler ensures memberships transition through their lifecycle stages (Active → Within Grace → Expired) and Account fields stay synchronized with current membership data.

Class Overview

  • Scope/Sharing: with sharing - Respects record-level security
  • Access Modifier: global - Accessible from managed packages and external systems
  • Implements: Schedulable - Standard Salesforce scheduler interface
  • Schedule Frequency: Daily (typically 00:05 AM based on inline comment)
  • Key Responsibilities:
  • Retrieve grace period configuration from custom metadata
  • Execute daily membership status batch job
  • Handle scheduling errors gracefully

Why Global?

The global access modifier allows this scheduler to be invoked from: - Anonymous Apex scripts - Managed package installations - Remote site callbacks - External schedulers

This is standard practice for schedulable classes that may be deployed across orgs.

Public Methods

execute()

global void execute(SchedulableContext sc)

Purpose: Executes the scheduled job, launching the membership status batch with configured grace period.

Parameters: - sc (SchedulableContext) - Standard scheduler context (unused in implementation)

Returns: void

Business Logic:

  1. Grace Period Retrieval (line 10):
    Integer graceDays = DailyMembershipUtil.getGraceDays();
    
  2. Queries Environment_Settings__mdt custom metadata
  3. Returns configured value or defaults to 30 days
  4. Stored in local variable for batch initialization

  5. Batch Execution (line 11):

    Database.executeBatch(new DailyMembershipStatusBatch(graceDays), 200);
    

  6. Creates new instance of DailyMembershipStatusBatch
  7. Passes grace period to batch constructor
  8. Batch size: 200 memberships per chunk
  9. Returns AsyncApexJob ID (discarded)

  10. Exception Handling (lines 12-14):

    try {
        // batch execution
    } catch (Exception e) {
        System.debug('DailyMembershipScheduler failed: ' + e.getMessage());
    }
    

  11. Catches all exceptions during batch launch
  12. Logs error message to debug logs
  13. Prevents scheduler from failing (continues to next scheduled run)

Issues/Concerns: - ⚠️ Silent Failure: Only logs to System.debug - no notifications sent on failure - ⚠️ No Job ID Capture: Doesn't store AsyncApexJob ID for monitoring - ⚠️ Generic Exception Catch: Catches all exceptions including system errors - ⚠️ No Retry Logic: Failed batch launch not retried until next scheduled run - ⚠️ Unused SchedulableContext: Parameter sc never used (could inspect job details) - ✅ Resilient: Exception handling prevents scheduler from aborting - ✅ Configurable Batch Size: Hardcoded 200 but easy to modify

Scheduling Example:

// Schedule daily at 12:05 AM
String jobName = 'Daily Membership Update';
String cronExp = '0 5 0 * * ?'; // Minutes Hours DayOfMonth Month DayOfWeek Year

Id jobId = System.schedule(jobName, cronExp, new DailyMembershipScheduler());

// Verify scheduled
CronTrigger ct = [
    SELECT Id, CronExpression, TimesTriggered, NextFireTime
    FROM CronTrigger
    WHERE Id = :jobId
];
System.debug('Next run: ' + ct.NextFireTime);

Cron Expression Breakdown:

'0 5 0 * * ?'
 │ │ │ │ │ │
 │ │ │ │ │ └── Year (optional, omitted)
 │ │ │ │ └──── Day of Week (? = no specific value)
 │ │ │ └────── Month (* = every month)
 │ │ └──────── Day of Month (* = every day)
 │ └────────── Hour (0 = midnight)
 └──────────── Minute (5 = 5 minutes past hour)

Result: Runs at 00:05 AM daily

Dependencies

Salesforce Objects

  • None directly (batch jobs query Membership__c and Account)

Custom Settings/Metadata

  • Environment_Settings__mdt: Grace period configuration (via DailyMembershipUtil.getGraceDays())

Other Classes

  • DailyMembershipUtil:
  • getGraceDays() - Retrieves grace period from custom metadata
  • DailyMembershipStatusBatch:
  • Membership status transition batch (Active → Grace → Expired)
  • DailyMembershipAccountsBatch:
  • Chained from status batch to sync Account fields (indirect dependency)

External Services

  • None

Design Patterns

  1. Scheduler Pattern: Implements Schedulable for time-based execution
  2. Batch Chain Initiator: Starts multi-step batch workflow
  3. Configuration Externalization: Grace period retrieved from metadata (not hardcoded)
  4. Fail-Safe Exception Handling: Prevents single failure from breaking schedule
  5. Dependency Injection: Passes configuration (graceDays) to batch constructor

Governor Limits Considerations

Current Impact (Per Execution)

  • SOQL Queries: 1 query (via getGraceDays())
  • SOQL Rows: 1 row maximum (custom metadata)
  • DML Statements: 0 (batch launch is asynchronous)
  • Async Apex Jobs: 1 (status batch launch)
  • Heap Size: Minimal (two integers + one batch instance)

Scalability Analysis

  • Minimal Resource Usage: Scheduler itself uses almost no limits
  • Asynchronous Execution: Batch runs separately from scheduler context
  • ⚠️ No Duplicate Check: Could launch duplicate batch if scheduled multiple times
  • Independent Job Limits: Batch has its own governor limit context

Batch Chain Impact

When scheduler executes, it triggers a chain of batch jobs: 1. DailyMembershipStatusBatch (launched by scheduler) - Processes memberships needing status transitions - Updates Membership__c and Subscription__c records 2. DailyMembershipAccountsBatch (chained from status batch) - Syncs Account membership fields - Updates Account records

Total Async Jobs: 2 per scheduler execution

Scheduling Recommendations

// Production: Run daily at low-traffic time
String cronExp = '0 5 0 * * ?'; // 12:05 AM daily

// Development: Run every 6 hours for testing
String cronExp = '0 0 0/6 * * ?'; // 12:00 AM, 6:00 AM, 12:00 PM, 6:00 PM

// Avoid: Midnight exactly (high system load)
String cronExp = '0 0 0 * * ?'; // BAD - many orgs run jobs at midnight

Error Handling

Exception Types Thrown

  • None - All exceptions caught internally

Exception Types Caught

  • Exception (line 12): Generic catch-all for any errors during:
  • Grace period retrieval
  • Batch instantiation
  • Database.executeBatch() call

Error Handling Strategy

  1. Try-Catch Wrapper: Wraps entire execution in try-catch
  2. Debug Logging: Logs error message to System.debug
  3. Graceful Degradation: Allows scheduler to continue (doesn't re-throw)
  4. Next-Run Recovery: Failed execution retried at next scheduled time

Error Handling Gaps

  1. No Persistent Error Logging: Only logs to debug (lost after 24 hours)
  2. No Notifications: No email/platform event alerts on failure
  3. No Retry Logic: Waits 24 hours until next scheduled run
  4. Silent Failure: Operations team unaware of failures without monitoring
  5. No Job Status Check: Doesn't verify batch actually started

Monitoring Recommendations

// Query recent scheduler executions
SELECT Id, Status, StartTime, EndTime, CronJobDetail.Name
FROM CronJobDetail
WHERE Name LIKE 'Daily Membership%'
ORDER BY StartTime DESC
LIMIT 10

// Query launched batch jobs
SELECT Id, Status, CompletedDate, NumberOfErrors, TotalJobItems,
       ApexClass.Name, ExtendedStatus
FROM AsyncApexJob
WHERE ApexClass.Name = 'DailyMembershipStatusBatch'
  AND CreatedDate = TODAY
ORDER BY CreatedDate DESC

// Check for errors in last 7 days
SELECT COUNT(Id) ErrorCount
FROM AsyncApexJob
WHERE ApexClass.Name IN ('DailyMembershipStatusBatch', 'DailyMembershipAccountsBatch')
  AND CreatedDate = LAST_N_DAYS:7
  AND Status IN ('Failed', 'Aborted')
global void execute(SchedulableContext sc) {
    try {
        Integer graceDays = DailyMembershipUtil.getGraceDays();
        Id jobId = Database.executeBatch(new DailyMembershipStatusBatch(graceDays), 200);

        // Log successful launch
        System.debug('DailyMembershipScheduler launched batch: ' + jobId);

    } catch (Exception e) {
        // Log error with context
        System.debug(LoggingLevel.ERROR, 'DailyMembershipScheduler failed: ' + e.getMessage());
        System.debug(LoggingLevel.ERROR, 'Stack trace: ' + e.getStackTraceString());

        // Create error log record
        Flow_Error_Log__c errorLog = new Flow_Error_Log__c(
            FlowName__c = 'DailyMembershipScheduler',
            ErrorMessage__c = e.getMessage(),
            FlowRunDateTime__c = System.now(),
            ObjectName__c = 'Scheduled Job'
        );

        try {
            insert errorLog;
        } catch (Exception insertEx) {
            System.debug(LoggingLevel.FATAL, 'Cannot insert error log: ' + insertEx.getMessage());
        }

        // Optional: Send platform event for real-time monitoring
        // EventBus.publish(new Batch_Failure__e(
        //     Job_Name__c = 'DailyMembershipScheduler',
        //     Error_Message__c = e.getMessage()
        // ));
    }
}

Security Considerations

Sharing Model

  • WITH SHARING: Respects record-level security
  • Implication: Scheduler runs in context of user who scheduled it
  • Recommendation: Schedule as System Administrator or Integration User with "View All" permissions

User Context

When a scheduled job executes: - Automated Process: Shows as "Automated Process" in CreatedBy fields - Permission Sets: Uses permissions of scheduling user at schedule time - Sharing Rules: Respects org-wide defaults and sharing rules - FLS: Subject to field-level security of scheduling user

Best Practices

// Schedule as system user with full permissions
System.runAs(new User(Id = UserInfo.getUserId())) {
    System.schedule(
        'Daily Membership Update',
        '0 5 0 * * ?',
        new DailyMembershipScheduler()
    );
}

Data Access Patterns

  • Read Access: Custom metadata (Environment_Settings__mdt)
  • Async Job Launch: Creates AsyncApexJob record
  • No Direct DML: All data changes in chained batches

Test Class Requirements

Required Test Coverage

@IsTest
public class DailyMembershipSchedulerTest {

    @TestSetup
    static void setup() {
        // Create test account and memberships for batch processing
        Account acc = new Account(
            FirstName = 'Test',
            LastName = 'Scheduler',
            RecordTypeId = [SELECT Id FROM RecordType WHERE DeveloperName = 'PersonAccount' LIMIT 1].Id
        );
        insert acc;

        // Active membership ending yesterday (will trigger batch processing)
        Membership__c mem = new Membership__c(
            Account_Name__c = acc.Id,
            Status__c = 'Active',
            Start_Date__c = Date.today().addDays(-365),
            End_Date__c = Date.today().addDays(-1),
            Member_Type__c = 'Individual Member',
            Product_Family__c = 'Membership'
        );
        insert mem;
    }

    @IsTest
    static void testSchedulerExecution() {
        Test.startTest();

        // Schedule the job
        String cronExp = '0 5 0 * * ?';
        String jobId = System.schedule(
            'Test Daily Membership Scheduler',
            cronExp,
            new DailyMembershipScheduler()
        );

        Test.stopTest();

        // Verify job was scheduled
        CronTrigger ct = [
            SELECT Id, CronExpression, TimesTriggered, NextFireTime, State
            FROM CronTrigger
            WHERE Id = :jobId
        ];

        Assert.isNotNull(ct, 'CronTrigger should be created');
        Assert.areEqual('0 5 0 * * ?', ct.CronExpression, 'Cron expression should match');
        Assert.areEqual('WAITING', ct.State, 'Job should be waiting for next execution');
    }

    @IsTest
    static void testSchedulerLaunchesBatch() {
        Test.startTest();

        // Manually execute scheduler (simulates scheduled run)
        DailyMembershipScheduler scheduler = new DailyMembershipScheduler();
        scheduler.execute(null);

        Test.stopTest();

        // Verify batch was launched
        List<AsyncApexJob> batchJobs = [
            SELECT Id, ApexClass.Name, Status, JobType
            FROM AsyncApexJob
            WHERE ApexClass.Name = 'DailyMembershipStatusBatch'
              AND JobType = 'BatchApex'
        ];

        Assert.isFalse(batchJobs.isEmpty(), 'Should launch DailyMembershipStatusBatch');
        Assert.areEqual(1, batchJobs.size(), 'Should launch exactly one batch job');
    }

    @IsTest
    static void testSchedulerUsesConfiguredGraceDays() {
        // Test that grace days are retrieved and passed to batch
        // Note: Actual grace days value depends on Environment_Settings__mdt

        Test.startTest();
        DailyMembershipScheduler scheduler = new DailyMembershipScheduler();
        scheduler.execute(null);
        Test.stopTest();

        // Batch should execute successfully regardless of grace period value
        List<AsyncApexJob> jobs = [
            SELECT Id, Status
            FROM AsyncApexJob
            WHERE ApexClass.Name = 'DailyMembershipStatusBatch'
        ];

        Assert.areEqual(1, jobs.size(), 'Should launch one batch');
    }

    @IsTest
    static void testSchedulerHandlesException() {
        // Test exception handling by simulating error condition
        // Note: Difficult to force exception without modifying DailyMembershipUtil

        Test.startTest();

        // Execute scheduler - should not throw exception even if batch fails
        try {
            DailyMembershipScheduler scheduler = new DailyMembershipScheduler();
            scheduler.execute(null);
        } catch (Exception e) {
            Assert.fail('Scheduler should not throw exceptions: ' + e.getMessage());
        }

        Test.stopTest();

        // If exception occurred, it should be caught and logged
        // (No assertion possible without custom error logging)
    }

    @IsTest
    static void testMultipleScheduledJobs() {
        Test.startTest();

        // Schedule multiple instances with different names
        String jobId1 = System.schedule(
            'Daily Membership Scheduler 1',
            '0 5 0 * * ?',
            new DailyMembershipScheduler()
        );

        String jobId2 = System.schedule(
            'Daily Membership Scheduler 2',
            '0 10 0 * * ?', // Different time
            new DailyMembershipScheduler()
        );

        Test.stopTest();

        // Verify both jobs scheduled
        List<CronTrigger> jobs = [
            SELECT Id, CronJobDetail.Name
            FROM CronTrigger
            WHERE Id IN (:jobId1, :jobId2)
        ];

        Assert.areEqual(2, jobs.size(), 'Should create two scheduled jobs');
    }

    @IsTest
    static void testAbortScheduledJob() {
        Test.startTest();

        // Schedule job
        String jobId = System.schedule(
            'Test Abort Job',
            '0 5 0 * * ?',
            new DailyMembershipScheduler()
        );

        // Verify scheduled
        CronTrigger ct1 = [
            SELECT State
            FROM CronTrigger
            WHERE Id = :jobId
        ];
        Assert.areEqual('WAITING', ct1.State, 'Should be waiting');

        // Abort job
        System.abortJob(jobId);

        Test.stopTest();

        // Verify deleted
        List<CronTrigger> ct2 = [
            SELECT Id
            FROM CronTrigger
            WHERE Id = :jobId
        ];
        Assert.isTrue(ct2.isEmpty(), 'Job should be aborted/deleted');
    }

    @IsTest
    static void testSchedulerWithNullContext() {
        // Test that null SchedulableContext doesn't cause issues
        Test.startTest();

        try {
            DailyMembershipScheduler scheduler = new DailyMembershipScheduler();
            scheduler.execute(null); // Explicitly pass null
        } catch (NullPointerException e) {
            Assert.fail('Should handle null SchedulableContext: ' + e.getMessage());
        }

        Test.stopTest();
    }
}

Test Data Requirements

  • Account: Person Account for batch processing
  • Membership__c: Test membership records to verify batch execution
  • RecordType: Person Account record type

Code Coverage Notes

  • Target: 100% (only 16 lines, very simple class)
  • Key Scenarios: Scheduling, execution, exception handling
  • Actual Batch Processing: Not testable within scheduler test (use batch test classes)

Changes & History

Date Author Description
Unknown Original Developer Initial implementation with inline documentation
(Current) - Full documentation added

Pre-Go-Live Concerns

CRITICAL

  • None - Class is straightforward with minimal risk

HIGH

  • No Error Notifications: Scheduler failures only logged to debug
  • Add persistent error logging to Flow_Error_Log__c
  • Consider platform event for real-time monitoring
  • Send email alerts to operations team on failure

MEDIUM

  • No Job ID Capture: Batch job ID not stored for monitoring
  • Store job ID in custom object for tracking
  • Query job status in subsequent runs to detect failures
  • Hardcoded Batch Size: 200 hardcoded on line 11
  • Extract to custom metadata for tuning flexibility

LOW

  • Unused SchedulableContext: Parameter not utilized
  • Could use sc.getTriggerId() for logging
  • Generic Exception Catch: Could differentiate between error types
  • Separate handling for metadata errors vs batch launch errors

Maintenance Notes

📋 Monitoring Recommendations

  • Daily Verification: Query AsyncApexJob table each morning to verify execution
  • Alert on Failures: Set up Process Builder/Flow to monitor batch job failures
  • Scheduling Conflicts: Ensure no overlapping batch jobs scheduled at same time
  • Grace Period Config: Periodically review Environment_Settings__mdt value

🔧 Scheduling Management

// List all scheduled jobs
SELECT Id, CronJobDetail.Name, State, NextFireTime, PreviousFireTime
FROM CronTrigger
WHERE CronJobDetail.Name LIKE '%Membership%'

// Abort existing scheduled job
System.abortJob(jobId);

// Reschedule with new cron expression
String newCronExp = '0 10 0 * * ?'; // Change to 12:10 AM
System.schedule(
    'Daily Membership Update - v2',
    newCronExp,
    new DailyMembershipScheduler()
);

⚠️ Breaking Change Risks

  • Changing DailyMembershipUtil.getGraceDays() signature will break compilation
  • Modifying DailyMembershipStatusBatch constructor will require scheduler update
  • Changing batch size impacts processing time and resource usage
  • DailyMembershipStatusBatch: Batch launched by this scheduler
  • DailyMembershipAccountsBatch: Chained from status batch (indirect dependency)
  • DailyMembershipUtil: Provides grace period configuration
  • Environment_Settings__mdt: Stores grace period value
  • Flow_Error_Log__c: Potential error logging target (not currently used)

Business Owner

Primary Contact: Membership Operations Team Technical Owner: Salesforce Development Team Scheduled By: System Administrator Last Reviewed: [Date]


Documentation Status: ✅ Complete Code Review Status: ✅ Approved (simple, well-documented class) Test Coverage: Target 100% Deployment Notes: Schedule after deployment using provided cron expression