Use Time-to-Live for Messages and Memories

Messages and memories can remain searchable after the conversation that created them ends. Time-to-live lets applications automatically expire records after a defined period, for example to meet compliance requirements, remove information that is likely outdated, or save storage space.

This guide shows how to configure record expiration for Oracle DB-backed messages and memories. It also explains purge jobs and per-record time-to-live overrides.

Oracle Agent Memory applies time-to-live in two layers:

When you select a per-record anchor, use TimeToLiveAnchor.CREATED_AT to count from when Oracle stores the row, or TimeToLiveAnchor.TIMESTAMP to count from the record’s event timestamp instead.

Note: Use time-to-live when records should remain available for a defined period and then expire automatically. For example, an application might retain support details for 30 days or temporary task information for one week.

Note: A few retention rules are worth keeping in mind:

Hint: For package setup, see the Get Started with Agent Memory. If you need a local Oracle AI Database for this example, follow Run Oracle AI Database locally.

Configure Schema-Level Retention Defaults

Create an OracleAgentMemory client with schema_policy=SchemaPolicy.CREATE_IF_NECESSARY when you want the SDK to create or upgrade the managed Oracle schema and persist a retention policy alongside it.

from datetime import datetime, timezone

import oracledb

from oracleagentmemory.apis import TimeToLiveAnchor
from oracleagentmemory.core import (
    MemoryRetentionConfig,
    OracleAgentMemory,
    SchemaPolicy,
)
from oracleagentmemory.core.embedders.embedder import Embedder
from oracleagentmemory.core.llms.llm import Llm

embedder = Embedder(model="YOUR_EMBEDDING_MODEL")
llm = Llm(
    model="YOUR_MEMORY_LLM_MODEL",
    api_base="YOUR_MEMORY_LLM_BASE_URL",
    api_key="YOUR_MEMORY_LLM_API_KEY",
)
db_pool = oracledb.SessionPool(
    user="YOUR DB USER",
    password="YOUR DB PASSWORD",
    dsn="localhost:1521/...",
)
memory_store_id = "T_TIME_TO_LIVE"

memory = OracleAgentMemory(
    connection=db_pool,
    embedder=embedder,
    llm=llm,
    schema_policy=SchemaPolicy.CREATE_IF_NECESSARY,
    memory_store_id=memory_store_id,
    memory_retention_config=MemoryRetentionConfig(
        default_ttl_days=30,
        max_ttl_days=90,
    ),
)

In this configuration:

API Reference: OracleAgentMemory

Add Messages and Memories with Time-to-Live

Use thread writes to rely on the schema default TTL for some records while setting a different TTL for others.

ttl_thread = memory.create_thread(
    thread_id="ttl_demo_thread",
    user_id="user_123",
)

message_ids = ttl_thread.add_messages(
    [
        {
            "id": "msg-ttl-1",
            "role": "user",
            "content": (
                "I opened ticket 1042 yesterday because the laptop battery failed."
            ),
            "timestamp": "2026-04-01T09:00:00Z",
        },
        {
            "id": "msg-ttl-2",
            "role": "assistant",
            "content": (
                "I will keep ticket 1042 active and send a replacement checklist."
            ),
            "timestamp": "2026-04-01T09:01:00Z",
        },
    ]
)

timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
#^ for instance, 2026-04-01T09:00:00Z

backfilled_memory_id = ttl_thread.add_memory(
    "Ticket 1042 battery-failure report was filed on 2026-04-01.",
    memory_id="mem-ttl-backfill",
    timestamp=timestamp,
    ttl_days=90,
    ttl_anchor=TimeToLiveAnchor.TIMESTAMP,
)

short_lived_memory_id = ttl_thread.add_memory(
    "Replacement checklist should be revisited within one week.",
    memory_id="mem-ttl-short",
    ttl_days=7,
)

print(message_ids)
print(backfilled_memory_id)
print(short_lived_memory_id)
#['msg-ttl-1', 'msg-ttl-2']
#mem-ttl-backfill
#mem-ttl-short

In this example:

API Reference: OracleThread

Refresh Time-to-Live on Existing Records

Use update_message() or update_memory() to refresh an existing expiration without replacing the stored content.

ttl_thread.update_message("msg-ttl-1", ttl_days=14)

