State across runs with PropertiesService
Store state across Apps Script runs with PropertiesService, CacheService, and LockService for durable script properties, per-user keys, and caches.
Read and validate stored state, update it under a shared lock, and tell saved properties apart from rebuildable caches, memoized results, and uncertain external outcomes.
Remember a decision without repeating an action
Save a seat-limit policy and three request decisions between editor runs. Change the policy for future requests while keeping earlier decisions, and save a display preference for each user.
As Fetch data from web APIs showed, local variables and top-level bindings in a .gs file exist only for one execution. A const declaration prevents reassignment within its scope, and a top-level let resets when the execution ends. To keep state across runs, a script must read and write a store outside the execution.
This project stores the durable record in Script Properties, keeps each user's display preference in User Properties, and caches a disposable summary in the Script cache. It also uses a script lock so two runs of this project cannot read and update the shared record at the same time. If the cache entry is evicted, the script rebuilds the summary from Script Properties.
Choose who shares each value
A property store is a collection of named string values. A key identifies one value within that store, and the same key text in a different store refers to different data. Google's Properties guide describes three stores:
| Store | Who shares it | Use in this lesson |
|---|---|---|
| Script Properties | Users of this script project | One shared policy, revision, and three-request work record |
| User Properties | The current or effective user within this script project | That user's compact or detailed display preference |
| Document Properties | Users of this script in its supported document context | Not used by this standalone project |
According to the PropertiesService reference, Document Properties are available only to a script that is bound to a Google file, or that is published and running as an add-on. getDocumentProperties() returns null when the store is unavailable. Knowing a spreadsheet ID or having its browser tab open does not give a standalone project that context. Document Properties also belong to this script alone. A different script that opens the same document cannot use them as a shared database.
Script Properties belong to one script project. A copied or newly created project starts with its own empty store. Changing which getter you call changes who shares the value.
For a manual run from the Apps Script editor, the execution user is the signed-in person who clicks Run. An installable trigger runs as the user who created it, and a web app's execution user depends on its deployment settings. Google's execution-identity table lists these cases.
A User Properties preference belongs to that execution user. For an installable trigger, that is the trigger's creator, even if another person fired it. For a web app that executes as its owner, the preference belongs to the owner rather than the visitor.
The key prefix book.state-across-runs. is a naming convention, a namespace, that keeps these keys from colliding with other keys in the project. The periods are ordinary characters and do not create folders or access controls. Anyone with edit access to the script project can read Script Properties and change code that later runs as other users. Neither Script Properties nor User Properties hides a secret from project editors.
Store the policy and decisions in a versioned record
The shared record stores the policy and request queue under one JSON key. Its schema defines the expected fields, types, and bounds:
| Field | Meaning |
|---|---|
version | The stored data format this code understands: 1 |
revision | A counter that increases each time the shared policy or work record changes. Starts at 1 and stops at 100. |
seatLimit | The current policy, an integer from 1 through 10. Starts at 5. |
work | Three requests in fixed order, each with an ID, seat count, decision, and recorded limit |
The initial requests are R-101 (2 seats), R-102 (6 seats), and R-103 (4 seats). Each starts with decision: 'pending' and limitUsed: null. Planning a request sets its decision to 'fits' or 'tooLarge' and records the seatLimit that was applied in limitUsed. The work array always holds those three request records.
Properties stores every value as a string. Passing a non-string to setProperty converts it to a string and loses its structure, so the script calls JSON.stringify before writing and JSON.parse after reading. A numeric seatLimit is written as a JSON number inside that string and parsed back as a number.
JSON.stringify omits undefined and function properties, and it throws on a circular reference. It also serializes a Date as an ISO string that parsing does not turn back into a Date. This schema uses only plain objects, arrays, fixed strings, integers within fixed ranges, and null.
A missing property is different from an invalid one. getProperty returns null for an absent key. The script throws an error for empty text, malformed JSON, a wrong type, an unsupported version, extra or missing fields, changed initial requests, and inconsistent decisions. initializePractice creates an absent key from the baseline record and refuses to overwrite an existing malformed value.
Install the complete standalone project
const SEAT_LIMIT_TO_SAVE = 5;
const VIEW_TO_SAVE = 'compact';
/** @type {boolean} */
const DELETE_PRACTICE_STATE = false;
const STATE_KEY = 'book.state-across-runs.shared';
const USER_KEY = 'book.state-across-runs.preference';
const SUMMARY_KEY = 'book.state-across-runs.summary.v1';
const SAMPLE_REQUESTS = [
{ id: 'R-101', seats: 2 },
{ id: 'R-102', seats: 6 },
{ id: 'R-103', seats: 4 }
];
/**
* @typedef {'pending' | 'fits' | 'tooLarge'} PracticeDecision_
*/
/**
* @typedef {{id: string, seats: number, decision: PracticeDecision_, limitUsed: number | null}} PracticeRequest_
*/
/**
* @typedef {{version: number, revision: number, seatLimit: number, work: PracticeRequest_[]}} PracticeState_
*/
/**
* @typedef {{version: number, view: string}} PracticePreference_
*/
/**
* @typedef {{pending: number, fits: number, tooLarge: number}} PracticeSummary_
*/
function initializePractice() {
withPracticeLock(() => {
const sharedStore = PropertiesService.getScriptProperties();
const userStore = PropertiesService.getUserProperties();
const sharedText = sharedStore.getProperty(STATE_KEY);
const userText = userStore.getProperty(USER_KEY);
let state;
let preference;
if (sharedText === null) {
state = {
version: 1,
revision: 1,
seatLimit: 5,
work: SAMPLE_REQUESTS.map((request) => {
return { id: request.id, seats: request.seats, decision: 'pending', limitUsed: null };
})
};
} else {
state = decodeState(sharedText);
}
if (userText === null) {
preference = { version: 1, view: 'compact' };
} else {
preference = decodePreference(userText);
}
// Validate both stores before any initialization write. Existing data is never reset here.
validateState(state);
validatePreference(preference);
if (sharedText === null) {
sharedStore.setProperty(STATE_KEY, encodeRecord(state));
Logger.log('Shared state created; revision 1.');
} else {
Logger.log('Shared state preserved; revision ' + state.revision + '.');
}
if (userText === null) {
userStore.setProperty(USER_KEY, encodeRecord(preference));
Logger.log('My view created: compact.');
} else {
Logger.log('My view preserved: ' + preference.view + '.');
}
});
}
function inspectPracticeState() {
withPracticeLock(() => {
const text = PropertiesService.getScriptProperties().getProperty(STATE_KEY);
const state = decodeState(text);
const preference = decodePreference(PropertiesService.getUserProperties().getProperty(USER_KEY));
const summary = cachedSummary(text, state);
Logger.log('Shared revision: ' + state.revision + '; seat limit: ' + state.seatLimit);
Logger.log('My view: ' + preference.view);
Logger.log('Requests: pending=' + summary.pending + '; fits=' + summary.fits + '; tooLarge=' + summary.tooLarge);
if (preference.view === 'detailed') {
Logger.log('Work: ' + JSON.stringify(state.work));
}
});
}
function saveSeatLimit() {
if (!boundedInteger(SEAT_LIMIT_TO_SAVE, 1, 10)) {
throw new Error('Set SEAT_LIMIT_TO_SAVE to a whole number from 1 to 10.');
}
withPracticeLock(() => {
const store = PropertiesService.getScriptProperties();
const state = decodeState(store.getProperty(STATE_KEY));
if (state.seatLimit === SEAT_LIMIT_TO_SAVE) {
Logger.log('Seat limit unchanged; revision ' + state.revision + '.');
return;
}
advanceRevision(state);
state.seatLimit = SEAT_LIMIT_TO_SAVE;
writeState(store, state);
Logger.log('Saved seat limit ' + state.seatLimit + '; revision ' + state.revision + '.');
});
}
function planNextRequest() {
withPracticeLock(() => {
const store = PropertiesService.getScriptProperties();
const state = decodeState(store.getProperty(STATE_KEY));
for (const request of state.work) {
if (request.decision === 'pending') {
advanceRevision(state);
request.limitUsed = state.seatLimit;
request.decision = decisionFor(request.seats, state.seatLimit);
writeState(store, state);
Logger.log('Planned ' + request.id + ': ' + request.decision + '; revision ' + state.revision + '. No booking or message was made.');
return;
}
}
Logger.log('No pending requests; revision ' + state.revision + ' unchanged.');
});
}
function saveMyView() {
if (VIEW_TO_SAVE !== 'compact' && VIEW_TO_SAVE !== 'detailed') {
throw new Error('Set VIEW_TO_SAVE to compact or detailed.');
}
withPracticeLock(() => {
const store = PropertiesService.getUserProperties();
const preference = decodePreference(store.getProperty(USER_KEY));
if (preference.view === VIEW_TO_SAVE) {
Logger.log('My view unchanged: ' + preference.view + '.');
return;
}
preference.view = VIEW_TO_SAVE;
validatePreference(preference);
store.setProperty(USER_KEY, encodeRecord(preference));
Logger.log('Saved my view: ' + preference.view + '. Shared state was not changed.');
});
}
function forgetPracticeSummary() {
withPracticeLock(() => {
removeSummaryCache();
Logger.log('No property keys were changed.');
});
}
function removePracticeState() {
if (DELETE_PRACTICE_STATE !== true) {
throw new Error('Inspect the disposable project, then set DELETE_PRACTICE_STATE to true to delete only this exercise state.');
}
withPracticeLock(() => {
// Deliberately allow exact-key cleanup even when a stored exercise value is malformed.
PropertiesService.getScriptProperties().deleteProperty(STATE_KEY);
PropertiesService.getUserProperties().deleteProperty(USER_KEY);
Logger.log('Removed the shared exercise key and my preference key; unrelated keys and other users\' preferences were not touched.');
removeSummaryCache();
});
}
/**
* @template T
* @param {() => T} operation
* @returns {T}
*/
function withPracticeLock(operation) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(3000)) {
throw new Error('Practice state is busy. No state operation started; inspect running executions before trying again.');
}
try {
return operation();
} finally {
lock.releaseLock();
}
}
/**
* @param {string | null} text
* @param {string} label
*/
function parseRecord(text, label) {
if (text === null) {
throw new Error(label + ' is missing. Run initializePractice in this project as the current user.');
}
if (typeof text !== 'string' || text.length === 0 || text.length > 2048) {
throw new Error(label + ' must be a nonempty JSON string of at most 2048 characters. Stop and inspect; nothing was reset.');
}
let record;
try {
record = JSON.parse(text);
} catch (error) {
throw new Error(label + ' is not valid JSON. Stop and inspect; nothing was reset.');
}
if (!record || typeof record !== 'object' || Array.isArray(record) || record.version !== 1) {
throw new Error(label + ' has an unsupported version or record shape. Expected version 1; do not reset or migrate it silently.');
}
return record;
}
/**
* @param {string | null} text
*/
function decodeState(text) {
const state = parseRecord(text, 'Shared practice state');
validateState(state);
return state;
}
/**
* @param {PracticeState_} state
*/
function validateState(state) {
if (!hasFields(state, ['version', 'revision', 'seatLimit', 'work']) || state.version !== 1 ||
!boundedInteger(state.revision, 1, 100) || !boundedInteger(state.seatLimit, 1, 10) ||
!Array.isArray(state.work) || state.work.length !== SAMPLE_REQUESTS.length) {
throw new Error('Shared practice state has invalid fields or bounds. Stop and inspect; nothing was reset.');
}
let index = 0;
let planned = 0;
let pendingSeen = false;
for (const request of state.work) {
const sample = SAMPLE_REQUESTS[index];
if (!hasFields(request, ['id', 'seats', 'decision', 'limitUsed']) ||
request.id !== sample.id || request.seats !== sample.seats) {
throw new Error('Stored work must contain the three unchanged sample requests in order.');
}
if (request.decision === 'pending') {
if (request.limitUsed !== null) throw new Error('A pending request must have a null limitUsed.');
pendingSeen = true;
} else {
if (pendingSeen || !boundedInteger(request.limitUsed, 1, 10) ||
request.decision !== decisionFor(request.seats, request.limitUsed)) {
throw new Error('Stored decisions must form a planned prefix and match their recorded seat limits.');
}
planned = planned + 1;
}
index = index + 1;
}
if (state.revision < planned + 1) throw new Error('Stored revision cannot precede its planned work.');
}
/**
* @param {string | null} text
*/
function decodePreference(text) {
const preference = parseRecord(text, 'My practice preference');
validatePreference(preference);
return preference;
}
/**
* @param {PracticePreference_} preference
*/
function validatePreference(preference) {
if (!hasFields(preference, ['version', 'view']) || preference.version !== 1 ||
(preference.view !== 'compact' && preference.view !== 'detailed')) {
throw new Error('My practice preference has invalid fields. Stop and inspect; nothing was reset.');
}
}
/**
* @param {unknown} value
* @param {string[]} fields
* @returns {boolean}
*/
function hasFields(value, fields) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Object.keys(value);
if (keys.length !== fields.length) return false;
for (const key of keys) {
if (!fields.includes(key)) return false;
}
return true;
}
/**
* @param {unknown} value
* @param {number} minimum
* @param {number} maximum
* @returns {value is number}
*/
function boundedInteger(value, minimum, maximum) {
return typeof value === 'number' && Number.isInteger(value) &&
value >= minimum && value <= maximum;
}
/**
* @param {number} seats
* @param {number} limit
* @returns {PracticeDecision_}
*/
function decisionFor(seats, limit) {
if (seats <= limit) return 'fits';
return 'tooLarge';
}
/**
* @param {PracticeState_} state
*/
function advanceRevision(state) {
if (state.revision >= 100) throw new Error('Practice revision limit reached. Inspect and deliberately clean up the exercise before restarting.');
state.revision = state.revision + 1;
}
/**
* @param {unknown} record
* @returns {string}
*/
function encodeRecord(record) {
const text = JSON.stringify(record);
if (text.length > 2048) throw new Error('Practice record exceeds the 2048-character write limit.');
return text;
}
/**
* @param {PropertiesService.Properties} store
* @param {PracticeState_} state
*/
function writeState(store, state) {
validateState(state);
store.setProperty(STATE_KEY, encodeRecord(state));
}
/**
* @param {string | null} source
* @param {PracticeState_} state
* @returns {PracticeSummary_}
*/
function cachedSummary(source, state) {
let cache = null;
let text = null;
try {
cache = CacheService.getScriptCache();
text = cache.get(SUMMARY_KEY);
} catch (error) {
cache = null;
Logger.log('Cache read unavailable; rebuilding from properties.');
}
if (text !== null && text.length <= 8192) {
let entry;
try {
entry = JSON.parse(text);
} catch (error) {
entry = null;
}
if (hasFields(entry, ['version', 'source', 'summary']) && entry.version === 1 &&
entry.source === source && validSummary(entry.summary)) {
Logger.log('Summary source: cache hit.');
return entry.summary;
}
}
const summary = { pending: 0, fits: 0, tooLarge: 0 };
for (const request of state.work) {
summary[request.decision] = summary[request.decision] + 1;
}
Logger.log('Summary source: rebuilt from properties.');
if (cache !== null) {
try {
cache.put(SUMMARY_KEY, JSON.stringify({ version: 1, source: source, summary: summary }), 60);
} catch (error) {
Logger.log('Cache write unavailable; the rebuilt summary is still usable.');
}
}
return summary;
}
/**
* @param {PracticeSummary_} summary
* @returns {boolean}
*/
function validSummary(summary) {
return hasFields(summary, ['pending', 'fits', 'tooLarge']) &&
boundedInteger(summary.pending, 0, 3) && boundedInteger(summary.fits, 0, 3) &&
boundedInteger(summary.tooLarge, 0, 3) && summary.pending + summary.fits + summary.tooLarge === 3;
}
function removeSummaryCache() {
try {
CacheService.getScriptCache().remove(SUMMARY_KEY);
Logger.log('Exercise summary cache removal requested.');
} catch (error) {
Logger.log('Cache removal unavailable. Any leftover entry is disposable, not authoritative state.');
}
}
{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": []
}
At
script.google.com, create a new standalone project namedRequest state practice.Replace the contents of
Code.gswith the complete file above.In Project Settings, enable Show "appsscript.json" manifest file in editor, replace
appsscript.jsonwith the project manifest, and save both files. Keep the V8 runtime and the emptyoauthScopesarray.Leave
SEAT_LIMIT_TO_SAVE = 5,VIEW_TO_SAVE = 'compact', andDELETE_PRACTICE_STATE = false.Select
initializePracticeand click Run, then selectinspectPracticeStateand run it.
PropertiesService, CacheService, and LockService require no OAuth scopes, so oauthScopes is [] and running the project shows no consent screen.
On a fresh initialization, expect:
Shared state created; revision 1.
My view created: compact. The inspection should include:
Shared revision: 1; seat limit: 5
My view: compact
Requests: pending=3; fits=0; tooLarge=0 You will also see a Summary source: line (cache hit or rebuilt from properties). It reports where the counts came from and does not affect the stored state.
Run initializePractice again before changing anything. It reports that shared revision 1 and your compact view were kept, not reset. Script Properties and User Properties are separate stores. If the shared write succeeds and the user-preference write then fails, running initializePractice again keeps the shared record and creates only the missing preference.
Read, change, then explicitly save
The Properties reference distinguishes reading a copy from updating storage. Parsing the saved text gives this execution a local object. Assigning state.seatLimit or a request's decision changes that local object in memory only. writeState validates the modified record, serializes it, and calls setProperty to save the replacement string.
setProperty replaces the entire string stored under a key. It does not merge JSON fields. So the script reads the full record, updates the target fields, and writes the full record back, all while holding the script lock.
A multi-key write is a separate operation. setProperties(values), or setProperties(values, false), updates the supplied keys and leaves the other keys in the store intact. setProperties(values, true) and deleteAllProperties() delete every other key in that store. This project reads, writes, and deletes only its own named keys.
The SEAT_LIMIT_TO_SAVE and VIEW_TO_SAVE constants supply the inputs to saveSeatLimit and saveMyView. Editing either constant in Code.gs takes effect only when you run its save function.
Change the policy for the next request
Follow this sequence from the fresh baseline, with no other users running the project:
Run
planNextRequestonce. It recordsR-101asfitsusing limit5and advancesrevisionto2. RuninspectPracticeState. It reports two pending requests and one that fits.Change
SEAT_LIMIT_TO_SAVEfrom5to6and saveCode.gs.R-102requests 6 seats, which exceeds the old limit of 5 but fits the new limit of 6.Run
saveSeatLimit. ExpectSaved seat limit 6; revision 3.RunsaveSeatLimita second time without editingCode.gs. It logsSeat limit unchanged; revision 3.and does not advance the revision.Run
planNextRequestonce.R-102becomesfitsat revision4, leaving one pending request and two that fit.Restore
SEAT_LIMIT_TO_SAVE = 5, saveCode.gs, and runsaveSeatLimit. Expect revision5. A newseatLimitapplies only to requests that are stillpending, soR-102keepslimitUsed: 6.Run
planNextRequestonce more.R-103(4 seats) fits limit5, advancingrevisionto6with zero pending requests and three that fit. RunningplanNextRequestagain reports no pending requests and leavesrevisionat6.
planNextRequest updates at most one pending request per run and returns right after writing. The next run reads the saved decisions and moves on to the next pending record. If a run ends with an uncertain outcome, run inspectPracticeState before retrying planNextRequest to check whether the write already succeeded.
decodeState checks each decided request against its own recorded limitUsed, not the current seatLimit. Decided requests must come before every pending entry in work.
Keep your view separate from the shared policy
Change VIEW_TO_SAVE from 'compact' to 'detailed', save Code.gs, and run saveMyView. Run inspectPracticeState again. The summary lines still report the same shared revision and counts. A further Work: JSON line shows each request with its recorded limitUsed: 5, 6, and 5, even though seatLimit is now 5.
Restore VIEW_TO_SAVE = 'compact', save, and run saveMyView. Later inspections omit the Work: line. Saving a view preference writes to the execution user's User Properties. It does not change Script Properties or advance the shared revision.
If a second authorized user runs initializePractice in this same project under their own account, the function keeps the shared policy and decisions and creates only that user's missing compact preference. Each user's saveMyView updates only their own User Properties, while saveSeatLimit and planNextRequest update the shared Script Properties that both accounts see.
The Script cache stores only shared data: the serialized shared record and its derived counts. inspectPracticeState applies the current user's preference when it formats the output, after reading or rebuilding the shared summary. One user's preference never reaches the shared cache entry.
Protect the whole read-modify-write step
Without coordination, two concurrent runs can both read revision 1 with R-101 pending, and the second write overwrites the first. Locking only the final setProperty call is not enough, because the second run computed its update from a stale read.
LockService.getScriptLock() returns a lock shared by all users of the script. tryLock(3000) tries to acquire it for up to 3,000 milliseconds. If that times out, withPracticeLock throws before it reads or writes Properties or CacheService.
A critical section is the sequence of operations that must not overlap with another cooperating execution: reading and validating the current record, computing the change, writing the updated record, and logging the result. Every entrypoint in this project acquires the same script lock, so only one of them can read and update the record at a time.
A User lock (getUserLock()) serializes executions only for one user and still allows other users' concurrent writes to shared Script Properties. A Document lock (getDocumentLock()) needs a bound document and returns null in a standalone project. See the LockService reference.
withPracticeLock(operation) runs operation() inside a try block and releases the lock in the finally block, whether operation() returns normally or throws. Google also documents automatic lock release when the script terminates, but an unexpected termination does not roll back partial writes.
The 3,000 ms argument limits only how long the script waits for the lock, not the total execution time. A script lock coordinates only code in this project that acquires it. It does not block manual edits to Script Properties in Project Settings or calls from other script projects.
Validate the saved format before using it
A manual edit in Project Settings or an older code version can leave a string that parses as JSON but breaks the expected schema. decodeState checks the schema version, exact field names, integer ranges, and consistency rules before any planner or policy update uses the record. It rejects a wrong type such as "5" rather than converting it.
hasFields uses Object.keys and includes to require an exact set of own property names, so both a missing and an unexpected field fail. That strict check fits a persisted record this project owns. The HTTP decoding in Fetch data from web APIs ignores extra fields instead, because an external API may add fields at any time.
boundedInteger checks that a value is an integer within an inclusive range. The validation loop also requires each request ID and seat count to match the three initial requests, and revision to be at least 1 plus the number of decided requests.
In the summary loop, summary[request.decision] uses computed property access to increment pending, fits, or tooLarge. That lookup works because decodeState has already verified that request.decision is one of those three strings.
If you evolve a persisted schema in your own projects, increment version and write an explicit migration that reads and validates version 1, transforms it to version 2, and writes the result under the script lock. This example supports only version 1 and stops on any other version number.
Rebuild a summary when the cache disappears
A cache holds a disposable derived value that can be recomputed from the saved state. CacheService provides a Script cache shared by users of the project, a User cache for the current user and project, and a Document cache that returns null in a standalone project. The request counts are shared across the project, so this example uses CacheService.getScriptCache().
Cache keys and values are strings. This project uses the key book.state-across-runs.summary.v1. Its value is a JSON string containing:
The cache format version.
The exact serialized Script Properties string (
source).The derived counts.
The entry has a time to live (TTL) of 60 seconds. The Cache reference accepts TTL values from 1 through 21600 seconds (6 hours). An entry can be evicted before its TTL expires, so a caller must always handle null from cache.get().
Every inspectPracticeState run reads and validates Script Properties first. It reuses the cached summary only if its cache version, summary structure, and source string match the current property record. If you change the derived calculation or the summary structure in code, change the cache key or version so old entries are ignored.
When the cache entry matches, inspectPracticeState logs Summary source: cache hit. Otherwise it recomputes the counts, writes the cache, and logs Summary source: rebuilt from properties. A cache read or write error logs a short warning and the run continues with the freshly computed summary. A malformed Script Properties record throws and stops the execution.
To observe cache invalidation:
With valid initialized state, run
inspectPracticeStatetwice.Run
forgetPracticeSummary. It removesbook.state-across-runs.summary.v1from the Script cache without touching Script Properties or User Properties.Run
inspectPracticeStateagain. ExpectSummary source: rebuilt from properties.with the same revision, policy, preference, and counts.
Cache reads and writes are not atomic, and an entry can disappear before its TTL, so never use CacheService as a completion checkpoint or as a lock.
Memoize deterministic work with an explicit key
Memoization caches a function's return value for a given input. Memoize only pure, deterministic calculations whose results can be recomputed on a cache miss without side effects. Never memoize an email send, a file creation, a random value, the current time, or a remote read whose result can change.
Build the cache key from a namespace, a version tag, and a cryptographic digest of the serialized arguments. Do not use function.toString(), because its output changes when comments or formatting change:
function memoizeJson(cache, namespace, args, ttlSeconds, compute) {
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 21600) {
throw new Error('ttlSeconds must be an integer from 1 through 21600.');
}
const input = JSON.stringify(args);
if (input === undefined) {
throw new Error('Memoized arguments must be JSON-serializable.');
}
const digest = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
input,
Utilities.Charset.UTF_8
);
const key = namespace + ':' + Utilities.base64EncodeWebSafe(digest);
const cached = cache.get(key);
if (cached !== null) {
return JSON.parse(cached);
}
const result = compute();
const encoded = JSON.stringify(result);
if (encoded === undefined) {
throw new Error('Memoized results must be JSON-serializable.');
}
cache.put(key, encoded, ttlSeconds);
return result;
}
function getMemoizedSummary(requests) {
return memoizeJson(
CacheService.getScriptCache(),
'request-summary.v2',
requests,
600,
() => summarizeRequests(requests)
);
} Replace summarizeRequests with the pure calculation whose JSON result you want to cache.
memoizeJson supports only JSON-serializable inputs and return values. JSON.stringify converts a Date to an ISO string, serializes a RegExp as {}, and depends on the order in which object keys were inserted. It also omits undefined and function properties on objects and converts them to null inside arrays. Validate input shapes before memoizing so that equivalent calls serialize to identical strings.
Two concurrent cache misses can both execute compute(). That is harmless for a fast calculation with no side effects. If recomputation is expensive, acquire a lock and check cache.get(key) again inside the lock before computing.
Rely on documented cache limits
Design against the documented Cache limits: a key is at most 250 characters, a value is at most 100 KB, and each cache holds up to 1,000 items. When a write exceeds 1,000 items, the service keeps the 900 entries farthest from expiration.
An experiment that inserts more than 1,000 entries with identical TTLs can look like first-in, first-out eviction, because the oldest entries are also closest to expiration. See the CacheService eviction experiment for details. Do not rely on insertion order, least-recently-used behavior, or a fixed eviction batch size. Any entry can be evicted at any time.
Pass string keys and values, and check that ttlSeconds is an integer from 1 through 21600 before calling put. Do not rely on undocumented type conversion or on the service accepting an out-of-range TTL.
Stay within a small storage budget
Google's current quotas limit PropertiesService to 9 KB per property value and 500 KB per property store. Daily read and write quotas are 50,000 for consumer accounts and 500,000 for Google Workspace accounts, and these numbers can change. Because one property cannot exceed 9 KB, keep small, fixed-size state in Properties and use a spreadsheet or database for a growing queue or audit log.
inspectPracticeState still reads Script Properties on a cache hit to verify the source string, so the cache does not reduce property reads. Skipping that check would save the read but could show an outdated summary.
This project caps each property JSON string at 2,048 characters, the work array at 3 records, and revision at 100. It also caps the cached summary string at 8,192 characters. JavaScript .length counts UTF-16 code units, not UTF-8 bytes, so non-ASCII text can take more bytes than its length suggests.
Detect and repair a corrupted record
To verify that decodeState and initializePractice reject a corrupted or unsupported record instead of overwriting it:
Make sure no executions are running, then open Project Settings and find
book.state-across-runs.sharedunder Script Properties. Copy its complete JSON value to a local note so you can restore it.Edit
book.state-across-runs.sharedin Project Settings, change"version":1to"version":0, and save. RuninspectPracticeState. It throws an unsupported-version error instead of returning a default state or a cached summary.Run
initializePractice. It also throws an unsupported-version error and does not overwrite the existing property.Restore the exact original JSON string from your note, save the property, and run
inspectPracticeStateto confirm that the saved revision and decisions are intact.To see how malformed JSON differs from an unsupported version, remove the final
}frombook.state-across-runs.shared, save, and runinspectPracticeStateto see the JSON parse error. Restore the complete original JSON string at once and runinspectPracticeStateto verify recovery.
Project Settings shows only Script Properties. To inspect or repair User Properties, run code as that user.
Distinguish a local plan from an external checkpoint
In this project, the property write is the entire state transition. If a run fails, inspecting book.state-across-runs.shared shows whether the update took effect. Even without an external system, a multi-step routine such as initializePractice or removePracticeState makes separate calls to Script Properties, User Properties, and CacheService. It can stop partway through without rolling back the earlier calls.
When a workflow also changes an external system, such as creating a Drive file or sending an email, the local state and the remote action cannot be updated in one transaction. A pending marker written beforehand records only that the action was planned. A completion marker written afterward can fail after the remote action has already succeeded.
External workflows therefore need three states: planned work, confirmed completion, and an uncertain outcome. For an uncertain outcome, check the target service's actual state or the saved resource ID before retrying.
Remove only the exercise's keys
To remove the keys this chapter created:
In
Code.gs, changeDELETE_PRACTICE_STATEfromfalsetotrue, save, and runremovePracticeState. It deletesbook.state-across-runs.sharedfrom Script Properties, deletesbook.state-across-runs.preferencefrom the current user's User Properties, and removesbook.state-across-runs.summary.v1from the Script cache.Restore
DELETE_PRACTICE_STATE = falseand saveCode.gs.Run
inspectPracticeStateand confirm that it throws a missing-state error and does not rebuild state from the cache.
If a second user also ran the exercise under their own account, they must run removePracticeState as that account to delete their own User Properties key. Next, install time-driven triggers to run a repeat-safe workflow on a clock schedule.
Project files
Complete source files for this chapter's examples.
Keep a request policy and local work record between runs
Code.gs
Download fileconst SEAT_LIMIT_TO_SAVE = 5;
const VIEW_TO_SAVE = 'compact';
/** @type {boolean} */
const DELETE_PRACTICE_STATE = false;
const STATE_KEY = 'book.state-across-runs.shared';
const USER_KEY = 'book.state-across-runs.preference';
const SUMMARY_KEY = 'book.state-across-runs.summary.v1';
const SAMPLE_REQUESTS = [
{ id: 'R-101', seats: 2 },
{ id: 'R-102', seats: 6 },
{ id: 'R-103', seats: 4 }
];
/**
* @typedef {'pending' | 'fits' | 'tooLarge'} PracticeDecision_
*/
/**
* @typedef {{id: string, seats: number, decision: PracticeDecision_, limitUsed: number | null}} PracticeRequest_
*/
/**
* @typedef {{version: number, revision: number, seatLimit: number, work: PracticeRequest_[]}} PracticeState_
*/
/**
* @typedef {{version: number, view: string}} PracticePreference_
*/
/**
* @typedef {{pending: number, fits: number, tooLarge: number}} PracticeSummary_
*/
function initializePractice() {
withPracticeLock(() => {
const sharedStore = PropertiesService.getScriptProperties();
const userStore = PropertiesService.getUserProperties();
const sharedText = sharedStore.getProperty(STATE_KEY);
const userText = userStore.getProperty(USER_KEY);
let state;
let preference;
if (sharedText === null) {
state = {
version: 1,
revision: 1,
seatLimit: 5,
work: SAMPLE_REQUESTS.map((request) => {
return { id: request.id, seats: request.seats, decision: 'pending', limitUsed: null };
})
};
} else {
state = decodeState(sharedText);
}
if (userText === null) {
preference = { version: 1, view: 'compact' };
} else {
preference = decodePreference(userText);
}
// Validate both stores before any initialization write. Existing data is never reset here.
validateState(state);
validatePreference(preference);
if (sharedText === null) {
sharedStore.setProperty(STATE_KEY, encodeRecord(state));
Logger.log('Shared state created; revision 1.');
} else {
Logger.log('Shared state preserved; revision ' + state.revision + '.');
}
if (userText === null) {
userStore.setProperty(USER_KEY, encodeRecord(preference));
Logger.log('My view created: compact.');
} else {
Logger.log('My view preserved: ' + preference.view + '.');
}
});
}
function inspectPracticeState() {
withPracticeLock(() => {
const text = PropertiesService.getScriptProperties().getProperty(STATE_KEY);
const state = decodeState(text);
const preference = decodePreference(PropertiesService.getUserProperties().getProperty(USER_KEY));
const summary = cachedSummary(text, state);
Logger.log('Shared revision: ' + state.revision + '; seat limit: ' + state.seatLimit);
Logger.log('My view: ' + preference.view);
Logger.log('Requests: pending=' + summary.pending + '; fits=' + summary.fits + '; tooLarge=' + summary.tooLarge);
if (preference.view === 'detailed') {
Logger.log('Work: ' + JSON.stringify(state.work));
}
});
}
function saveSeatLimit() {
if (!boundedInteger(SEAT_LIMIT_TO_SAVE, 1, 10)) {
throw new Error('Set SEAT_LIMIT_TO_SAVE to a whole number from 1 to 10.');
}
withPracticeLock(() => {
const store = PropertiesService.getScriptProperties();
const state = decodeState(store.getProperty(STATE_KEY));
if (state.seatLimit === SEAT_LIMIT_TO_SAVE) {
Logger.log('Seat limit unchanged; revision ' + state.revision + '.');
return;
}
advanceRevision(state);
state.seatLimit = SEAT_LIMIT_TO_SAVE;
writeState(store, state);
Logger.log('Saved seat limit ' + state.seatLimit + '; revision ' + state.revision + '.');
});
}
function planNextRequest() {
withPracticeLock(() => {
const store = PropertiesService.getScriptProperties();
const state = decodeState(store.getProperty(STATE_KEY));
for (const request of state.work) {
if (request.decision === 'pending') {
advanceRevision(state);
request.limitUsed = state.seatLimit;
request.decision = decisionFor(request.seats, state.seatLimit);
writeState(store, state);
Logger.log('Planned ' + request.id + ': ' + request.decision + '; revision ' + state.revision + '. No booking or message was made.');
return;
}
}
Logger.log('No pending requests; revision ' + state.revision + ' unchanged.');
});
}
function saveMyView() {
if (VIEW_TO_SAVE !== 'compact' && VIEW_TO_SAVE !== 'detailed') {
throw new Error('Set VIEW_TO_SAVE to compact or detailed.');
}
withPracticeLock(() => {
const store = PropertiesService.getUserProperties();
const preference = decodePreference(store.getProperty(USER_KEY));
if (preference.view === VIEW_TO_SAVE) {
Logger.log('My view unchanged: ' + preference.view + '.');
return;
}
preference.view = VIEW_TO_SAVE;
validatePreference(preference);
store.setProperty(USER_KEY, encodeRecord(preference));
Logger.log('Saved my view: ' + preference.view + '. Shared state was not changed.');
});
}
function forgetPracticeSummary() {
withPracticeLock(() => {
removeSummaryCache();
Logger.log('No property keys were changed.');
});
}
function removePracticeState() {
if (DELETE_PRACTICE_STATE !== true) {
throw new Error('Inspect the disposable project, then set DELETE_PRACTICE_STATE to true to delete only this exercise state.');
}
withPracticeLock(() => {
// Deliberately allow exact-key cleanup even when a stored exercise value is malformed.
PropertiesService.getScriptProperties().deleteProperty(STATE_KEY);
PropertiesService.getUserProperties().deleteProperty(USER_KEY);
Logger.log('Removed the shared exercise key and my preference key; unrelated keys and other users\' preferences were not touched.');
removeSummaryCache();
});
}
/**
* @template T
* @param {() => T} operation
* @returns {T}
*/
function withPracticeLock(operation) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(3000)) {
throw new Error('Practice state is busy. No state operation started; inspect running executions before trying again.');
}
try {
return operation();
} finally {
lock.releaseLock();
}
}
/**
* @param {string | null} text
* @param {string} label
*/
function parseRecord(text, label) {
if (text === null) {
throw new Error(label + ' is missing. Run initializePractice in this project as the current user.');
}
if (typeof text !== 'string' || text.length === 0 || text.length > 2048) {
throw new Error(label + ' must be a nonempty JSON string of at most 2048 characters. Stop and inspect; nothing was reset.');
}
let record;
try {
record = JSON.parse(text);
} catch (error) {
throw new Error(label + ' is not valid JSON. Stop and inspect; nothing was reset.');
}
if (!record || typeof record !== 'object' || Array.isArray(record) || record.version !== 1) {
throw new Error(label + ' has an unsupported version or record shape. Expected version 1; do not reset or migrate it silently.');
}
return record;
}
/**
* @param {string | null} text
*/
function decodeState(text) {
const state = parseRecord(text, 'Shared practice state');
validateState(state);
return state;
}
/**
* @param {PracticeState_} state
*/
function validateState(state) {
if (!hasFields(state, ['version', 'revision', 'seatLimit', 'work']) || state.version !== 1 ||
!boundedInteger(state.revision, 1, 100) || !boundedInteger(state.seatLimit, 1, 10) ||
!Array.isArray(state.work) || state.work.length !== SAMPLE_REQUESTS.length) {
throw new Error('Shared practice state has invalid fields or bounds. Stop and inspect; nothing was reset.');
}
let index = 0;
let planned = 0;
let pendingSeen = false;
for (const request of state.work) {
const sample = SAMPLE_REQUESTS[index];
if (!hasFields(request, ['id', 'seats', 'decision', 'limitUsed']) ||
request.id !== sample.id || request.seats !== sample.seats) {
throw new Error('Stored work must contain the three unchanged sample requests in order.');
}
if (request.decision === 'pending') {
if (request.limitUsed !== null) throw new Error('A pending request must have a null limitUsed.');
pendingSeen = true;
} else {
if (pendingSeen || !boundedInteger(request.limitUsed, 1, 10) ||
request.decision !== decisionFor(request.seats, request.limitUsed)) {
throw new Error('Stored decisions must form a planned prefix and match their recorded seat limits.');
}
planned = planned + 1;
}
index = index + 1;
}
if (state.revision < planned + 1) throw new Error('Stored revision cannot precede its planned work.');
}
/**
* @param {string | null} text
*/
function decodePreference(text) {
const preference = parseRecord(text, 'My practice preference');
validatePreference(preference);
return preference;
}
/**
* @param {PracticePreference_} preference
*/
function validatePreference(preference) {
if (!hasFields(preference, ['version', 'view']) || preference.version !== 1 ||
(preference.view !== 'compact' && preference.view !== 'detailed')) {
throw new Error('My practice preference has invalid fields. Stop and inspect; nothing was reset.');
}
}
/**
* @param {unknown} value
* @param {string[]} fields
* @returns {boolean}
*/
function hasFields(value, fields) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Object.keys(value);
if (keys.length !== fields.length) return false;
for (const key of keys) {
if (!fields.includes(key)) return false;
}
return true;
}
/**
* @param {unknown} value
* @param {number} minimum
* @param {number} maximum
* @returns {value is number}
*/
function boundedInteger(value, minimum, maximum) {
return typeof value === 'number' && Number.isInteger(value) &&
value >= minimum && value <= maximum;
}
/**
* @param {number} seats
* @param {number} limit
* @returns {PracticeDecision_}
*/
function decisionFor(seats, limit) {
if (seats <= limit) return 'fits';
return 'tooLarge';
}
/**
* @param {PracticeState_} state
*/
function advanceRevision(state) {
if (state.revision >= 100) throw new Error('Practice revision limit reached. Inspect and deliberately clean up the exercise before restarting.');
state.revision = state.revision + 1;
}
/**
* @param {unknown} record
* @returns {string}
*/
function encodeRecord(record) {
const text = JSON.stringify(record);
if (text.length > 2048) throw new Error('Practice record exceeds the 2048-character write limit.');
return text;
}
/**
* @param {PropertiesService.Properties} store
* @param {PracticeState_} state
*/
function writeState(store, state) {
validateState(state);
store.setProperty(STATE_KEY, encodeRecord(state));
}
/**
* @param {string | null} source
* @param {PracticeState_} state
* @returns {PracticeSummary_}
*/
function cachedSummary(source, state) {
let cache = null;
let text = null;
try {
cache = CacheService.getScriptCache();
text = cache.get(SUMMARY_KEY);
} catch (error) {
cache = null;
Logger.log('Cache read unavailable; rebuilding from properties.');
}
if (text !== null && text.length <= 8192) {
let entry;
try {
entry = JSON.parse(text);
} catch (error) {
entry = null;
}
if (hasFields(entry, ['version', 'source', 'summary']) && entry.version === 1 &&
entry.source === source && validSummary(entry.summary)) {
Logger.log('Summary source: cache hit.');
return entry.summary;
}
}
const summary = { pending: 0, fits: 0, tooLarge: 0 };
for (const request of state.work) {
summary[request.decision] = summary[request.decision] + 1;
}
Logger.log('Summary source: rebuilt from properties.');
if (cache !== null) {
try {
cache.put(SUMMARY_KEY, JSON.stringify({ version: 1, source: source, summary: summary }), 60);
} catch (error) {
Logger.log('Cache write unavailable; the rebuilt summary is still usable.');
}
}
return summary;
}
/**
* @param {PracticeSummary_} summary
* @returns {boolean}
*/
function validSummary(summary) {
return hasFields(summary, ['pending', 'fits', 'tooLarge']) &&
boundedInteger(summary.pending, 0, 3) && boundedInteger(summary.fits, 0, 3) &&
boundedInteger(summary.tooLarge, 0, 3) && summary.pending + summary.fits + summary.tooLarge === 3;
}
function removeSummaryCache() {
try {
CacheService.getScriptCache().remove(SUMMARY_KEY);
Logger.log('Exercise summary cache removal requested.');
} catch (error) {
Logger.log('Cache removal unavailable. Any leftover entry is disposable, not authoritative state.');
}
}
appsscript.json
Download file{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": []
}