ttl_thread.update_memory(
    "mem-ttl-backfill",
    ttl_days=60,
    ttl_anchor=TimeToLiveAnchor.TIMESTAMP,
)

These calls keep the stored content and metadata in place while recomputing the expiration window. Remember that updates preserve the current expiration unless you pass ttl_days or ttl_anchor.

API Reference: OracleThread

Ensure the Managed Purge Job Exists

When Oracle Agent Memory creates or upgrades its managed Oracle schema, it also creates two daily DBMS_SCHEDULER jobs. One deletes expired message and memory rows and their retrieval chunks. The other deletes orphaned retrieval chunks whose source row no longer exists or no longer matches its source type. Both jobs purge rows in batches instead of issuing one large delete, commit between batches, and capture one reference timestamp up front so each run uses one consistent expiration cutoff.

Each scheduler definition sets schedule_limit to one day so a run that starts too late can be skipped instead of running arbitrarily late. Each job body separately stops taking new batches after one day from the run start time. That runtime limit is checked between batches, so a batch already in progress is allowed to finish and commit. At the end of a run, each job writes a short DBMS_OUTPUT summary that includes whether the runtime limit was reached and how many rows were deleted.

When SchemaPolicy.CREATE_IF_NECESSARY needs to create either job but the schema user lacks CREATE JOB, setup completes with a warning. Expired records stay hidden from reads and search, and orphaned chunks remain until a privileged user creates the missing job.

If you set memory_store_id explicitly, prefix the job and table names with that ID plus an underscore. If you still use the deprecated table_name_prefix, apply that prefix instead. The default managed job names are PURGE_EXPIRED_RECORDS_J and PURGE_ORPHANED_CHUNKS_J.

Tip: Use the following DBA options when the schema owner does not have CREATE JOB.

Grant Scheduler Job Privileges

GRANT CREATE JOB TO app_schema;
-- Run OracleAgentMemory schema setup as app_schema.
REVOKE CREATE JOB FROM app_schema;

Create the Purge Job Manually

Replace APP_SCHEMA with the managed schema owner and adjust object names if a table-name prefix is configured. The following PL/SQL block is intentionally compact so it stays within Oracle Scheduler’s job_action length limit even when prefixed object names are long:

BEGIN
  DBMS_SCHEDULER.CREATE_JOB(
    job_name => 'APP_SCHEMA.PURGE_EXPIRED_RECORDS_J',
    job_type => 'PLSQL_BLOCK',
    job_action => q'[
      DECLARE
        bs CONSTANT PLS_INTEGER := 1000;
        n PLS_INTEGER;
        mc PLS_INTEGER := 0;
        xc PLS_INTEGER := 0;
        mr PLS_INTEGER := 0;
        xr PLS_INTEGER := 0;
        tl PLS_INTEGER := 0;
        rt TIMESTAMP(6) WITH TIME ZONE := SYSTIMESTAMP;
        dl TIMESTAMP(6) WITH TIME ZONE := rt + INTERVAL '1 00:00:00' DAY TO SECOND;
      BEGIN
        LOOP
          IF SYSTIMESTAMP >= dl THEN
            tl := 1;
            EXIT;
          END IF;
          DELETE FROM APP_SCHEMA.RECORD_CHUNKS c
          WHERE c.rowid IN (
            SELECT rid
            FROM (
              SELECT c.rowid AS rid
              FROM APP_SCHEMA.RECORD_CHUNKS c
              WHERE c.source_record_type = 'message'
                AND EXISTS (
                  SELECT 1
                  FROM APP_SCHEMA.MESSAGE m
                  WHERE m.record_id = c.source_id
                    AND m.expires_at IS NOT NULL
                    AND m.expires_at <= rt
                )
              FETCH FIRST bs ROWS ONLY
            )
          );
          n := SQL%ROWCOUNT;
          mc := mc + n;
          EXIT WHEN n = 0;
          COMMIT;
        END LOOP;

        LOOP
          IF SYSTIMESTAMP >= dl THEN
            tl := 1;
            EXIT;
          END IF;
          DELETE FROM APP_SCHEMA.RECORD_CHUNKS c
          WHERE c.rowid IN (
            SELECT rid
            FROM (
              SELECT c.rowid AS rid
              FROM APP_SCHEMA.RECORD_CHUNKS c
              WHERE c.source_record_type IN ('fact', 'guideline', 'memory', 'preference')
                AND EXISTS (
                  SELECT 1
                  FROM APP_SCHEMA.MEMORY m
                  WHERE m.record_id = c.source_id
                    AND m.memory_type = c.source_record_type
                    AND m.expires_at IS NOT NULL
                    AND m.expires_at <= rt
                )
              FETCH FIRST bs ROWS ONLY
            )
          );
          n := SQL%ROWCOUNT;
          xc := xc + n;
          EXIT WHEN n = 0;
          COMMIT;
        END LOOP;

        LOOP
          IF SYSTIMESTAMP >= dl THEN
            tl := 1;
            EXIT;
          END IF;
          DELETE FROM APP_SCHEMA.MESSAGE
          WHERE rowid IN (
            SELECT rid
            FROM (
              SELECT rowid AS rid
              FROM APP_SCHEMA.MESSAGE
              WHERE expires_at IS NOT NULL
                AND expires_at <= rt
              FETCH FIRST bs ROWS ONLY
            )
          );
          n := SQL%ROWCOUNT;
          mr := mr + n;
          EXIT WHEN n = 0;
          COMMIT;
        END LOOP;

        LOOP
          IF SYSTIMESTAMP >= dl THEN
            tl := 1;
            EXIT;
          END IF;
          DELETE FROM APP_SCHEMA.MEMORY
          WHERE rowid IN (
            SELECT rid
            FROM (
              SELECT rowid AS rid
              FROM APP_SCHEMA.MEMORY
              WHERE expires_at IS NOT NULL
                AND expires_at <= rt
              FETCH FIRST bs ROWS ONLY
            )
          );
          n := SQL%ROWCOUNT;
          xr := xr + n;
          EXIT WHEN n = 0;
          COMMIT;
        END LOOP;

        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory purge reference_time='
          || TO_CHAR(rt, 'YYYY-MM-DD"T"HH24:MI:SS.FF3TZH:TZM')
        );
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory purge run_deadline='
          || TO_CHAR(dl, 'YYYY-MM-DD"T"HH24:MI:SS.FF3TZH:TZM')
        );
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory purge runtime_limit_reached='
          || CASE WHEN tl = 1 THEN 'true' ELSE 'false' END
        );
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory purge deleted message chunk rows='
          || TO_CHAR(mc)
        );
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory purge deleted fact/guideline/memory/preference chunk rows='
          || TO_CHAR(xc)
        );
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory purge deleted message rows='
          || TO_CHAR(mr)
        );
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory purge deleted fact/guideline/memory/preference rows='
          || TO_CHAR(xr)
        );
      END;
    ]',
    start_date => SYSTIMESTAMP,
    repeat_interval => 'FREQ=DAILY;INTERVAL=1',
    enabled => FALSE,
    auto_drop => FALSE,
    comments => 'OracleAgentMemory expired-record purge'
  );
  DBMS_SCHEDULER.SET_ATTRIBUTE(
    name => 'APP_SCHEMA.PURGE_EXPIRED_RECORDS_J',
    attribute => 'logging_level',
    value => DBMS_SCHEDULER.LOGGING_RUNS
  );
  DBMS_SCHEDULER.SET_ATTRIBUTE(
    name => 'APP_SCHEMA.PURGE_EXPIRED_RECORDS_J',
    attribute => 'store_output',
    value => TRUE
  );
  DBMS_SCHEDULER.SET_ATTRIBUTE(
    name => 'APP_SCHEMA.PURGE_EXPIRED_RECORDS_J',
    attribute => 'schedule_limit',
    value => INTERVAL '1 00:00:00' DAY TO SECOND
  );
  DBMS_SCHEDULER.ENABLE('APP_SCHEMA.PURGE_EXPIRED_RECORDS_J');
END;
/

The orphaned-chunk cleanup runs as a separate daily job:

BEGIN
  DBMS_SCHEDULER.CREATE_JOB(
    job_name => 'APP_SCHEMA.PURGE_ORPHANED_CHUNKS_J',
    job_type => 'PLSQL_BLOCK',
    job_action => q'[
      DECLARE
        bs CONSTANT PLS_INTEGER := 1000;
        n PLS_INTEGER;
        oc PLS_INTEGER := 0;
        tl PLS_INTEGER := 0;
        rt TIMESTAMP(6) WITH TIME ZONE := SYSTIMESTAMP;
        dl TIMESTAMP(6) WITH TIME ZONE := rt + INTERVAL '1 00:00:00' DAY TO SECOND;
      BEGIN
        LOOP
          IF SYSTIMESTAMP >= dl THEN
            tl := 1;
            EXIT;
          END IF;
          DELETE FROM APP_SCHEMA.RECORD_CHUNKS c
          WHERE c.rowid IN (
            SELECT rid
            FROM (
              SELECT c.rowid AS rid
              FROM APP_SCHEMA.RECORD_CHUNKS c
              WHERE NOT (
                (
                  c.source_record_type = 'message'
                  AND EXISTS (
                    SELECT 1
                    FROM APP_SCHEMA.MESSAGE m
                    WHERE m.record_id = c.source_id
                  )
                )
                OR (
                  c.source_record_type IN ('fact', 'guideline', 'memory', 'preference')
                  AND EXISTS (
                    SELECT 1
                    FROM APP_SCHEMA.MEMORY m
                    WHERE m.record_id = c.source_id
                      AND m.memory_type = c.source_record_type
                  )
                )
                OR (
                  c.source_record_type = 'user_profile'
                  AND EXISTS (
                    SELECT 1
                    FROM APP_SCHEMA.ACTOR_PROFILE a
                    WHERE a.actor_id = c.source_id
                      AND a.actor_type = 'user'
                  )
                )
                OR (
                  c.source_record_type = 'agent_profile'
                  AND EXISTS (
                    SELECT 1
                    FROM APP_SCHEMA.ACTOR_PROFILE a
                    WHERE a.actor_id = c.source_id
                      AND a.actor_type = 'agent'
                  )
                )
              )
              FETCH FIRST bs ROWS ONLY
            )
          );
          n := SQL%ROWCOUNT;
          oc := oc + n;
          EXIT WHEN n = 0;
          COMMIT;
        END LOOP;
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory orphan cleanup reference_time='
          || TO_CHAR(rt, 'YYYY-MM-DD"T"HH24:MI:SS.FF3TZH:TZM')
        );
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory orphan cleanup run_deadline='
          || TO_CHAR(dl, 'YYYY-MM-DD"T"HH24:MI:SS.FF3TZH:TZM')
        );
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory orphan cleanup runtime_limit_reached='
          || CASE WHEN tl = 1 THEN 'true' ELSE 'false' END
        );
        DBMS_OUTPUT.PUT_LINE(
          'OracleAgentMemory orphan cleanup deleted chunk rows='
          || TO_CHAR(oc)
        );
      END;
    ]',
    start_date => SYSTIMESTAMP,
    repeat_interval => 'FREQ=DAILY;INTERVAL=1',
    enabled => FALSE,
    auto_drop => FALSE,
    comments => 'OracleAgentMemory orphaned-chunk cleanup'
  );
  DBMS_SCHEDULER.SET_ATTRIBUTE(
    name => 'APP_SCHEMA.PURGE_ORPHANED_CHUNKS_J',
    attribute => 'logging_level',
    value => DBMS_SCHEDULER.LOGGING_RUNS
  );
  DBMS_SCHEDULER.SET_ATTRIBUTE(
    name => 'APP_SCHEMA.PURGE_ORPHANED_CHUNKS_J',
    attribute => 'store_output',
    value => TRUE
  );
  DBMS_SCHEDULER.SET_ATTRIBUTE(
    name => 'APP_SCHEMA.PURGE_ORPHANED_CHUNKS_J',
    attribute => 'schedule_limit',
    value => INTERVAL '1 00:00:00' DAY TO SECOND
  );
  DBMS_SCHEDULER.ENABLE('APP_SCHEMA.PURGE_ORPHANED_CHUNKS_J');
END;
/

It is useful to confirm the scheduler definition and inspect recent run history. Replace APP_SCHEMA if needed and apply the configured table-name prefix to the job name when your deployment uses one.

Check that the managed purge job exists and kept the expected inspection settings:

SELECT owner,
       job_name,
       enabled,
       state,
       repeat_interval,
       schedule_limit,
       logging_level,
       store_output,
       start_date,
       last_start_date,
       next_run_date
FROM   all_scheduler_jobs
WHERE  owner = 'APP_SCHEMA'
AND    job_name IN (
         'PURGE_EXPIRED_RECORDS_J',
         'PURGE_ORPHANED_CHUNKS_J'
       );

Check recent runs when you need to confirm that purge executions are happening and finishing successfully:

SELECT log_date,
       status,
       run_duration,
       output,
       additional_info
FROM   all_scheduler_job_run_details
WHERE  owner = 'APP_SCHEMA'
AND    job_name IN (
         'PURGE_EXPIRED_RECORDS_J',
         'PURGE_ORPHANED_CHUNKS_J'
       )
ORDER BY log_date DESC;

Run either job once manually if you want to validate the installation immediately:

BEGIN
  DBMS_SCHEDULER.RUN_JOB(
    job_name => 'APP_SCHEMA.PURGE_EXPIRED_RECORDS_J',
    use_current_session => TRUE
  );
END;
/

Conclusion

In this guide we learned how schema-level retention defaults, per-record ttl_days values, and TimeToLiveAnchor work together, how the managed jobs remove expired and orphaned rows, and how to refresh existing expirations from Python.

→ Having learned how to control record lifetime, you may now proceed to Use Metadata and Metadata Filtering.

Full Code

Copy the complete code that follows.

#Copyright © 2026 Oracle and/or its affiliates.
#This software is under the Apache License 2.0
#(LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License
#(UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option.

#Oracle Agent Memory Code Example - Use Time-to-Live for Messages and Memories
#-----------------------------------------------------------------------------

##Create a retention aware client

from datetime import datetime, timezone

import oracledb

from oracleagentmemory.apis import TimeToLiveAnchor
from oracleagentmemory.core import (
    MemoryRetentionConfig,
    OracleAgentMemory,
    SchemaPolicy,
)
from oracleagentmemory.core.embedders.embedder import Embedder
from oracleagentmemory.core.llms.llm import Llm

embedder = Embedder(model="YOUR_EMBEDDING_MODEL")
llm = Llm(
    model="YOUR_MEMORY_LLM_MODEL",
    api_base="YOUR_MEMORY_LLM_BASE_URL",
    api_key="YOUR_MEMORY_LLM_API_KEY",
)
db_pool = oracledb.SessionPool(
    user="YOUR DB USER",
    password="YOUR DB PASSWORD",
    dsn="localhost:1521/...",
)
memory_store_id = "T_TIME_TO_LIVE"

memory = OracleAgentMemory(
    connection=db_pool,
    embedder=embedder,
    llm=llm,
    schema_policy=SchemaPolicy.CREATE_IF_NECESSARY,
    memory_store_id=memory_store_id,
    memory_retention_config=MemoryRetentionConfig(
        default_ttl_days=30,
        max_ttl_days=90,
    ),
)

##Add messages and memories with time to live

ttl_thread = memory.create_thread(
    thread_id="ttl_demo_thread",
    user_id="user_123",
)

message_ids = ttl_thread.add_messages(
    [
        {
            "id": "msg-ttl-1",
            "role": "user",
            "content": (
                "I opened ticket 1042 yesterday because the laptop battery failed."
            ),
            "timestamp": "2026-04-01T09:00:00Z",
        },
        {
            "id": "msg-ttl-2",
            "role": "assistant",
            "content": (
                "I will keep ticket 1042 active and send a replacement checklist."
            ),
            "timestamp": "2026-04-01T09:01:00Z",
        },
    ]
)

timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
#^ for instance, 2026-04-01T09:00:00Z

backfilled_memory_id = ttl_thread.add_memory(
    "Ticket 1042 battery-failure report was filed on 2026-04-01.",
    memory_id="mem-ttl-backfill",
    timestamp=timestamp,
    ttl_days=90,
    ttl_anchor=TimeToLiveAnchor.TIMESTAMP,
)

short_lived_memory_id = ttl_thread.add_memory(
    "Replacement checklist should be revisited within one week.",
    memory_id="mem-ttl-short",
    ttl_days=7,
)

print(message_ids)
print(backfilled_memory_id)
print(short_lived_memory_id)
#['msg-ttl-1', 'msg-ttl-2']
#mem-ttl-backfill
#mem-ttl-short

##Refresh time to live on existing records

ttl_thread.update_message("msg-ttl-1", ttl_days=14)

ttl_thread.update_memory(
    "mem-ttl-backfill",
    ttl_days=60,
    ttl_anchor=TimeToLiveAnchor.TIMESTAMP,
)