diff --git a/.codecov.yml b/.codecov.yml
index 817c42208..45dd0ffa9 100644
--- a/.codecov.yml
+++ b/.codecov.yml
@@ -1,10 +1,157 @@
+# Codecov configuration for the Store 6 library family.
+#
+# Coverage is produced by Kover and aggregated by the `:coverage` module into a
+# single JaCoCo-compatible XML, uploaded by .github/workflows/ci.yml under the
+# flag `store6`. Upstream main's Store v5 lane uploads flag `unittests` into the
+# same Codecov project.
+#
+# IMPORTANT INSTRUMENTATION LIMIT: Kover instruments JVM bytecode only. Common
+# code is measured through its JVM compilation, but source sets that never reach
+# the JVM (appleMain, linuxMain, mingwMain, jsMain, wasmJsMain, androidMain) are
+# exercised by CI yet contribute no coverage data. Every threshold below is
+# calibrated around that fact.
+
+codecov:
+ # Do not comment or set statuses off a half-finished build; the coverage upload
+ # is the last step of build-and-test.
+ require_ci_to_pass: true
+ notify:
+ wait_for_ci: true
+
coverage:
range: 70..80
round: down
precision: 2
+ status:
+ # Both statuses are informational on purpose: they report on every PR but
+ # never block a merge. Rationale: with JVM-only instrumentation, a PR that
+ # adds Apple/Linux/JS/wasm source would show near-zero patch coverage and go
+ # red for a reason the author cannot fix. Establish a trusted baseline first;
+ # flipping either `informational: false` is a deliberate, separate decision.
+ project:
+ default:
+ target: auto
+ threshold: 1%
+ informational: true
+ patch:
+ default:
+ target: 80%
+ threshold: 0%
+ informational: true
+
+# Two upload flags exist: `store6` (this tree, aggregated by :coverage) and
+# `unittests` (upstream main's Store v5 lane). Carryforward keeps each flag's
+# last-known coverage when a commit uploads only the other flag.
+flag_management:
+ default_rules:
+ carryforward: true
+
+# One component per published Store 6 artifact. The list mirrors the `kover(...)`
+# dependencies in coverage/build.gradle.kts exactly — if a module is added there,
+# add it here too, or its coverage silently lands in no component.
+#
+# Components carry no statuses: 19 extra required checks per PR would drown the
+# signal. They exist to break the single `store6` number down per artifact in the
+# PR comment and to filter the Codecov UI. `ignore` below still wins over these
+# paths, so tests, samples, and demos stay out regardless of the globs.
+component_management:
+ default_rules:
+ statuses: []
+
+ individual_components:
+ - component_id: core
+ name: core
+ paths: [ "core/**" ]
+
+ - component_id: testing
+ name: testing
+ paths: [ "testing/**" ]
+
+ - component_id: mutations
+ name: mutations
+ paths: [ "mutations/**" ]
+
+ - component_id: mutations_testing
+ name: mutations-testing
+ paths: [ "mutations-testing/**" ]
+
+ - component_id: mutations_sqldelight
+ name: mutations-sqldelight
+ paths: [ "mutations-sqldelight/**" ]
+
+ - component_id: mutations_drain
+ name: mutations-drain
+ paths: [ "mutations-drain/**" ]
+
+ - component_id: mutations_drain_meeseeks
+ name: mutations-drain-meeseeks
+ paths: [ "mutations-drain-meeseeks/**" ]
+
+ - component_id: mutations_conflicts
+ name: mutations-conflicts
+ paths: [ "mutations-conflicts/**" ]
+
+ - component_id: sqldelight
+ name: sqldelight
+ paths: [ "sqldelight/**" ]
+
+ - component_id: room
+ name: room
+ paths: [ "room/**" ]
+
+ - component_id: file
+ name: file
+ paths: [ "file/**" ]
+
+ - component_id: paging_androidx
+ name: paging-androidx
+ paths: [ "paging-androidx/**" ]
+
+ - component_id: graphql
+ name: graphql
+ paths: [ "graphql/**" ]
+
+ - component_id: ktor
+ name: ktor
+ paths: [ "ktor/**" ]
+
+ - component_id: realtime
+ name: realtime
+ paths: [ "realtime/**" ]
+
+ - component_id: opentelemetry
+ name: opentelemetry
+ paths: [ "opentelemetry/**" ]
+
+ - component_id: compose
+ name: compose
+ paths: [ "compose/**" ]
+
+ - component_id: devtools
+ name: devtools
+ paths: [ "devtools/**" ]
+
+ - component_id: devtools_inspector
+ name: devtools-inspector
+ paths: [ "devtools-inspector/**" ]
+
comment:
- layout: diff, files
+ # `components` is the point of this block: one row per artifact touched by the
+ # PR, so a diff that moves core and mutations does not hide behind one number.
+ layout: "header, diff, components, flags, files"
+ behavior: default
+ # Comment even when total coverage is unchanged — the per-component and patch
+ # breakdown is useful on a PR that only moves code between modules.
+ require_changes: false
+ require_base: false
+ require_head: true
+
+github_checks:
+ # Line annotations are off deliberately. They would flag every added
+ # Apple/Linux/JS/wasm line as uncovered — true of the JVM report, useless as
+ # review signal, and impossible for the author to act on.
+ annotations: false
ignore:
- "**/fake"
@@ -12,4 +159,17 @@ ignore:
- "**/androidTest"
- "**/iOSTest"
- "**/jsTest"
- - "**/jvmTest"
\ No newline at end of file
+ - "**/jvmTest"
+ - "**/sample/**"
+ - "quickstart/**"
+ - "mutations-quickstart/**"
+ - "benchmarks/**"
+ - "extension-probe/**"
+ - "swift-dumps/**"
+ - "compose-demo/**"
+ - "devtools-demo/**"
+ # Build-time and packaging surface, never part of the published library.
+ - "bom/**"
+ - "tooling/**"
+ - "plugins/**"
+ - "store6-swift/**"
diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
index 9d3c613e9..000000000
--- a/.editorconfig
+++ /dev/null
@@ -1,4 +0,0 @@
-root = true
-
-[*.{kt,kts}]
-ktlint_standard_import-ordering = disabled
\ No newline at end of file
diff --git a/.github/docs-sync-sources.txt b/.github/docs-sync-sources.txt
new file mode 100644
index 000000000..e9feef00c
--- /dev/null
+++ b/.github/docs-sync-sources.txt
@@ -0,0 +1,12 @@
+CONTRIBUTING.md
+ROADMAP.md
+STABILITY.md
+compose/README.md
+docs/store6/important-defaults.md
+docs/store6/invalidate-vs-clear.md
+docs/store6/key-design.md
+docs/store6/platforms.md
+docs/store6/quickstart.md
+llms.txt
+room/README.md
+sqldelight/README.md
diff --git a/.github/release-manifest.json b/.github/release-manifest.json
new file mode 100644
index 000000000..28d19d62e
--- /dev/null
+++ b/.github/release-manifest.json
@@ -0,0 +1,13 @@
+{
+ "schema_version": 1,
+ "repository": "MobileNativeFoundation/Store",
+ "version_property": "VERSION_NAME",
+ "group_property": "GROUP",
+ "artifacts": ["core", "testing", "sqldelight", "room", "compose", "graphql", "realtime", "mutations", "mutations-testing", "mutations-sqldelight", "paging-androidx", "opentelemetry", "ktor", "file", "mutations-conflicts", "bom"],
+ "release_jobs": ["build-and-test", "release-matrix", "release-full-suite", "workflow-fixtures"],
+ "matrix_jobs": ["linux-build-test", "apple-tests", "swift-dumps", "swift-facade", "klib-publication-check", "native-stress"],
+ "full_suite_jobs": ["full-mutations-jvm", "lincheck"],
+ "non_release_jobs": {"docs-sync-guard": "Pull-request documentation acknowledgment; no pull request exists for a release tag or snapshot dispatch."},
+ "publication_classifications": ["validated"],
+ "full_suite_classifications": ["passed", "test-failure", "infrastructure-or-incomplete", "unexecuted"]
+}
diff --git a/.github/scripts/release_control.py b/.github/scripts/release_control.py
new file mode 100644
index 000000000..5cfaa9e41
--- /dev/null
+++ b/.github/scripts/release_control.py
@@ -0,0 +1,634 @@
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import sys
+import xml.etree.ElementTree as ET
+
+ROOT = Path(__file__).resolve().parents[2]
+
+# The full mutations JVM suite is two Gradle tasks: every other test class in :mutations:jvmTest,
+# and the Lincheck model-checking class alone in :mutations:lincheckTest, sharded across jobs.
+LINCHECK_CLASS = 'org.mobilenativefoundation.store6.mutations.MutationJournalLincheckTest'
+JVM_SUITE_TASK = ':mutations:jvmTest'
+LINCHECK_TASK = ':mutations:lincheckTest'
+# Mirrors LincheckScenarioPlan.SCENARIO_COUNT, .SCENARIO_DIGEST and .SCENARIO_MARKER;
+# test_workflow_contract pins these to the Kotlin source so the two cannot drift apart silently.
+# The digest is the golden hash of the whole scenario plan: every shard prints it, and a release
+# is refused unless all of them validated this exact plan. That is what makes the Kotlin
+# SCENARIO_SEED a guarded lever rather than a silent one.
+LINCHECK_SCENARIO_COUNT = 101
+LINCHECK_SCENARIO_DIGEST = '353a057d5f715bc8'
+SCENARIO_MARKER = 'store6-lincheck-scenarios'
+SCENARIO_MARKER_FORM = re.compile(
+ r'[ \t]*' + re.escape(SCENARIO_MARKER) +
+ r' shard=(\d+/\d+) count=(\d+) indices=([0-9,]*) digest=([0-9a-f]+)')
+# Lincheck's own Reporter.logIteration prints this once per scenario at LoggingLevel.INFO. The
+# marker states the plan; these lines are what the model checker actually ran.
+ITERATION_FORM = re.compile(r'[ \t]*= Iteration (\d+) / (\d+) =')
+
+
+def write_json(path, value):
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + '.tmp')
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n')
+ temporary.replace(path)
+
+
+def properties(path):
+ result = {}
+ for line in path.read_text().splitlines():
+ if line.strip() and not line.lstrip().startswith('#') and '=' in line:
+ key, value = line.split('=', 1)
+ if key.strip() in result:
+ raise ValueError(f'duplicate property: {key.strip()}')
+ result[key.strip()] = value.strip()
+ return result
+
+
+def root_version(root):
+ version = properties(root / 'gradle.properties')['VERSION_NAME']
+ if not re.fullmatch(r'[0-9][0-9A-Za-z.+-]*', version):
+ raise ValueError('invalid root version')
+ for path in root.glob('*/gradle.properties'):
+ if 'VERSION_NAME' in properties(path):
+ raise ValueError(f'version override in {path}')
+ return version
+
+
+def validate_jobs(needs, required):
+ missing = set(required) - set(needs)
+ if missing:
+ raise ValueError('missing validation jobs: ' + ', '.join(sorted(missing)))
+ for name in required:
+ if needs[name].get('result') != 'success':
+ raise ValueError(f'{name} must finish with success')
+
+
+def validate_job_provenance(context, version, needs, required):
+ validate_jobs(needs, required)
+ for name in required:
+ output = needs[name].get('outputs', {})
+ if output.get('source_sha') != context['sha']:
+ raise ValueError(f'{name} validated a different SHA')
+ if any(str(output.get(key)) != str(context[key]) for key in ['run_id', 'run_attempt']):
+ raise ValueError(f'{name} has different run provenance')
+ if output.get('version') != version:
+ raise ValueError(f'{name} validated a different version')
+
+
+def validate_context(context, version, manifest):
+ if context['repository'] != manifest['repository']:
+ raise ValueError('publication repository is forbidden')
+ if not re.fullmatch(r'[0-9a-f]{40}', context['sha']) or context['sha'] != context['checked_out_sha']:
+ raise ValueError('checked-out SHA does not match the workflow SHA')
+ if not all(str(context[key]).isdigit() for key in ['run_id', 'run_attempt']):
+ raise ValueError('missing run provenance')
+ if context['event'] == 'push' and context['ref'].startswith('refs/tags/v'):
+ if version.endswith('-SNAPSHOT'):
+ raise ValueError('SNAPSHOT tags cannot publish')
+ if context['ref'] != f'refs/tags/v{version}':
+ raise ValueError('tag and root version differ')
+ elif context['event'] == 'workflow_dispatch' and context['ref'].startswith('refs/heads/'):
+ if not version.endswith('-SNAPSHOT'):
+ raise ValueError('workflow_dispatch publishes snapshots only')
+ else:
+ raise ValueError('unsupported publication event or ref')
+
+
+def release_evidence(context, version, manifest, needs):
+ validate_context(context, version, manifest)
+ validate_job_provenance(context, version, needs, manifest['release_jobs'])
+ return dict(schema_version=1, source_sha=context['sha'], version=version,
+ repository=context['repository'], ref=context['ref'],
+ run_id=context['run_id'], run_attempt=context['run_attempt'],
+ classification='validated', artifacts=manifest['artifacts'],
+ checks=needs, matrix_jobs=manifest['matrix_jobs'])
+
+
+def release_notes(path, version):
+ text = path.read_text()
+ sections = re.split(r'(?m)^## ', text)
+ selected = [section for section in sections[1:] if section.startswith(f'[{version}]')]
+ if len(selected) != 1:
+ raise ValueError(f'exactly one release-note section required for {version}')
+ heading, _, body = selected[0].partition('\n')
+ if not re.fullmatch(r'\[' + re.escape(version) + r'\] \(\d{4}-\d{2}-\d{2}\)', heading.strip()):
+ raise ValueError('release notes need a dated version heading')
+ if not body.strip() or re.search(r'\b(TODO|TBD|FIXME)\b|\[Unreleased\]', body):
+ raise ValueError('release notes are empty or contain placeholders')
+ return '## ' + selected[0].strip() + '\n'
+
+
+def require_new_release(existing):
+ if existing is not None:
+ raise ValueError('A release record already exists. Inspect its receipt and use record repair; do not republish.')
+
+
+def publish_modules(record, path, publish):
+ if Path(path).exists():
+ raise ValueError('publication receipt already exists; inspect and repair instead of republishing')
+ receipt = dict(record, publication_status='started', published_modules=[])
+ write_json(path, receipt)
+ for module in record['artifacts']:
+ receipt['attempting_module'] = module
+ write_json(path, receipt)
+ task = 'publishToMavenCentral' if record['version'].endswith('-SNAPSHOT') else 'publishAndReleaseToMavenCentral'
+ try:
+ publish(f':{module}:{task}')
+ except BaseException:
+ receipt['publication_status'] = 'partial'
+ write_json(path, receipt)
+ raise
+ receipt['published_modules'].append(module)
+ receipt.pop('attempting_module')
+ write_json(path, receipt)
+ receipt['publication_status'] = 'complete'
+ write_json(path, receipt)
+ return receipt
+
+
+def repair_record(receipt, update):
+ if receipt.get('publication_status') != 'complete' or receipt.get('published_modules') != receipt.get('artifacts'):
+ raise ValueError('Maven publication receipt is incomplete; reconcile Central before record repair')
+ update(receipt)
+
+
+def publication_versions(repository, version, group, modules, artifacts, expected_publications=None):
+ if not expected_publications or len(expected_publications) != len(set(expected_publications)):
+ raise ValueError('an explicit, nonduplicated expected publication inventory is required')
+ if not set(modules).issubset(expected_publications):
+ raise ValueError('expected publication inventory omits a module root')
+ discovered = {path.name for path in repository.iterdir() if (path / version).is_dir()
+ and any(path.name == module or path.name.startswith(module + '-') for module in modules)}
+ unexpected = discovered - set(expected_publications)
+ if unexpected:
+ raise ValueError('unexpected publication outside the inventory: ' + ', '.join(sorted(unexpected)))
+ ns = {'m': 'http://maven.apache.org/POM/4.0.0'}
+ count = 0
+ for artifact in expected_publications:
+ pom = repository / artifact / version / f'{artifact}-{version}.pom'
+ if not pom.is_file():
+ raise ValueError(f'missing publication POM: {pom}')
+ root = ET.parse(pom).getroot()
+ if root.findtext('m:version', namespaces=ns) != version:
+ raise ValueError(f'publication version differs in {pom}')
+ if root.findtext('m:groupId', namespaces=ns) != group:
+ raise ValueError(f'publication group differs in {pom}')
+ if root.findtext('m:artifactId', namespaces=ns) != artifact:
+ raise ValueError(f'publication artifact differs in {pom}')
+ dependencies = root.findall('.//m:dependency', ns)
+ for dependency in dependencies:
+ if dependency.findtext('m:groupId', namespaces=ns) == group:
+ if dependency.findtext('m:version', namespaces=ns) != version:
+ raise ValueError(f'internal dependency version differs in {pom}')
+ if artifact == 'bom':
+ constraints = root.findall('m:dependencyManagement/m:dependencies/m:dependency', ns)
+ names = [dependency.findtext('m:artifactId', namespaces=ns) for dependency in constraints]
+ if sorted(names) != sorted(set(artifacts) - {'bom'}):
+ raise ValueError('BOM inventory differs from the publication allowlist')
+ metadata = pom.with_suffix('.module')
+ if not metadata.is_file():
+ raise ValueError(f'missing Gradle module metadata: {metadata}')
+ data = json.loads(metadata.read_text())
+ component = data.get('component', {})
+ component_module = component.get('module')
+ if component_module == artifact:
+ if 'url' in component:
+ raise ValueError(f'unexpected self-component URL in {metadata}')
+ else:
+ owners = [module for module in modules if artifact.startswith(module + '-')]
+ owner = max(owners, key=len) if owners else None
+ if owner is None or component_module != owner or artifact in modules:
+ raise ValueError(f'Gradle component owner differs in {metadata}')
+ # KMP target metadata identifies its root component through this relative URL.
+ expected_url = f'../../{owner}/{version}/{owner}-{version}.module'
+ if component.get('url') != expected_url:
+ raise ValueError(f'Gradle component URL differs in {metadata}')
+ owner_metadata = (metadata.parent / expected_url).resolve()
+ if not owner_metadata.is_relative_to(repository.resolve()):
+ raise ValueError(f'Gradle component URL escapes the repository in {metadata}')
+ if not owner_metadata.is_file():
+ raise ValueError(f'missing owning Gradle module metadata: {owner_metadata}')
+ if any(component.get(key) != value for key, value in
+ dict(group=group, module=component_module, version=version).items()):
+ raise ValueError(f'Gradle module coordinates differ in {metadata}')
+ for variant in data.get('variants', []):
+ for dependency in variant.get('dependencies', []) + variant.get('dependencyConstraints', []):
+ if dependency.get('group') == group:
+ constraint = dependency.get('version', {})
+ if not constraint or any(value != version for key, value in constraint.items()
+ if key in ['requires', 'strictly', 'prefers']):
+ raise ValueError(f'Gradle dependency version differs in {metadata}')
+ available = variant.get('available-at', {})
+ if available.get('group') == group and available.get('version') != version:
+ raise ValueError(f'Gradle target version differs in {metadata}')
+ count += 1
+ return count
+
+
+def task_outcome(log, task):
+ matches = re.findall(r'^> Task ' + re.escape(task) + r'(?:[ \t]+([^\r\n]*))?\r?$', log, re.MULTILINE)
+ outcomes = [match.strip() for match in matches]
+ if not outcomes or any(outcome not in ['', 'FAILED'] for outcome in outcomes):
+ raise ValueError(f'expected an actually executed {task} task')
+ return 'failed' if 'FAILED' in outcomes else 'executed'
+
+
+def shard_indices(shard):
+ match = re.fullmatch(r'(\d+)/(\d+)', shard or '')
+ index, count = (int(match[1]), int(match[2])) if match else (0, 0)
+ if not 1 <= index <= count <= LINCHECK_SCENARIO_COUNT:
+ raise ValueError(f"a Lincheck shard must be k/N with 1 <= k <= N <= {LINCHECK_SCENARIO_COUNT}; "
+ f"got '{shard}'")
+ return [value for value in range(LINCHECK_SCENARIO_COUNT) if value % count == index - 1]
+
+
+def standard_output(log, results, log_path):
+ """Everything the shard printed: the Gradle console log and every result XML's system-out."""
+ sources = [(log, str(log_path))]
+ for path in sorted(results.glob('TEST-*.xml')):
+ for stream in ET.parse(path).getroot().iter('system-out'):
+ sources.append((''.join(stream.itertext()), path.name))
+ return sources
+
+
+def scenario_indices(log, results, shard, log_path):
+ """The shard prints its plan; both the console log and the result XML must agree with it."""
+ reported = set()
+ for text, _ in standard_output(log, results, log_path):
+ for line in text.split('\n'):
+ match = SCENARIO_MARKER_FORM.fullmatch(line.rstrip('\r'))
+ if match:
+ reported.add(match.group(1, 2, 3, 4))
+ if not reported:
+ raise ValueError(f'no {SCENARIO_MARKER} evidence for {shard}; the shard did not report its plan')
+ if len(reported) != 1:
+ raise ValueError(f'conflicting {SCENARIO_MARKER} evidence: ' + '; '.join(sorted(str(x) for x in reported)))
+ executed, count, joined, digest = reported.pop()
+ indices = [int(value) for value in joined.split(',')] if joined else []
+ if executed != shard:
+ raise ValueError(f'shard {shard} reported scenarios for {executed}')
+ if int(count) != len(indices) or sorted(set(indices)) != indices:
+ raise ValueError(f'shard {shard} reported {count} scenarios as {joined}')
+ if indices != shard_indices(shard):
+ raise ValueError(f'shard {shard} executed scenarios outside its partition of the plan')
+ if digest != LINCHECK_SCENARIO_DIGEST:
+ raise ValueError(f'shard {shard} validated plan digest {digest}, not the pinned '
+ f'{LINCHECK_SCENARIO_DIGEST}; the scenario plan changed')
+ return indices, digest
+
+
+def executed_iterations(log, results, shard, log_path):
+ """How many scenarios Lincheck reported running, and how many it was configured to run."""
+ reported = set()
+ for text, _ in standard_output(log, results, log_path):
+ for line in text.split('\n'):
+ match = ITERATION_FORM.fullmatch(line.rstrip('\r'))
+ if match:
+ reported.add((int(match[1]), int(match[2])))
+ if not reported:
+ raise ValueError(f'no Lincheck iteration evidence for shard {shard}; the model checker did '
+ f'not report the scenarios it ran')
+ planned = {total for _, total in reported}
+ if len(planned) != 1:
+ raise ValueError(f'shard {shard} reported conflicting Lincheck iteration totals: ' +
+ ', '.join(str(total) for total in sorted(planned)))
+ executed = sorted(index for index, _ in reported)
+ if executed != list(range(1, len(executed) + 1)):
+ raise ValueError(f'shard {shard} reported Lincheck iterations that are not a prefix of its '
+ f'plan: {", ".join(str(index) for index in executed)}')
+ return len(executed), planned.pop()
+
+
+def suite_classes(root):
+ """Every mutations test class the sources declare, taken as the census of the full suite."""
+ expected = []
+ for source in sorted((root / 'mutations/src').glob('**/*Test.kt')):
+ if '/commonTest/' in str(source) or '/jvmTest/' in str(source):
+ package = re.search(r'(?m)^package (\S+)', source.read_text())
+ if package:
+ expected.append(package[1] + '.' + source.stem)
+ return expected
+
+
+def executed_classes(results):
+ executed = set()
+ for path in sorted(results.glob('TEST-*.xml')):
+ for testcase in ET.parse(path).getroot().iter('testcase'):
+ if testcase.find('skipped') is None:
+ executed.add(testcase.get('classname', ''))
+ return executed
+
+
+def test_identifiers(results, expected):
+ identifiers = []
+ executed = set()
+ for path in sorted(results.glob('TEST-*.xml')):
+ for testcase in ET.parse(path).getroot().iter('testcase'):
+ classname = testcase.get('classname', '')
+ outcome = 'passed'
+ if testcase.find('skipped') is not None:
+ outcome = 'skipped'
+ elif testcase.find('failure') is not None or testcase.find('error') is not None:
+ outcome = 'failed'
+ if outcome != 'skipped':
+ executed.add(classname)
+ identifiers.append(dict(id=classname + '#' + testcase.get('name', ''), outcome=outcome))
+ missing = set(expected) - executed
+ if missing or not identifiers:
+ raise ValueError('no executed testcase evidence for: ' + ', '.join(sorted(missing)))
+ return identifiers
+
+
+def context_from_env():
+ return dict(repository=os.environ['GITHUB_REPOSITORY'], event=os.environ['GITHUB_EVENT_NAME'],
+ ref=os.environ['GITHUB_REF'], sha=os.environ['GITHUB_SHA'],
+ checked_out_sha=subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(),
+ run_id=os.environ['GITHUB_RUN_ID'], run_attempt=os.environ['GITHUB_RUN_ATTEMPT'])
+
+
+def append_outputs(context, version):
+ if context['sha'] != context['checked_out_sha']:
+ raise ValueError('checked-out SHA changed')
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
+ for key, value in [('source_sha', context['sha']), ('run_id', context['run_id']),
+ ('run_attempt', context['run_attempt']), ('version', version)]:
+ output.write(f'{key}={value}\n')
+
+
+def instrumentation_failures(log, results, log_path):
+ failures = []
+
+ def collect(text, source, path, stream_index=None):
+ for line_number, line in enumerate(text.split('\n'), 1):
+ match = re.fullmatch(r"[ \t]*Unable to transform (.+)", line)
+ if match:
+ failure = dict(source=source, path=path, line=line_number, class_name=match[1])
+ if stream_index is not None:
+ failure['stream_index'] = stream_index
+ failures.append(failure)
+
+ collect(log, 'gradle-log', str(log_path))
+ for path in sorted(results.glob('TEST-*.xml')):
+ for index, stream in enumerate(ET.parse(path).getroot().iter('system-err'), 1):
+ collect(''.join(stream.itertext()), 'xml-system-err', path.name, index)
+ return failures
+
+
+def full_suite_record(args, context, manifest):
+ output = Path(args.output)
+ log = Path(args.log).read_text()
+ version = root_version(ROOT)
+ lincheck = args.task == 'lincheckTest'
+ task = LINCHECK_TASK if lincheck else JVM_SUITE_TASK
+ # No implicit whole-plan default here: on this lane "no shard" would be a third meaning next to
+ # the absent Gradle property and an explicit 1/1, and a shard job that lost its matrix value
+ # would silently claim the whole plan. The workflow always passes k/N.
+ shard = (args.shard or '').strip() if lincheck else None
+ record = dict(schema_version=1, source_sha=context['sha'], checked_out_sha=context['checked_out_sha'], version=version,
+ repository=context['repository'], run_id=context['run_id'], run_attempt=context['run_attempt'],
+ task=task, shard=shard, gradle_exit_code=args.exit_code,
+ classification='unexecuted', log_sha256=hashlib.sha256(log.encode()).hexdigest())
+ try:
+ if not lincheck and args.shard:
+ raise ValueError(f'{JVM_SUITE_TASK} does not take a Lincheck shard')
+ if lincheck and not shard:
+ raise ValueError(f'{LINCHECK_TASK} requires an explicit --shard of the form k/N')
+ record['task_outcome'] = task_outcome(log, task)
+ record['classification'] = 'infrastructure-or-incomplete'
+ suite = [name for name in suite_classes(ROOT) if name != LINCHECK_CLASS]
+ expected = [LINCHECK_CLASS] if lincheck else suite
+ forbidden = suite if lincheck else [LINCHECK_CLASS]
+ record['expected_classes'] = expected
+ results = Path(args.results)
+ record['xml_files'] = {path.name: hashlib.sha256(path.read_bytes()).hexdigest()
+ for path in sorted(results.glob('TEST-*.xml'))}
+ record['test_identifiers'] = test_identifiers(results, [])
+ if any(item['outcome'] == 'failed' for item in record['test_identifiers']):
+ record['classification'] = 'test-failure'
+ test_identifiers(results, expected)
+ record['executed_classes'] = sorted(executed_classes(results))
+ trespassing = sorted(set(forbidden) & set(record['executed_classes']))
+ if trespassing:
+ raise ValueError(f'{task} executed {", ".join(trespassing)}, which belongs to the other lane')
+ if lincheck:
+ record['scenario_indices'], record['scenario_digest'] = \
+ scenario_indices(log, results, shard, args.log)
+ executed, planned = executed_iterations(log, results, shard, args.log)
+ record['executed_iterations'] = executed
+ if planned != len(record['scenario_indices']):
+ raise ValueError(f'shard {shard} planned {len(record["scenario_indices"])} scenarios '
+ f'but Lincheck was configured for {planned} iterations')
+ # A failing shard stops at the failure, so a short count is expected there and the
+ # recorded number is diagnostic rather than a second, misleading error.
+ if record['classification'] != 'test-failure' and executed != planned:
+ raise ValueError(f'shard {shard} planned {planned} scenarios but Lincheck reported '
+ f'{executed} iterations')
+ record['instrumentation_failures'] = instrumentation_failures(log, results, args.log)
+ if record['instrumentation_failures']:
+ record['evidence_error'] = 'Lincheck reported a bytecode transformation failure'
+ if context['sha'] != context['checked_out_sha']:
+ raise ValueError('source SHA changed during test execution')
+ # A real test failure keeps its own classification; the guards below distinguish the ways a
+ # run can fail to be evidence at all.
+ if record['classification'] != 'test-failure':
+ if record['instrumentation_failures']:
+ raise ValueError(record['evidence_error'])
+ if args.exit_code != 0 or record['task_outcome'] != 'executed':
+ raise ValueError('Gradle failed without test failure evidence')
+ record['classification'] = 'passed'
+ except (ValueError, OSError, ET.ParseError) as error:
+ record['evidence_error'] = str(error)
+ write_json(output, record)
+ summary = os.environ.get('GITHUB_STEP_SUMMARY')
+ if summary:
+ with open(summary, 'a') as handle:
+ handle.write(f"### Full mutations JVM execution: {task}{' shard ' + shard if shard else ''}\n\n"
+ f"Classification: **{record['classification']}**. Source: `{context['sha']}`. "
+ f"Run: `{context['run_id']}`, attempt: `{context['run_attempt']}`.\n\n"
+ f"Task outcome: `{record.get('task_outcome', 'unexecuted')}`. "
+ f"Gradle exit: `{args.exit_code}`.\n\n"
+ "The result artifact contains the console log, XML, test identifiers and hashes. "
+ "For a failure, preserve this first result, classify the failed test or infrastructure cause, "
+ "and link the fix or disposition from this run. Do not rerun unchanged failures for green.\n")
+ if record['classification'] != 'passed':
+ raise ValueError('full-suite evidence did not pass; inspect the archived first result')
+ append_outputs(context, version)
+
+
+def full_suite_validation(context, version, manifest, needs, executions, shards):
+ """Prove that one forced execution of the whole suite happened, with no lane and no scenario lost."""
+ validate_job_provenance(context, version, needs, manifest['full_suite_jobs'])
+ if not isinstance(shards, int) or shards < 1:
+ raise ValueError('the Lincheck shard count must be a positive integer')
+ records = [json.loads(path.read_text())
+ for path in sorted(Path(executions).glob('**/execution.json'))]
+ if not records:
+ raise ValueError(f'no full-suite execution record was archived under {executions}')
+ for record in records:
+ name = str(record.get('task')) + (' shard ' + str(record['shard']) if record.get('shard') else '')
+ # argparse cannot produce a third lane today, but an archived record is a file: refuse an
+ # unknown one rather than letting it sit uncounted in the census.
+ if record.get('task') not in [JVM_SUITE_TASK, LINCHECK_TASK]:
+ raise ValueError(f'{name} is not a full-suite lane; expected {JVM_SUITE_TASK} '
+ f'or {LINCHECK_TASK}')
+ if record.get('classification') != 'passed':
+ raise ValueError(f"{name} is classified {record.get('classification')}, not passed")
+ if record.get('source_sha') != context['sha'] or record.get('checked_out_sha') != context['sha']:
+ raise ValueError(f'{name} validated a different SHA')
+ if any(str(record.get(key)) != str(context[key]) for key in ['run_id', 'run_attempt']):
+ raise ValueError(f'{name} has different run provenance')
+ if record.get('version') != version:
+ raise ValueError(f'{name} validated a different version')
+
+ suite = [name for name in suite_classes(ROOT) if name != LINCHECK_CLASS]
+ jvm = [record for record in records if record.get('task') == JVM_SUITE_TASK]
+ if len(jvm) != 1:
+ raise ValueError(f'expected exactly one {JVM_SUITE_TASK} execution, found {len(jvm)}')
+ executed = set(jvm[0].get('executed_classes') or [])
+ missing = sorted(set(suite) - executed)
+ if missing:
+ raise ValueError(f'{JVM_SUITE_TASK} lost test classes: ' + ', '.join(missing))
+ if LINCHECK_CLASS in executed:
+ raise ValueError(f'{JVM_SUITE_TASK} executed {LINCHECK_CLASS}; the Lincheck lane owns it')
+
+ lincheck = [record for record in records if record.get('task') == LINCHECK_TASK]
+ expected_shards = [f'{index}/{shards}' for index in range(1, shards + 1)]
+ observed = sorted(str(record.get('shard')) for record in lincheck)
+ if observed != sorted(expected_shards):
+ raise ValueError('expected Lincheck shards ' + ', '.join(expected_shards) +
+ '; found ' + (', '.join(observed) or 'none'))
+ digests = {record.get('scenario_digest') for record in lincheck}
+ if digests != {LINCHECK_SCENARIO_DIGEST}:
+ raise ValueError(f'every Lincheck shard must report plan digest {LINCHECK_SCENARIO_DIGEST}; '
+ 'found ' + ', '.join(sorted(str(digest) for digest in digests)))
+ union = []
+ for record in lincheck:
+ shard = record['shard']
+ if LINCHECK_CLASS not in set(record.get('executed_classes') or []):
+ raise ValueError(f'shard {shard} did not execute {LINCHECK_CLASS}')
+ if not any(item.get('id', '').startswith(LINCHECK_CLASS + '#') and item.get('outcome') == 'passed'
+ for item in record.get('test_identifiers') or []):
+ raise ValueError(f'shard {shard} has no passed {LINCHECK_CLASS} testcase')
+ union += record.get('scenario_indices') or []
+ if sorted(union) != list(range(LINCHECK_SCENARIO_COUNT)):
+ raise ValueError(f'the Lincheck shards must cover scenario 0..{LINCHECK_SCENARIO_COUNT - 1} '
+ f'exactly once; they covered {len(union)} with {len(set(union))} distinct')
+ return dict(context, version=version, checks=needs, classification='validated',
+ lincheck_shards=expected_shards, scenario_count=LINCHECK_SCENARIO_COUNT,
+ scenario_digest=LINCHECK_SCENARIO_DIGEST,
+ executions=[dict(task=record['task'], shard=record.get('shard'),
+ classification=record['classification'],
+ task_outcome=record.get('task_outcome'),
+ log_sha256=record.get('log_sha256'),
+ executed_classes=len(record.get('executed_classes') or []),
+ executed_iterations=record.get('executed_iterations'),
+ scenario_indices=record.get('scenario_indices'))
+ for record in sorted(records, key=lambda item: (item['task'], item.get('shard') or ''))])
+
+
+def gh(*args, **kwargs):
+ return subprocess.run(['gh', *args], check=True, text=True, **kwargs)
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('command', choices=['version', 'provenance', 'matrix', 'full-suite', 'gate', 'reserve',
+ 'publish', 'record', 'full-suite-execution', 'publication-versions'])
+ parser.add_argument('--output', default='release-evidence.json')
+ parser.add_argument('--receipt', default='publication-receipt.json')
+ parser.add_argument('--repository')
+ parser.add_argument('--modules', nargs='+')
+ parser.add_argument('--publications', nargs='+')
+ parser.add_argument('--log')
+ parser.add_argument('--results')
+ parser.add_argument('--task', choices=['jvmTest', 'lincheckTest'])
+ parser.add_argument('--shard')
+ parser.add_argument('--exit-code', type=int)
+ args = parser.parse_args()
+ # --task is shared with the commands that ignore it, so argparse cannot mark it required; the
+ # lane that decides which census a record is measured against must never be inferred.
+ if args.command == 'full-suite-execution' and args.task is None:
+ parser.error('full-suite-execution requires --task')
+ version = root_version(ROOT)
+ manifest = json.loads((ROOT / '.github/release-manifest.json').read_text())
+ if args.command == 'version':
+ print(version)
+ return
+ if args.command == 'publication-versions':
+ count = publication_versions(Path(args.repository), version, properties(ROOT / 'gradle.properties')['GROUP'],
+ args.modules, manifest['artifacts'], args.publications)
+ print(f'Validated {count} publication POMs and Gradle metadata files at version {version}')
+ return
+ context = context_from_env()
+ if args.command == 'provenance':
+ append_outputs(context, version)
+ write_json(args.output, dict(context, version=version, classification='validated'))
+ elif args.command == 'matrix':
+ needs = json.loads(os.environ['VALIDATION_NEEDS'])
+ validate_job_provenance(context, version, needs, manifest['matrix_jobs'])
+ append_outputs(context, version)
+ write_json(args.output, dict(context, version=version, checks=needs, classification='validated'))
+ elif args.command == 'full-suite':
+ record = full_suite_validation(context, version, manifest, json.loads(os.environ['VALIDATION_NEEDS']),
+ os.environ['FULL_SUITE_EXECUTIONS'], int(os.environ['FULL_SUITE_SHARDS']))
+ append_outputs(context, version)
+ write_json(args.output, record)
+ elif args.command == 'gate':
+ record = release_evidence(context, version, manifest, json.loads(os.environ['VALIDATION_NEEDS']))
+ if not version.endswith('-SNAPSHOT'):
+ Path('release-notes.md').write_text(release_notes(ROOT / 'CHANGELOG.md', version))
+ write_json(args.output, record)
+ elif args.command == 'reserve':
+ record = json.loads(Path(args.output).read_text())
+ validate_context(context, version, manifest)
+ if record['source_sha'] != context['sha'] or record['version'] != version:
+ raise ValueError('release evidence version or SHA differs')
+ if version.endswith('-SNAPSHOT'):
+ return
+ tag = f'v{version}'
+ existing = subprocess.run(['gh', 'release', 'view', tag, '--repo', context['repository'], '--json', 'tagName'],
+ text=True, capture_output=True)
+ require_new_release(json.loads(existing.stdout) if existing.returncode == 0 else None)
+ gh('release', 'create', tag, '--repo', context['repository'], '--draft', '--verify-tag',
+ '--title', tag, '--notes-file', 'release-notes.md')
+ elif args.command == 'publish':
+ record = json.loads(Path(args.output).read_text())
+ validate_context(context, version, manifest)
+ if record['source_sha'] != context['sha'] or record['version'] != version:
+ raise ValueError('release evidence version or SHA differs')
+ publish_modules(record, args.receipt, lambda task: subprocess.run(['./gradlew', task, '--stacktrace'], check=True))
+ elif args.command == 'record':
+ receipt = json.loads(Path(args.receipt).read_text())
+ if context['repository'] != manifest['repository'] or receipt['repository'] != context['repository']:
+ raise ValueError('record repair repository differs')
+ if receipt['source_sha'] != context['checked_out_sha'] or receipt['version'] != version:
+ raise ValueError('record repair source SHA or version differs')
+ if receipt['artifacts'] != manifest['artifacts'] or receipt['classification'] != 'validated':
+ raise ValueError('record repair inventory or validation differs')
+ if version.endswith('-SNAPSHOT'):
+ repair_record(receipt, lambda _: None)
+ return
+ Path('release-notes.md').write_text(release_notes(ROOT / 'CHANGELOG.md', version))
+ tag = f'v{version}'
+ def update(saved):
+ gh('release', 'upload', tag, args.receipt, '--clobber', '--repo', context['repository'])
+ command = ['release', 'edit', tag, '--repo', context['repository'], '--draft=false',
+ '--notes-file', 'release-notes.md', '--prerelease=' + str('-' in version).lower()]
+ gh(*command)
+ repair_record(receipt, update)
+ elif args.command == 'full-suite-execution':
+ full_suite_record(args, context, manifest)
+
+
+if __name__ == '__main__':
+ try:
+ main()
+ except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
+ print(f'ERROR: {error}', file=sys.stderr)
+ sys.exit(1)
diff --git a/.github/scripts/tests/test_release_control.py b/.github/scripts/tests/test_release_control.py
new file mode 100644
index 000000000..57edf67ca
--- /dev/null
+++ b/.github/scripts/tests/test_release_control.py
@@ -0,0 +1,1025 @@
+import contextlib
+import copy
+import importlib.util
+import io
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+from types import SimpleNamespace
+from unittest import mock
+import xml.etree.ElementTree as ET
+
+ROOT = Path(__file__).resolve().parents[3]
+SCRIPT = ROOT / '.github/scripts/release_control.py'
+SPEC = importlib.util.spec_from_file_location('release_control', SCRIPT) if SCRIPT.exists() else None
+CONTROL = importlib.util.module_from_spec(SPEC) if SPEC else None
+if SPEC:
+ SPEC.loader.exec_module(CONTROL)
+
+
+class ReleaseFixtures(unittest.TestCase):
+ def setUp(self):
+ self.assertIsNotNone(CONTROL, 'The fail-closed release controller does not exist')
+ self.sha = 'a' * 40
+ self.context = dict(repository='MobileNativeFoundation/Store', event='push',
+ ref='refs/tags/v6.0.0-alpha01', sha=self.sha,
+ checked_out_sha=self.sha, run_id='123', run_attempt='1')
+ self.manifest = json.loads((ROOT / '.github/release-manifest.json').read_text())
+ self.needs = {name: {'result': 'success', 'outputs': {
+ 'source_sha': self.sha, 'run_id': '123', 'run_attempt': '1', 'version': '6.0.0-alpha01'}}
+ for name in self.manifest['release_jobs']}
+ for name in ['release-matrix', 'release-full-suite']:
+ self.needs[name]['outputs'] = {'source_sha': self.sha, 'run_id': '123',
+ 'run_attempt': '1', 'version': '6.0.0-alpha01'}
+
+ def gate(self, version='6.0.0-alpha01'):
+ return CONTROL.release_evidence(self.context, version, self.manifest, self.needs)
+
+ def test_valid_tag(self):
+ record = self.gate()
+ self.assertEqual(record['source_sha'], self.sha)
+ self.assertEqual(record['artifacts'], self.manifest['artifacts'])
+ self.assertEqual(record['classification'], 'validated')
+
+ def test_mismatched_version(self):
+ with self.assertRaisesRegex(ValueError, 'version'):
+ self.gate('6.0.0-alpha02')
+
+ def test_snapshot_tag(self):
+ self.context['ref'] = 'refs/tags/v6.0.0-SNAPSHOT'
+ with self.assertRaisesRegex(ValueError, 'SNAPSHOT'):
+ self.gate('6.0.0-SNAPSHOT')
+
+ def test_forbidden_repository(self):
+ self.context['repository'] = 'someone/Store'
+ with self.assertRaisesRegex(ValueError, 'repository'):
+ self.gate()
+
+ def test_missing_matrix(self):
+ del self.needs['release-matrix']
+ with self.assertRaisesRegex(ValueError, 'missing'):
+ self.gate()
+
+ def test_failed_pending_cancelled_skipped_matrix(self):
+ for result in ['failure', 'pending', 'cancelled', 'skipped', '']:
+ with self.subTest(result=result):
+ self.needs['release-matrix']['result'] = result
+ with self.assertRaisesRegex(ValueError, 'success'):
+ self.gate()
+
+ def test_wrong_sha_green(self):
+ self.needs['release-matrix']['outputs']['source_sha'] = 'b' * 40
+ with self.assertRaisesRegex(ValueError, 'SHA'):
+ self.gate()
+
+ def test_wrong_run_or_attempt(self):
+ for key in ['run_id', 'run_attempt']:
+ with self.subTest(key=key):
+ changed = copy.deepcopy(self.needs)
+ changed['release-full-suite']['outputs'][key] = '99'
+ with self.assertRaisesRegex(ValueError, 'provenance'):
+ CONTROL.release_evidence(self.context, '6.0.0-alpha01', self.manifest, changed)
+
+ def test_moved_checkout(self):
+ self.context['checked_out_sha'] = 'b' * 40
+ with self.assertRaisesRegex(ValueError, 'SHA'):
+ self.gate()
+
+ def test_snapshot_dispatch(self):
+ self.context.update(event='workflow_dispatch', ref='refs/heads/store6')
+ for job in self.needs.values():
+ job['outputs']['version'] = '6.0.0-SNAPSHOT'
+ self.assertEqual(self.gate('6.0.0-SNAPSHOT')['version'], '6.0.0-SNAPSHOT')
+
+ def test_release_dispatch_denied(self):
+ self.context.update(event='workflow_dispatch', ref='refs/heads/store6')
+ with self.assertRaisesRegex(ValueError, 'snapshots only'):
+ self.gate()
+
+ def test_untrusted_event_denied(self):
+ self.context['event'] = 'pull_request'
+ with self.assertRaises(ValueError):
+ self.gate()
+
+ def test_complete_matrix_required(self):
+ needs = {name: {'result': 'success'} for name in self.manifest['matrix_jobs']}
+ CONTROL.validate_jobs(needs, self.manifest['matrix_jobs'])
+ for name in self.manifest['matrix_jobs']:
+ with self.subTest(name=name):
+ partial = copy.deepcopy(needs)
+ del partial[name]
+ with self.assertRaises(ValueError):
+ CONTROL.validate_jobs(partial, self.manifest['matrix_jobs'])
+
+ def test_release_notes_required_before_publication(self):
+ with tempfile.TemporaryDirectory() as directory:
+ notes = Path(directory) / 'CHANGELOG.md'
+ notes.write_text('## [6.0.0-alpha01] (2026-09-06)\n\nConcrete changes.\n\n## [5.0.0]\nOlder.\n')
+ self.assertIn('Concrete changes.', CONTROL.release_notes(notes, '6.0.0-alpha01'))
+ with self.assertRaises(ValueError):
+ CONTROL.release_notes(notes, '6.0.0-alpha02')
+ notes.write_text('## [6.0.0-alpha01]\n\nTODO\n')
+ with self.assertRaises(ValueError):
+ CONTROL.release_notes(notes, '6.0.0-alpha01')
+
+ def test_publish_receipt_survives_record_failure_and_repairs_without_publish(self):
+ calls = []
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / 'publication-receipt.json'
+ record = self.gate()
+ CONTROL.publish_modules(record, path, lambda task: calls.append(task))
+ saved = json.loads(path.read_text())
+ self.assertEqual(saved['publication_status'], 'complete')
+ with self.assertRaisesRegex(RuntimeError, 'record unavailable'):
+ CONTROL.repair_record(saved, lambda _: (_ for _ in ()).throw(RuntimeError('record unavailable')))
+ self.assertEqual(json.loads(path.read_text()), saved)
+ updates = []
+ CONTROL.repair_record(saved, lambda receipt: updates.append(receipt))
+ CONTROL.repair_record(saved, lambda receipt: updates.append(receipt))
+ self.assertEqual(len(calls), len(self.manifest['artifacts']))
+ self.assertEqual(updates[0], updates[1])
+
+ def test_partial_publication_is_not_republished(self):
+ calls = []
+ def publish(task):
+ calls.append(task)
+ if len(calls) == 2:
+ raise RuntimeError('Central unavailable')
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / 'receipt.json'
+ with self.assertRaises(RuntimeError):
+ CONTROL.publish_modules(self.gate(), path, publish)
+ receipt = json.loads(path.read_text())
+ self.assertEqual(receipt['publication_status'], 'partial')
+ self.assertEqual(receipt['published_modules'], ['core'])
+ with self.assertRaisesRegex(ValueError, 'incomplete'):
+ CONTROL.repair_record(receipt, lambda _: self.fail('No release for partial Maven deployment'))
+ with self.assertRaisesRegex(ValueError, 'receipt already exists'):
+ CONTROL.publish_modules(self.gate(), path, publish)
+ self.assertEqual(len(calls), 2)
+
+ def test_repeated_immutable_release_is_blocked_by_existing_record(self):
+ with self.assertRaisesRegex(ValueError, 'repair'):
+ CONTROL.require_new_release({'tag_name': self.context['ref'].removeprefix('refs/tags/')})
+ CONTROL.require_new_release(None)
+
+ def test_root_version_is_unique(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / 'gradle.properties').write_text('VERSION_NAME=6.0.0-alpha02\n')
+ self.assertEqual(CONTROL.root_version(root), '6.0.0-alpha02')
+ (root / 'core').mkdir()
+ (root / 'core/gradle.properties').write_text('VERSION_NAME=6.0.0-SNAPSHOT\n')
+ with self.assertRaisesRegex(ValueError, 'override'):
+ CONTROL.root_version(root)
+
+ def test_publication_metadata_versions_and_bom_roster(self):
+ self.assertTrue(hasattr(CONTROL, 'publication_versions'), 'Publication metadata must use the root version')
+ with tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ for module in ['core', 'bom']:
+ target = repository / module / '6.0.0-alpha01'
+ target.mkdir(parents=True)
+ dependencies = ('org.example'
+ 'core6.0.0-alpha01'
+ '') if module == 'bom' else ''
+ (target / f'{module}-6.0.0-alpha01.pom').write_text(
+ 'org.example'
+ f'{module}6.0.0-alpha01{dependencies}')
+ (target / f'{module}-6.0.0-alpha01.module').write_text(json.dumps(dict(
+ component=dict(group='org.example', module=module, version='6.0.0-alpha01'), variants=[])))
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core', 'bom'], ['core', 'bom'],
+ expected_publications=['core', 'bom'])
+ pom = repository / 'bom/6.0.0-alpha01/bom-6.0.0-alpha01.pom'
+ pom.write_text(pom.read_text().replace('6.0.0-alpha01',
+ '6.0.0-SNAPSHOT'))
+ with self.assertRaisesRegex(ValueError, 'version'):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core', 'bom'], ['core', 'bom'],
+ expected_publications=['core', 'bom'])
+
+ def test_full_suite_rejects_cached_up_to_date_and_missing_task(self):
+ for task in [':mutations:jvmTest', ':mutations:lincheckTest']:
+ for outcome in [' FROM-CACHE', ' UP-TO-DATE', ' SKIPPED', ' NO-SOURCE']:
+ with self.subTest(task=task, outcome=outcome):
+ with self.assertRaisesRegex(ValueError, 'executed'):
+ CONTROL.task_outcome('> Task ' + task + outcome + '\n', task)
+ with self.assertRaises(ValueError):
+ CONTROL.task_outcome('BUILD SUCCESSFUL\n', task)
+ self.assertEqual(CONTROL.task_outcome('> Task ' + task + '\n', task), 'executed')
+ self.assertEqual(CONTROL.task_outcome('> Task ' + task + ' FAILED\n', task), 'failed')
+
+ def test_each_lane_reads_only_its_own_task_outcome(self):
+ both = '> Task :mutations:jvmTest\n> Task :mutations:lincheckTest FAILED\n'
+ self.assertEqual(CONTROL.task_outcome(both, ':mutations:jvmTest'), 'executed')
+ self.assertEqual(CONTROL.task_outcome(both, ':mutations:lincheckTest'), 'failed')
+ with self.assertRaisesRegex(ValueError, 'executed'):
+ CONTROL.task_outcome('> Task :mutations:jvmTest\n', ':mutations:lincheckTest')
+
+ def test_full_suite_task_names_and_repeated_failed_outcome(self):
+ neighboring_tasks = (
+ '> Task :mutations:jvmTestProcessResources NO-SOURCE\n'
+ '> Task :mutations:jvmTestClasses\n'
+ )
+ observed_failure = (
+ neighboring_tasks + '> Task :mutations:jvmTest\n'
+ '284 tests completed, 6 failed\n'
+ '> Task :mutations:jvmTest FAILED\n'
+ 'BUILD FAILED in 7s\n'
+ )
+ task = ':mutations:jvmTest'
+ self.assertEqual(CONTROL.task_outcome(observed_failure, task), 'failed')
+ self.assertEqual(CONTROL.task_outcome(neighboring_tasks + '> Task :mutations:jvmTest\n', task), 'executed')
+ self.assertEqual(CONTROL.task_outcome(neighboring_tasks + '> Task :mutations:jvmTest FAILED\n', task), 'failed')
+ self.assertEqual(CONTROL.task_outcome('> Task :mutations:jvmTest\r\n', task), 'executed')
+ self.assertEqual(CONTROL.task_outcome('> Task :mutations:jvmTest FAILED\n' * 2, task), 'failed')
+ with self.assertRaisesRegex(ValueError, 'executed'):
+ CONTROL.task_outcome(neighboring_tasks, task)
+ for outcome in ['FROM-CACHE', 'UP-TO-DATE', 'SKIPPED', 'NO-SOURCE']:
+ with self.subTest(outcome=outcome):
+ for started in ['', '> Task :mutations:jvmTest\n']:
+ with self.subTest(started=bool(started)):
+ with self.assertRaisesRegex(ValueError, 'executed'):
+ CONTROL.task_outcome(
+ neighboring_tasks + started + '> Task :mutations:jvmTest ' + outcome + '\n', task)
+
+ def test_shard_indices_partition_the_whole_scenario_plan(self):
+ self.assertEqual(CONTROL.LINCHECK_SCENARIO_COUNT, 101)
+ for count in range(1, 9):
+ union = []
+ for index in range(1, count + 1):
+ union += CONTROL.shard_indices(f'{index}/{count}')
+ self.assertEqual(sorted(union), list(range(CONTROL.LINCHECK_SCENARIO_COUNT)))
+ self.assertEqual(CONTROL.shard_indices('1/4')[:3], [0, 4, 8])
+ self.assertEqual(CONTROL.shard_indices('1/4')[-1], CONTROL.LINCHECK_SCENARIO_COUNT - 1)
+ for malformed in ['', '1', '0/4', '5/4', '1/0', '1/102', 'a/b', '1/4/2', None]:
+ with self.subTest(shard=malformed):
+ with self.assertRaisesRegex(ValueError, 'k/N'):
+ CONTROL.shard_indices(malformed)
+
+ def test_full_suite_execution_requires_an_explicit_task(self):
+ argv = ['release_control.py', 'full-suite-execution', '--log', 'gradle.log']
+ with mock.patch.object(CONTROL.sys, 'argv', argv), contextlib.redirect_stderr(io.StringIO()) as err:
+ with self.assertRaises(SystemExit) as raised:
+ CONTROL.main()
+ self.assertEqual(raised.exception.code, 2)
+ self.assertIn('--task', err.getvalue())
+
+ def test_full_suite_identifiers_and_skipped_class_guard(self):
+ with tempfile.TemporaryDirectory() as directory:
+ results = Path(directory)
+ xml = results / 'TEST-example.Test.xml'
+ xml.write_text('')
+ record = CONTROL.test_identifiers(results, ['example.Test'])
+ self.assertEqual(record[0]['id'], 'example.Test#works')
+ xml.write_text('')
+ with self.assertRaisesRegex(ValueError, 'executed'):
+ CONTROL.test_identifiers(results, ['example.Test'])
+
+ def test_every_release_job_requires_current_source_run_attempt_and_version(self):
+ for name in self.manifest['release_jobs']:
+ for key in ['source_sha', 'run_id', 'run_attempt', 'version']:
+ for mismatch in [None, 'different']:
+ with self.subTest(job=name, field=key, mismatch=mismatch):
+ needs = copy.deepcopy(self.needs)
+ if mismatch is None:
+ needs[name]['outputs'].pop(key)
+ else:
+ needs[name]['outputs'][key] = mismatch
+ with self.assertRaises(ValueError):
+ CONTROL.release_evidence(self.context, '6.0.0-alpha01', self.manifest, needs)
+
+ def test_matrix_and_full_suite_jobs_require_each_leaf_provenance(self):
+ validate = getattr(CONTROL, 'validate_job_provenance', None)
+ self.assertIsNotNone(validate, 'Required leaf jobs need individual provenance validation')
+ for required in [self.manifest['matrix_jobs'], self.manifest['full_suite_jobs']]:
+ needs = {name: copy.deepcopy(self.needs['build-and-test']) for name in required}
+ validate(self.context, '6.0.0-alpha01', needs, required)
+ for name in required:
+ with self.subTest(job=name):
+ changed = copy.deepcopy(needs)
+ changed[name]['outputs']['run_attempt'] = '0'
+ with self.assertRaisesRegex(ValueError, 'provenance'):
+ validate(self.context, '6.0.0-alpha01', changed, required)
+
+ def test_a_reattempted_shard_matrix_cannot_relabel_the_jvm_suite(self):
+ validate = getattr(CONTROL, 'validate_job_provenance', None)
+ self.assertIsNotNone(validate, 'The full-suite gate must compare every execution attempt')
+ context = dict(self.context, run_attempt='2')
+ required = self.manifest['full_suite_jobs']
+ needs = {name: copy.deepcopy(self.needs['build-and-test']) for name in required}
+ needs[required[-1]]['outputs']['run_attempt'] = '2'
+ with self.assertRaisesRegex(ValueError, required[0] + '.*provenance'):
+ validate(context, '6.0.0-alpha01', needs, required)
+
+ def test_publication_metadata_requires_an_explicit_target_inventory(self):
+ with tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ self.write_publication(repository, 'core')
+ with self.assertRaisesRegex(ValueError, 'inventory'):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core'], ['core'])
+
+ def test_expected_target_requires_its_own_pom_and_module_metadata(self):
+ for extension in ['pom', 'module']:
+ with self.subTest(extension=extension), tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ self.write_publication(repository, 'core')
+ target = self.write_publication(repository, 'core-jvm')
+ (target / 'core-jvm-6.0.0-alpha01.jar').write_bytes(b'publication fixture')
+ (target / f'core-jvm-6.0.0-alpha01.{extension}').unlink()
+ with self.assertRaisesRegex(ValueError, 'missing.*core-jvm'):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core'], ['core'],
+ expected_publications=['core', 'core-jvm'])
+
+ def test_complete_expected_publication_inventory_is_validated_once(self):
+ with tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ for name in ['mutations', 'mutations-testing', 'mutations-jvm', 'mutations-testing-jvm']:
+ self.write_publication(repository, name)
+ expected = ['mutations', 'mutations-testing', 'mutations-jvm', 'mutations-testing-jvm']
+ count = CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example',
+ ['mutations', 'mutations-testing'], ['mutations', 'mutations-testing'],
+ expected_publications=expected)
+ self.assertEqual(count, len(expected))
+
+ def test_unlisted_target_cannot_escape_publication_inventory(self):
+ with tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ self.write_publication(repository, 'core')
+ self.write_publication(repository, 'core-jvm')
+ with self.assertRaisesRegex(ValueError, 'unexpected.*core-jvm'):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core'], ['core'],
+ expected_publications=['core'])
+
+ def test_generated_kmp_targets_link_to_the_owning_root_component(self):
+ with tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ expected = self.write_kmp_publications(repository)
+ count = CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example',
+ ['core'], ['core'], expected_publications=expected)
+ self.assertEqual(count, 4)
+
+ def test_kmp_component_owner_uses_the_longest_module_name(self):
+ with tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ expected = self.write_kmp_publications(repository, 'mutations')
+ expected += self.write_kmp_publications(repository, 'mutations-testing')
+ modules = ['mutations', 'mutations-testing']
+ self.assertEqual(CONTROL.publication_versions(
+ repository, '6.0.0-alpha01', 'org.example', modules, modules,
+ expected_publications=expected), 8)
+ metadata = repository / 'mutations-testing-jvm/6.0.0-alpha01/mutations-testing-jvm-6.0.0-alpha01.module'
+ data = json.loads(metadata.read_text())
+ data['component'].update(module='mutations', url='../../mutations/6.0.0-alpha01/mutations-6.0.0-alpha01.module')
+ metadata.write_text(json.dumps(data))
+ with self.assertRaises(ValueError):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', modules, modules,
+ expected_publications=expected)
+
+ def test_kmp_target_component_rejects_wrong_coordinates(self):
+ for field, value in [('module', 'unrelated'), ('group', 'org.foreign'), ('version', '6.0.0-SNAPSHOT')]:
+ with self.subTest(field=field), tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ expected = self.write_kmp_publications(repository)
+ metadata = repository / 'core-jvm/6.0.0-alpha01/core-jvm-6.0.0-alpha01.module'
+ data = json.loads(metadata.read_text())
+ data['component'][field] = value
+ metadata.write_text(json.dumps(data))
+ with self.assertRaises(ValueError):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core'], ['core'],
+ expected_publications=expected)
+
+ def test_kmp_target_component_rejects_missing_or_forged_urls(self):
+ urls = [None, '', 7, '../../core/6.0.0-alpha01/core-jvm-6.0.0-alpha01.module',
+ '../../other/6.0.0-alpha01/other-6.0.0-alpha01.module',
+ '../../core/6.0.0-SNAPSHOT/core-6.0.0-SNAPSHOT.module',
+ '../../../outside/core-6.0.0-alpha01.module',
+ '../../%63ore/6.0.0-alpha01/core-6.0.0-alpha01.module',
+ 'https://example.invalid/core-6.0.0-alpha01.module',
+ '/core/6.0.0-alpha01/core-6.0.0-alpha01.module',
+ '../../core/6.0.0-alpha01/core-6.0.0-alpha01.module?forged=1']
+ for url in urls:
+ with self.subTest(url=url), tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ expected = self.write_kmp_publications(repository)
+ metadata = repository / 'core-jvm/6.0.0-alpha01/core-jvm-6.0.0-alpha01.module'
+ data = json.loads(metadata.read_text())
+ if url is None:
+ data['component'].pop('url')
+ else:
+ data['component']['url'] = url
+ metadata.write_text(json.dumps(data))
+ with self.assertRaises(ValueError):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core'], ['core'],
+ expected_publications=expected)
+
+ def test_component_url_cannot_disguise_a_self_component(self):
+ with tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ target = self.write_publication(repository, 'core')
+ metadata = target / 'core-6.0.0-alpha01.module'
+ data = json.loads(metadata.read_text())
+ data['component']['url'] = '../../unrelated/6.0.0-alpha01/unrelated-6.0.0-alpha01.module'
+ metadata.write_text(json.dumps(data))
+ with self.assertRaises(ValueError):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core'], ['core'],
+ expected_publications=['core'])
+
+ def test_kmp_component_url_cannot_follow_a_symlink_outside_repository(self):
+ with tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory) / 'repository'
+ expected = self.write_kmp_publications(repository)
+ metadata = repository / 'core/6.0.0-alpha01/core-6.0.0-alpha01.module'
+ outside = Path(directory) / 'outside.module'
+ outside.write_bytes(metadata.read_bytes())
+ metadata.unlink()
+ metadata.symlink_to(outside)
+ with self.assertRaises(ValueError):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core'], ['core'],
+ expected_publications=expected)
+
+ def test_kmp_link_preserves_root_and_target_pom_and_metadata_requirements(self):
+ for artifact in ['core', 'core-jvm']:
+ for extension in ['pom', 'module']:
+ with self.subTest(artifact=artifact, extension=extension), tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ expected = self.write_kmp_publications(repository)
+ (repository / artifact / '6.0.0-alpha01' / f'{artifact}-6.0.0-alpha01.{extension}').unlink()
+ with self.assertRaisesRegex(ValueError, 'missing'):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core'], ['core'],
+ expected_publications=expected)
+
+ def test_kmp_link_preserves_internal_metadata_version_checks(self):
+ for field in ['dependencies', 'dependencyConstraints', 'available-at']:
+ with self.subTest(field=field), tempfile.TemporaryDirectory() as directory:
+ repository = Path(directory)
+ expected = self.write_kmp_publications(repository)
+ metadata = repository / 'core-jvm/6.0.0-alpha01/core-jvm-6.0.0-alpha01.module'
+ data = json.loads(metadata.read_text())
+ dependency = dict(group='org.example', module='core', version=dict(requires='6.0.0-SNAPSHOT'))
+ data['variants'][0][field] = (dict(dependency, version='6.0.0-SNAPSHOT')
+ if field == 'available-at' else [dependency])
+ metadata.write_text(json.dumps(data))
+ with self.assertRaisesRegex(ValueError, 'version'):
+ CONTROL.publication_versions(repository, '6.0.0-alpha01', 'org.example', ['core'], ['core'],
+ expected_publications=expected)
+
+ def write_kmp_publications(self, repository, owner='core'):
+ version = '6.0.0-alpha01'
+ expected = [owner] + [owner + suffix for suffix in ['-android', '-jvm', '-iosarm64']]
+ root = self.write_publication(repository, owner)
+ root_metadata = root / f'{owner}-{version}.module'
+ root_data = dict(formatVersion='1.1', component=dict(
+ group='org.example', module=owner, version=version, attributes={'org.gradle.status': 'release'}), variants=[])
+ for artifact, extension in zip(expected[1:], ['aar', 'jar', 'klib']):
+ target = self.write_publication(repository, artifact)
+ metadata = target / f'{artifact}-{version}.module'
+ filename = f'{artifact}-{version}.{extension}'
+ (target / filename).write_bytes(b'publication fixture')
+ target_data = dict(formatVersion='1.1', component=dict(
+ url=f'../../{owner}/{version}/{owner}-{version}.module', group='org.example',
+ module=owner, version=version, attributes={'org.gradle.status': 'release'}), variants=[dict(
+ name='apiElements-published', files=[dict(name=filename, url=filename)],
+ dependencies=[dict(group='org.example', module=owner, version=dict(requires=version))])])
+ metadata.write_text(json.dumps(target_data))
+ root_data['variants'].append({'name': artifact, 'available-at': dict(
+ url=f'../../{artifact}/{version}/{artifact}-{version}.module',
+ group='org.example', module=artifact, version=version)})
+ root_metadata.write_text(json.dumps(root_data))
+ return expected
+
+ def write_publication(self, repository, artifact):
+ target = repository / artifact / '6.0.0-alpha01'
+ target.mkdir(parents=True)
+ (target / f'{artifact}-6.0.0-alpha01.pom').write_text(
+ 'org.example'
+ f'{artifact}6.0.0-alpha01')
+ (target / f'{artifact}-6.0.0-alpha01.module').write_text(json.dumps(dict(
+ component=dict(group='org.example', module=artifact, version='6.0.0-alpha01'), variants=[])))
+ return target
+
+
+
+class FullSuiteInstrumentationFixtures(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ (self.root / 'gradle.properties').write_text('VERSION_NAME=6.0.0-alpha01\n')
+ source = self.root / 'mutations/src/jvmTest/kotlin/ExampleTest.kt'
+ source.parent.mkdir(parents=True)
+ source.write_text('package example\nclass ExampleTest\n')
+ self.results = self.root / 'results'
+ self.results.mkdir()
+ self.xml = self.results / 'TEST-example.ExampleTest.xml'
+ self.log = self.root / 'gradle.log'
+ self.output = self.root / 'execution.json'
+ self.context = dict(sha='a' * 40, checked_out_sha='a' * 40,
+ repository='example/repository', run_id='123', run_attempt='1')
+ root_patch = mock.patch.object(CONTROL, 'ROOT', self.root)
+ root_patch.start()
+ self.addCleanup(root_patch.stop)
+ output_patch = mock.patch.object(CONTROL, 'append_outputs')
+ self.append_outputs = output_patch.start()
+ self.addCleanup(output_patch.stop)
+ summary_patch = mock.patch.dict(CONTROL.os.environ, {'GITHUB_STEP_SUMMARY': ''})
+ summary_patch.start()
+ self.addCleanup(summary_patch.stop)
+ self.write_inputs()
+
+ def write_inputs(self, log_extra='', stderr='', failure=False, name='works', cdata=False):
+ suite = ET.Element('testsuite', tests='1', failures='1' if failure else '0', errors='0', skipped='0')
+ testcase = ET.SubElement(suite, 'testcase', classname='example.ExampleTest', name=name)
+ if failure:
+ ET.SubElement(testcase, 'failure', message='original assertion').text = 'original assertion'
+ error_stream = ET.SubElement(suite, 'system-err')
+ error_stream.text = stderr
+ xml = ET.tostring(suite, encoding='unicode')
+ if cdata:
+ xml = xml.replace('' + stderr + '',
+ '')
+ self.xml.write_text(xml)
+ self.log.write_text('> Task :mutations:jvmTest' + (' FAILED' if failure else '') + '\n' + log_extra)
+
+ def record(self, exit_code=0, task='jvmTest', shard=None):
+ args = SimpleNamespace(output=str(self.output), log=str(self.log), results=str(self.results),
+ task=task, shard=shard, exit_code=exit_code)
+ CONTROL.full_suite_record(args, self.context, {})
+
+ def saved(self):
+ record = json.loads(self.output.read_text())
+ self.assertEqual(record['expected_classes'], ['example.ExampleTest'])
+ self.assertEqual(len(record['test_identifiers']), 1)
+ self.assertIn(self.xml.name, record['xml_files'])
+ return record
+
+ def test_green_xml_and_successful_task_reject_log_transform_failure(self):
+ self.write_inputs(log_extra=' Unable to transform org/example/Record$Nested\n')
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record()
+ record = self.saved()
+ self.assertEqual(record['classification'], 'infrastructure-or-incomplete')
+ self.assertEqual(record['test_identifiers'][0]['outcome'], 'passed')
+ self.assertEqual(record['instrumentation_failures'][0]['class_name'], 'org/example/Record$Nested')
+ self.assertEqual(record['instrumentation_failures'][0]['source'], 'gradle-log')
+ self.assertEqual(record['instrumentation_failures'][0]['line'], 2)
+ self.append_outputs.assert_not_called()
+
+ def test_green_xml_rejects_decoded_stderr_and_cdata_transform_failures(self):
+ for cdata in [False, True]:
+ with self.subTest(cdata=cdata):
+ self.write_inputs(stderr='\n\tUnable to transform org/example/Record$Nested\n', cdata=cdata)
+ if not cdata:
+ self.xml.write_text(self.xml.read_text().replace('transform', 'transform'))
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record()
+ record = self.saved()
+ self.assertEqual(record['classification'], 'infrastructure-or-incomplete')
+ marker = record['instrumentation_failures'][0]
+ self.assertEqual(marker['class_name'], 'org/example/Record$Nested')
+ self.assertEqual(marker['source'], 'xml-system-err')
+ self.assertEqual(marker['path'], self.xml.name)
+ self.assertEqual(marker['line'], 2)
+ self.append_outputs.assert_not_called()
+
+ def test_real_test_failure_keeps_classification_and_testcase_with_marker(self):
+ self.write_inputs(stderr='Unable to transform org/example/Record\n', failure=True)
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(exit_code=1)
+ record = self.saved()
+ self.assertEqual(record['classification'], 'test-failure')
+ self.assertEqual(record['test_identifiers'], [{'id': 'example.ExampleTest#works', 'outcome': 'failed'}])
+ self.assertIn('instrumentation_failures', record)
+ self.assertEqual(record['instrumentation_failures'][0]['class_name'], 'org/example/Record')
+ self.assertEqual(ET.parse(self.xml).find('testcase/failure').get('message'), 'original assertion')
+ self.append_outputs.assert_not_called()
+
+ def test_log_marker_preserves_legal_jvm_class_name_characters(self):
+ names = ["org/example/Foo'Bar", 'org/example/Foo"Bar', 'org/example/Foo`Bar',
+ 'org/example/Foo Bar', '"org/example/QuotedClass"',
+ 'org/example/Name extra words', ' Leading', 'Trailing ', ' \t ']
+ for class_name in names:
+ with self.subTest(class_name=class_name):
+ self.write_inputs(log_extra='Unable to transform ' + class_name + '\n')
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record()
+ record = self.saved()
+ self.assertEqual(record['classification'], 'infrastructure-or-incomplete')
+ self.assertEqual(record['instrumentation_failures'][0]['class_name'], class_name)
+ self.append_outputs.assert_not_called()
+
+ def test_cdata_stderr_marker_preserves_legal_jvm_class_name_characters(self):
+ names = ["org/example/Foo'Bar", 'org/example/Foo"Bar', 'org/example/Foo`Bar',
+ 'org/example/Foo Bar', '"org/example/QuotedClass"',
+ 'org/example/Name extra words', ' Leading', 'Trailing ', ' \t ']
+ for class_name in names:
+ with self.subTest(class_name=class_name):
+ self.write_inputs(stderr='Unable to transform ' + class_name + '\n', cdata=True)
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record()
+ record = self.saved()
+ self.assertEqual(record['classification'], 'infrastructure-or-incomplete')
+ self.assertEqual(record['instrumentation_failures'][0]['class_name'], class_name)
+ self.append_outputs.assert_not_called()
+
+ def test_log_marker_records_actual_supplied_log_path(self):
+ self.log = self.root / 'captured-worker-output.log'
+ self.write_inputs(log_extra='Unable to transform org/example/Record\n')
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record()
+ self.assertEqual(self.saved()['instrumentation_failures'][0]['path'], str(self.log))
+ self.append_outputs.assert_not_called()
+
+ def test_clean_passing_execution_still_emits_provenance(self):
+ self.record()
+ self.assertEqual(self.saved()['classification'], 'passed')
+ self.append_outputs.assert_called_once_with(self.context, '6.0.0-alpha01')
+
+ def test_mentions_quotes_and_test_names_do_not_imply_transform_failure(self):
+ mentions = (
+ 'ExampleTest > Unable to transform org/example/TestName PASSED\n'
+ 'An example says Unable to transform org/example/Prose\n'
+ '"Unable to transform org/example/Quoted"\n'
+ "'Unable to transform org/example/Quoted'\n"
+ )
+ self.write_inputs(log_extra=mentions, stderr=mentions,
+ name='Unable to transform org/example/TestName')
+ self.record()
+ self.assertEqual(self.saved()['classification'], 'passed')
+ self.append_outputs.assert_called_once_with(self.context, '6.0.0-alpha01')
+
+ def test_the_jvm_lane_refuses_results_carrying_the_lincheck_class(self):
+ trespass = self.results / ('TEST-' + CONTROL.LINCHECK_CLASS + '.xml')
+ trespass.write_text('')
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record()
+ record = json.loads(self.output.read_text())
+ self.assertEqual(record['classification'], 'infrastructure-or-incomplete')
+ self.assertIn(CONTROL.LINCHECK_CLASS, record['evidence_error'])
+ self.append_outputs.assert_not_called()
+
+ def test_the_jvm_lane_expects_every_non_lincheck_class(self):
+ source = self.root / 'mutations/src/commonTest/kotlin/OtherTest.kt'
+ source.parent.mkdir(parents=True)
+ source.write_text('package example\nclass OtherTest\n')
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record()
+ record = json.loads(self.output.read_text())
+ self.assertIn('example.OtherTest', record['expected_classes'])
+ self.assertIn('example.OtherTest', record['evidence_error'])
+
+
+class LincheckShardExecutionFixtures(unittest.TestCase):
+ def setUp(self):
+ self.assertIsNotNone(CONTROL, 'The fail-closed release controller does not exist')
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ (self.root / 'gradle.properties').write_text('VERSION_NAME=6.0.0-alpha01\n')
+ source = self.root / ('mutations/src/jvmTest/kotlin/' + CONTROL.LINCHECK_CLASS.rsplit('.', 1)[1] + '.kt')
+ source.parent.mkdir(parents=True)
+ source.write_text('package ' + CONTROL.LINCHECK_CLASS.rsplit('.', 1)[0] + '\nclass X\n')
+ self.results = self.root / 'results'
+ self.results.mkdir()
+ self.log = self.root / 'gradle.log'
+ self.output = self.root / 'execution.json'
+ self.context = dict(sha='a' * 40, checked_out_sha='a' * 40,
+ repository='example/repository', run_id='123', run_attempt='1')
+ root_patch = mock.patch.object(CONTROL, 'ROOT', self.root)
+ root_patch.start()
+ self.addCleanup(root_patch.stop)
+ output_patch = mock.patch.object(CONTROL, 'append_outputs')
+ self.append_outputs = output_patch.start()
+ self.addCleanup(output_patch.stop)
+ summary_patch = mock.patch.dict(CONTROL.os.environ, {'GITHUB_STEP_SUMMARY': ''})
+ summary_patch.start()
+ self.addCleanup(summary_patch.stop)
+
+ def marker(self, shard='1/4', indices=None, digest=None):
+ indices = CONTROL.shard_indices(shard) if indices is None else indices
+ return (CONTROL.SCENARIO_MARKER + ' shard=' + shard + ' count=' + str(len(indices)) +
+ ' indices=' + ','.join(str(value) for value in indices) +
+ ' digest=' + (digest or CONTROL.LINCHECK_SCENARIO_DIGEST))
+
+ def iterations(self, planned, executed=None):
+ """What Lincheck's own reporter prints per scenario at LoggingLevel.INFO."""
+ executed = planned if executed is None else executed
+ return ''.join('= Iteration %d / %d =\n' % (index, planned)
+ for index in range(1, executed + 1))
+
+ def write_inputs(self, marker=None, in_log=True, failure=False, iterations=None):
+ suite = ET.Element('testsuite', tests='1', failures='1' if failure else '0',
+ errors='0', skipped='0')
+ testcase = ET.SubElement(suite, 'testcase', classname=CONTROL.LINCHECK_CLASS,
+ name='inMemoryJournalTransactions_areLinearizable')
+ if failure:
+ ET.SubElement(testcase, 'failure', message='not linearizable').text = 'not linearizable'
+ if marker is not None and iterations is None:
+ iterations = self.iterations(int(marker.split(' count=')[1].split(' ')[0]))
+ standard_output = '' if marker is None else marker + '\n' + (iterations or '')
+ ET.SubElement(suite, 'system-out').text = '' if in_log else standard_output
+ (self.results / ('TEST-' + CONTROL.LINCHECK_CLASS + '.xml')).write_text(
+ ET.tostring(suite, encoding='unicode'))
+ log = '> Task :mutations:lincheckTest' + (' FAILED' if failure else '') + '\n'
+ if in_log:
+ log += ''.join(' ' + line + '\n' for line in standard_output.splitlines())
+ self.log.write_text(log)
+
+ def record(self, shard='1/4', exit_code=0):
+ args = SimpleNamespace(output=str(self.output), log=str(self.log), results=str(self.results),
+ task='lincheckTest', shard=shard, exit_code=exit_code)
+ CONTROL.full_suite_record(args, self.context, {})
+
+ def test_a_clean_shard_records_its_scenario_indices_from_the_gradle_log(self):
+ self.write_inputs(marker=self.marker('2/4'))
+ self.record(shard='2/4')
+ record = json.loads(self.output.read_text())
+ self.assertEqual(record['classification'], 'passed')
+ self.assertEqual(record['task'], ':mutations:lincheckTest')
+ self.assertEqual(record['shard'], '2/4')
+ self.assertEqual(record['scenario_indices'], CONTROL.shard_indices('2/4'))
+ self.assertEqual(record['scenario_digest'], CONTROL.LINCHECK_SCENARIO_DIGEST)
+ self.assertEqual(record['executed_iterations'], len(CONTROL.shard_indices('2/4')))
+ self.assertEqual(record['executed_classes'], [CONTROL.LINCHECK_CLASS])
+ self.append_outputs.assert_called_once_with(self.context, '6.0.0-alpha01')
+
+ def test_standard_output_captured_only_in_the_result_xml_still_counts(self):
+ self.write_inputs(marker=self.marker('3/4'), in_log=False)
+ self.record(shard='3/4')
+ self.assertEqual(json.loads(self.output.read_text())['scenario_indices'],
+ CONTROL.shard_indices('3/4'))
+
+ def test_a_shard_without_scenario_evidence_is_refused(self):
+ self.write_inputs(marker=None)
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record()
+ self.assertIn(CONTROL.SCENARIO_MARKER, json.loads(self.output.read_text())['evidence_error'])
+ self.append_outputs.assert_not_called()
+
+ def test_indices_that_disagree_with_the_requested_shard_are_refused(self):
+ for marker in [self.marker('1/4', CONTROL.shard_indices('1/4')[:-1]),
+ self.marker('1/4', CONTROL.shard_indices('2/4')),
+ self.marker('2/4')]:
+ with self.subTest(marker=marker[:60]):
+ self.write_inputs(marker=marker)
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard='1/4')
+ self.append_outputs.assert_not_called()
+
+ def test_conflicting_scenario_markers_are_refused(self):
+ self.write_inputs(marker=self.marker('1/4'))
+ self.log.write_text(self.log.read_text() + ' ' + self.marker('2/4') + '\n')
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard='1/4')
+ self.append_outputs.assert_not_called()
+
+ def test_a_failing_shard_keeps_its_test_failure_classification(self):
+ self.write_inputs(marker=self.marker('1/4'), failure=True)
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard='1/4', exit_code=1)
+ self.assertEqual(json.loads(self.output.read_text())['classification'], 'test-failure')
+ self.append_outputs.assert_not_called()
+
+ def test_a_shard_reporting_a_foreign_plan_digest_is_refused(self):
+ self.write_inputs(marker=self.marker('1/4', digest='f' * 16))
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard='1/4')
+ error = json.loads(self.output.read_text())['evidence_error']
+ self.assertIn(CONTROL.LINCHECK_SCENARIO_DIGEST, error)
+ self.append_outputs.assert_not_called()
+
+ def test_a_shard_that_logged_fewer_iterations_than_it_planned_is_refused(self):
+ planned = len(CONTROL.shard_indices('1/4'))
+ self.write_inputs(marker=self.marker('1/4'),
+ iterations=self.iterations(planned, executed=planned - 1))
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard='1/4')
+ record = json.loads(self.output.read_text())
+ self.assertEqual(record['executed_iterations'], planned - 1)
+ self.assertIn('iteration', record['evidence_error'])
+ self.append_outputs.assert_not_called()
+
+ def test_a_shard_with_no_iteration_evidence_at_all_is_refused(self):
+ self.write_inputs(marker=self.marker('1/4'), iterations='')
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard='1/4')
+ self.assertIn('iteration', json.loads(self.output.read_text())['evidence_error'])
+
+ def test_a_shard_whose_iteration_total_is_not_its_plan_is_refused(self):
+ planned = len(CONTROL.shard_indices('1/4'))
+ self.write_inputs(marker=self.marker('1/4'), iterations=self.iterations(planned + 100))
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard='1/4')
+ self.assertIn('iteration', json.loads(self.output.read_text())['evidence_error'])
+
+ def test_a_failing_shard_reports_its_short_iteration_count_without_masking_the_failure(self):
+ planned = len(CONTROL.shard_indices('1/4'))
+ self.write_inputs(marker=self.marker('1/4'), failure=True,
+ iterations=self.iterations(planned, executed=3))
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard='1/4', exit_code=1)
+ record = json.loads(self.output.read_text())
+ self.assertEqual(record['classification'], 'test-failure')
+ self.assertEqual(record['executed_iterations'], 3)
+
+ def test_the_lincheck_lane_refuses_an_empty_shard(self):
+ for shard in [None, '', ' ']:
+ with self.subTest(shard=shard):
+ self.write_inputs(marker=self.marker('1/4'))
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard=shard)
+ self.assertIn('shard', json.loads(self.output.read_text())['evidence_error'])
+ self.append_outputs.assert_not_called()
+
+ def test_a_cached_shard_is_refused(self):
+ self.write_inputs(marker=self.marker('1/4'))
+ self.log.write_text('> Task :mutations:lincheckTest FROM-CACHE\n ' + self.marker('1/4') + '\n' +
+ self.iterations(len(CONTROL.shard_indices('1/4'))))
+ with self.assertRaisesRegex(ValueError, 'full-suite evidence did not pass'):
+ self.record(shard='1/4')
+ record = json.loads(self.output.read_text())
+ self.assertEqual(record['classification'], 'unexecuted')
+ self.append_outputs.assert_not_called()
+
+
+class FullSuiteShardCensusFixtures(unittest.TestCase):
+ SHARDS = 4
+
+ def setUp(self):
+ self.assertIsNotNone(CONTROL, 'The fail-closed release controller does not exist')
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ (self.root / 'gradle.properties').write_text('VERSION_NAME=6.0.0-alpha01\n')
+ for package, name in [('example', 'ExampleTest'),
+ (CONTROL.LINCHECK_CLASS.rsplit('.', 1)[0],
+ CONTROL.LINCHECK_CLASS.rsplit('.', 1)[1])]:
+ source = self.root / ('mutations/src/jvmTest/kotlin/' + name + '.kt')
+ source.parent.mkdir(parents=True, exist_ok=True)
+ source.write_text('package ' + package + '\nclass ' + name + '\n')
+ self.executions = self.root / 'artifacts'
+ self.sha = 'a' * 40
+ self.context = dict(repository='MobileNativeFoundation/Store', event='push',
+ ref='refs/tags/v6.0.0-alpha01', sha=self.sha, checked_out_sha=self.sha,
+ run_id='123', run_attempt='1')
+ self.manifest = dict(full_suite_jobs=['full-mutations-jvm', 'lincheck'])
+ self.needs = {name: dict(result='success', outputs=dict(
+ source_sha=self.sha, run_id='123', run_attempt='1', version='6.0.0-alpha01'))
+ for name in self.manifest['full_suite_jobs']}
+ root_patch = mock.patch.object(CONTROL, 'ROOT', self.root)
+ root_patch.start()
+ self.addCleanup(root_patch.stop)
+ self.write_jvm()
+ for index in range(1, self.SHARDS + 1):
+ self.write_shard(index)
+
+ def write_execution(self, name, **fields):
+ record = dict(schema_version=1, source_sha=self.sha, checked_out_sha=self.sha,
+ version='6.0.0-alpha01', repository=self.context['repository'],
+ run_id='123', run_attempt='1', gradle_exit_code=0, classification='passed',
+ task_outcome='executed', log_sha256='0' * 64)
+ record.update(fields)
+ path = self.executions / name / 'full-suite-evidence' / 'execution.json'
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(record))
+ return path
+
+ def write_jvm(self, **fields):
+ fields.setdefault('executed_classes', ['example.ExampleTest'])
+ fields.setdefault('test_identifiers', [dict(id='example.ExampleTest#works', outcome='passed')])
+ return self.write_execution('full-jvm-results-jvmTest', task=':mutations:jvmTest', shard=None, **fields)
+
+ def write_shard(self, index, **fields):
+ shard = f'{index}/{self.SHARDS}'
+ fields.setdefault('scenario_indices', CONTROL.shard_indices(shard))
+ fields.setdefault('scenario_digest', CONTROL.LINCHECK_SCENARIO_DIGEST)
+ fields.setdefault('executed_iterations', len(CONTROL.shard_indices(shard)))
+ fields.setdefault('executed_classes', [CONTROL.LINCHECK_CLASS])
+ fields.setdefault('test_identifiers', [
+ dict(id=CONTROL.LINCHECK_CLASS + '#inMemoryJournalTransactions_areLinearizable', outcome='passed')])
+ return self.write_execution('full-jvm-results-lincheck-' + str(index),
+ task=':mutations:lincheckTest', shard=shard, **fields)
+
+ def validate(self):
+ return CONTROL.full_suite_validation(self.context, '6.0.0-alpha01', self.manifest, self.needs,
+ str(self.executions), self.SHARDS)
+
+ def test_a_complete_census_validates_the_single_forced_execution(self):
+ record = self.validate()
+ self.assertEqual(record['classification'], 'validated')
+ self.assertEqual(record['sha'], self.sha)
+ self.assertEqual(record['checks'], self.needs)
+ self.assertEqual(record['lincheck_shards'], ['1/4', '2/4', '3/4', '4/4'])
+ self.assertEqual(record['scenario_count'], CONTROL.LINCHECK_SCENARIO_COUNT)
+ self.assertEqual(record['scenario_digest'], CONTROL.LINCHECK_SCENARIO_DIGEST)
+ self.assertEqual(len(record['executions']), self.SHARDS + 1)
+
+ def test_two_digit_shard_counts_are_compared_by_shard_not_by_string_order(self):
+ shutil.rmtree(self.executions / 'full-jvm-results-lincheck-1')
+ for index in range(2, self.SHARDS + 1):
+ shutil.rmtree(self.executions / ('full-jvm-results-lincheck-' + str(index)))
+ self.SHARDS = 10
+ for index in range(1, 11):
+ self.write_shard(index)
+ self.assertEqual(self.validate()['lincheck_shards'][-1], '10/10')
+
+ def test_a_missing_shard_is_refused(self):
+ (self.executions / 'full-jvm-results-lincheck-3' / 'full-suite-evidence' / 'execution.json').unlink()
+ with self.assertRaisesRegex(ValueError, '3/4'):
+ self.validate()
+
+ def test_a_missing_jvm_execution_is_refused(self):
+ (self.executions / 'full-jvm-results-jvmTest' / 'full-suite-evidence' / 'execution.json').unlink()
+ with self.assertRaisesRegex(ValueError, 'jvmTest'):
+ self.validate()
+
+ def test_no_archived_execution_at_all_is_refused(self):
+ shutil.rmtree(self.executions)
+ with self.assertRaisesRegex(ValueError, 'execution'):
+ self.validate()
+
+ def test_a_shard_not_classified_passed_is_refused(self):
+ for classification in ['unexecuted', 'infrastructure-or-incomplete', 'test-failure']:
+ with self.subTest(classification=classification):
+ self.write_shard(2, classification=classification)
+ with self.assertRaisesRegex(ValueError, classification):
+ self.validate()
+ self.write_shard(2)
+ self.validate()
+
+ def test_scenario_indices_short_of_the_whole_plan_are_refused(self):
+ self.write_shard(4, scenario_indices=CONTROL.shard_indices('4/4')[:-1])
+ with self.assertRaisesRegex(ValueError, 'scenario'):
+ self.validate()
+
+ def test_overlapping_scenario_indices_are_refused(self):
+ self.write_shard(4, scenario_indices=CONTROL.shard_indices('3/4'))
+ with self.assertRaisesRegex(ValueError, 'scenario'):
+ self.validate()
+
+ def test_shards_that_do_not_all_carry_the_pinned_plan_digest_are_refused(self):
+ for digest in ['f' * 16, None, '']:
+ with self.subTest(digest=digest):
+ self.write_shard(2, scenario_digest=digest)
+ with self.assertRaisesRegex(ValueError, CONTROL.LINCHECK_SCENARIO_DIGEST):
+ self.validate()
+ self.write_shard(2)
+ self.validate()
+
+ def test_a_record_from_an_unknown_task_is_refused(self):
+ self.write_execution('stray', task=':mutations:someOtherTest', shard=None)
+ with self.assertRaisesRegex(ValueError, 'someOtherTest'):
+ self.validate()
+
+ def test_a_record_with_no_task_at_all_is_refused(self):
+ self.write_execution('stray', shard=None)
+ with self.assertRaisesRegex(ValueError, 'lane|task'):
+ self.validate()
+
+ def test_jvm_results_containing_the_lincheck_class_are_refused(self):
+ self.write_jvm(executed_classes=['example.ExampleTest', CONTROL.LINCHECK_CLASS])
+ with self.assertRaisesRegex(ValueError, CONTROL.LINCHECK_CLASS):
+ self.validate()
+
+ def test_jvm_results_missing_a_suite_class_are_refused(self):
+ self.write_jvm(executed_classes=[])
+ with self.assertRaisesRegex(ValueError, 'example.ExampleTest'):
+ self.validate()
+
+ def test_a_shard_without_a_passed_lincheck_testcase_is_refused(self):
+ for identifiers in [[], [dict(id=CONTROL.LINCHECK_CLASS + '#x', outcome='skipped')]]:
+ with self.subTest(identifiers=identifiers):
+ self.write_shard(1, test_identifiers=identifiers)
+ with self.assertRaisesRegex(ValueError, '1/4'):
+ self.validate()
+
+ def test_provenance_drift_across_shards_is_refused(self):
+ for field, value in [('source_sha', 'b' * 40), ('checked_out_sha', 'b' * 40),
+ ('run_id', '456'), ('run_attempt', '2'), ('version', '6.0.0-alpha02')]:
+ with self.subTest(field=field):
+ self.write_shard(3, **{field: value})
+ with self.assertRaisesRegex(ValueError, 'provenance|SHA|version'):
+ self.validate()
+ self.write_shard(3)
+ self.validate()
+
+ def test_leaf_job_provenance_is_still_required(self):
+ for name in self.manifest['full_suite_jobs']:
+ with self.subTest(job=name):
+ needs = copy.deepcopy(self.needs)
+ needs[name]['outputs']['run_attempt'] = '0'
+ with self.assertRaisesRegex(ValueError, 'provenance'):
+ CONTROL.full_suite_validation(self.context, '6.0.0-alpha01', self.manifest, needs,
+ str(self.executions), self.SHARDS)
+ needs = copy.deepcopy(self.needs)
+ needs[name]['result'] = 'failure'
+ with self.assertRaisesRegex(ValueError, 'success'):
+ CONTROL.full_suite_validation(self.context, '6.0.0-alpha01', self.manifest, needs,
+ str(self.executions), self.SHARDS)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/.github/scripts/tests/test_release_simulation.py b/.github/scripts/tests/test_release_simulation.py
new file mode 100644
index 000000000..9d6c4550c
--- /dev/null
+++ b/.github/scripts/tests/test_release_simulation.py
@@ -0,0 +1,219 @@
+import importlib.util
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[3]
+SPEC = importlib.util.spec_from_file_location('release_control', ROOT / '.github/scripts/release_control.py')
+CONTROL = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(CONTROL)
+# One Maven invocation per shipping artifact. Read from the manifest so a roster change is a
+# one-file edit rather than a count to chase through these fixtures.
+# The independent check that this roster matches the BOM and STABILITY.md lives in
+# test_workflow_contract.py's test_publication_roster_matches_bom_and_root_version.
+ARTIFACTS = json.loads((ROOT / '.github/release-manifest.json').read_text())['artifacts']
+
+
+class ReleaseSimulation(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ (self.root / '.github/scripts').mkdir(parents=True)
+ shutil.copy(ROOT / '.github/scripts/release_control.py', self.root / '.github/scripts')
+ shutil.copy(ROOT / '.github/release-manifest.json', self.root / '.github')
+ (self.root / 'gradle.properties').write_text('VERSION_NAME=6.0.0-alpha01\n')
+ (self.root / 'CHANGELOG.md').write_text('## [6.0.0-alpha01] (2026-09-06)\n\nRelease changes.\n')
+ binaries = self.root / 'bin'
+ binaries.mkdir()
+ self.env = dict(os.environ, PATH=str(binaries) + os.pathsep + os.environ['PATH'],
+ GITHUB_REPOSITORY='MobileNativeFoundation/Store', GITHUB_EVENT_NAME='push',
+ GITHUB_REF='refs/tags/v6.0.0-alpha01', GITHUB_SHA='a' * 40,
+ GITHUB_RUN_ID='123', GITHUB_RUN_ATTEMPT='1', STUB_DIRECTORY=str(self.root))
+ needs = {name: {'result': 'success', 'outputs': dict(source_sha='a' * 40, run_id='123',
+ run_attempt='1', version='6.0.0-alpha01')} for name in
+ json.loads((ROOT / '.github/release-manifest.json').read_text())['release_jobs']}
+ for name in ['release-matrix', 'release-full-suite']:
+ needs[name]['outputs'] = dict(source_sha='a' * 40, run_id='123', run_attempt='1', version='6.0.0-alpha01')
+ self.env['VALIDATION_NEEDS'] = json.dumps(needs)
+ self.executable(binaries / 'git', 'import os\nprint(os.environ["GITHUB_SHA"])\n')
+ self.executable(binaries / 'gh', '''import json, os, sys
+from pathlib import Path
+root = Path(os.environ['STUB_DIRECTORY'])
+state = root / 'github-release.json'
+operation = sys.argv[2]
+if operation == 'view':
+ if not state.exists():
+ sys.exit(1)
+ print(state.read_text())
+elif operation == 'create':
+ if state.exists():
+ sys.exit(1)
+ state.write_text(json.dumps(dict(tagName=sys.argv[3], draft=True)))
+elif operation == 'upload':
+ if os.environ.get('STUB_FAIL_RECORD') == '1':
+ sys.exit(1)
+ receipt = Path(sys.argv[4])
+ (root / 'uploaded-receipt.json').write_text(receipt.read_text())
+elif operation == 'edit':
+ record = json.loads(state.read_text())
+ record['draft'] = False
+ state.write_text(json.dumps(record))
+else:
+ sys.exit(2)
+''')
+ self.executable(self.root / 'gradlew', '''import os, sys
+from pathlib import Path
+root = Path(os.environ['STUB_DIRECTORY'])
+with (root / 'maven-calls.txt').open('a') as calls:
+ calls.write(sys.argv[1] + '\\n')
+if os.environ.get('STUB_FAIL_MODULE') and sys.argv[1].startswith(':' + os.environ['STUB_FAIL_MODULE'] + ':'):
+ sys.exit(1)
+''')
+
+ def executable(self, path, body):
+ path.write_text('#!/usr/bin/env python3\n' + body)
+ path.chmod(0o755)
+
+ def run_command(self, command, success=True, arguments=()):
+ result = subprocess.run(['python3', '.github/scripts/release_control.py', command, *arguments],
+ cwd=self.root, env=self.env, text=True, capture_output=True)
+ if success:
+ self.assertEqual(result.returncode, 0, result.stderr)
+ else:
+ self.assertNotEqual(result.returncode, 0, result.stdout)
+ return result
+
+ def test_complete_publish_then_record_failure_and_idempotent_repair(self):
+ for command in ['gate', 'reserve', 'publish']:
+ self.run_command(command)
+ receipt = (self.root / 'publication-receipt.json').read_bytes()
+ self.assertEqual(len((self.root / 'maven-calls.txt').read_text().splitlines()), len(ARTIFACTS))
+ self.env['STUB_FAIL_RECORD'] = '1'
+ self.run_command('record', success=False)
+ self.assertEqual((self.root / 'publication-receipt.json').read_bytes(), receipt)
+ self.run_command('reserve', success=False)
+ self.env.pop('STUB_FAIL_RECORD')
+ self.run_command('record')
+ self.run_command('record')
+ self.assertEqual((self.root / 'uploaded-receipt.json').read_bytes(), receipt)
+ self.assertFalse(json.loads((self.root / 'github-release.json').read_text())['draft'])
+ self.assertEqual(len((self.root / 'maven-calls.txt').read_text().splitlines()), len(ARTIFACTS))
+
+ def test_partial_maven_success_blocks_release_record_and_automatic_retry(self):
+ for command in ['gate', 'reserve']:
+ self.run_command(command)
+ self.env['STUB_FAIL_MODULE'] = 'testing'
+ self.run_command('publish', success=False)
+ receipt = json.loads((self.root / 'publication-receipt.json').read_text())
+ self.assertEqual(receipt['published_modules'], ['core'])
+ self.assertEqual(receipt['attempting_module'], 'testing')
+ self.run_command('record', success=False)
+ self.run_command('reserve', success=False)
+ self.assertEqual(len((self.root / 'maven-calls.txt').read_text().splitlines()), 2)
+
+ def test_snapshot_dispatch_uses_snapshot_tasks_without_github_release(self):
+ (self.root / 'gradle.properties').write_text('VERSION_NAME=6.0.0-SNAPSHOT\n')
+ self.env.update(GITHUB_EVENT_NAME='workflow_dispatch', GITHUB_REF='refs/heads/store6')
+ needs = json.loads(self.env['VALIDATION_NEEDS'])
+ for job in needs.values():
+ job['outputs']['version'] = '6.0.0-SNAPSHOT'
+ self.env['VALIDATION_NEEDS'] = json.dumps(needs)
+ for command in ['gate', 'reserve', 'publish', 'record']:
+ self.run_command(command)
+ self.assertFalse((self.root / 'github-release.json').exists())
+ calls = (self.root / 'maven-calls.txt').read_text().splitlines()
+ self.assertEqual(len(calls), len(ARTIFACTS))
+ self.assertTrue(all(call.endswith(':publishToMavenCentral') for call in calls))
+
+ def write_execution(self, name, **fields):
+ record = dict(schema_version=1, source_sha='a' * 40, checked_out_sha='a' * 40,
+ version='6.0.0-alpha01', repository='MobileNativeFoundation/Store',
+ run_id='123', run_attempt='1', gradle_exit_code=0, classification='passed',
+ task_outcome='executed', log_sha256='0' * 64)
+ record.update(fields)
+ path = self.root / 'full-suite-artifacts' / name / 'full-suite-evidence' / 'execution.json'
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(record))
+
+ def write_full_suite_executions(self, shards=4):
+ lincheck = 'org.mobilenativefoundation.store6.mutations.MutationJournalLincheckTest'
+ for package, name in [('example', 'ExampleTest'), (lincheck.rsplit('.', 1)[0], lincheck.rsplit('.', 1)[1])]:
+ source = self.root / 'mutations/src/jvmTest/kotlin' / (name + '.kt')
+ source.parent.mkdir(parents=True, exist_ok=True)
+ source.write_text('package ' + package + '\nclass ' + name + '\n')
+ self.write_execution('results-jvmTest', task=':mutations:jvmTest', shard=None,
+ executed_classes=['example.ExampleTest'],
+ test_identifiers=[dict(id='example.ExampleTest#works', outcome='passed')])
+ for index in range(1, shards + 1):
+ indices = [value for value in range(CONTROL.LINCHECK_SCENARIO_COUNT)
+ if value % shards == index - 1]
+ self.write_execution(
+ 'results-lincheck-' + str(index), task=':mutations:lincheckTest',
+ shard=f'{index}/{shards}',
+ scenario_indices=indices,
+ scenario_digest=CONTROL.LINCHECK_SCENARIO_DIGEST,
+ executed_iterations=len(indices),
+ executed_classes=[lincheck],
+ test_identifiers=[dict(id=lincheck + '#inMemoryJournalTransactions_areLinearizable',
+ outcome='passed')])
+ self.env['FULL_SUITE_EXECUTIONS'] = 'full-suite-artifacts'
+ self.env['FULL_SUITE_SHARDS'] = str(shards)
+
+ def test_matrix_and_full_suite_cli_preserve_leaf_attempt_provenance(self):
+ manifest = json.loads((self.root / '.github/release-manifest.json').read_text())
+ output = self.root / 'job-outputs.txt'
+ self.env['GITHUB_OUTPUT'] = str(output)
+ self.write_full_suite_executions()
+ for command, required in [('matrix', manifest['matrix_jobs']),
+ ('full-suite', manifest['full_suite_jobs'])]:
+ with self.subTest(command=command):
+ needs = {name: dict(result='success', outputs=dict(source_sha='a' * 40, run_id='123',
+ run_attempt='1', version='6.0.0-alpha01'))
+ for name in required}
+ self.env['VALIDATION_NEEDS'] = json.dumps(needs)
+ self.run_command(command)
+ record = json.loads((self.root / 'release-evidence.json').read_text())
+ self.assertEqual(record['checks'], needs)
+ self.assertIn('version=6.0.0-alpha01\n', output.read_text())
+ saved_outputs = output.read_bytes()
+ for name in required:
+ with self.subTest(job=name):
+ needs[name]['outputs']['run_attempt'] = '0'
+ self.env['VALIDATION_NEEDS'] = json.dumps(needs)
+ self.run_command(command, success=False)
+ self.assertEqual(output.read_bytes(), saved_outputs)
+ needs[name]['outputs']['run_attempt'] = '1'
+ self.assertFalse((self.root / 'maven-calls.txt').exists())
+
+ def test_the_full_suite_cli_refuses_an_incomplete_shard_census(self):
+ manifest = json.loads((self.root / '.github/release-manifest.json').read_text())
+ output = self.root / 'job-outputs.txt'
+ self.env['GITHUB_OUTPUT'] = str(output)
+ self.write_full_suite_executions()
+ self.env['VALIDATION_NEEDS'] = json.dumps({
+ name: dict(result='success', outputs=dict(source_sha='a' * 40, run_id='123',
+ run_attempt='1', version='6.0.0-alpha01'))
+ for name in manifest['full_suite_jobs']})
+ record = self.root / 'full-suite-artifacts/results-lincheck-2/full-suite-evidence/execution.json'
+ saved = record.read_text()
+ record.unlink()
+ self.run_command('full-suite', success=False)
+ self.assertFalse(output.exists())
+ record.write_text(saved)
+ for missing in ['FULL_SUITE_EXECUTIONS', 'FULL_SUITE_SHARDS']:
+ with self.subTest(missing=missing):
+ value = self.env.pop(missing)
+ self.run_command('full-suite', success=False)
+ self.assertFalse(output.exists())
+ self.env[missing] = value
+ self.run_command('full-suite')
+ self.assertIn('version=6.0.0-alpha01\n', output.read_text())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/.github/scripts/tests/test_workflow_contract.py b/.github/scripts/tests/test_workflow_contract.py
new file mode 100644
index 000000000..4d75ebf32
--- /dev/null
+++ b/.github/scripts/tests/test_workflow_contract.py
@@ -0,0 +1,141 @@
+import json
+from pathlib import Path
+import re
+import unittest
+
+ROOT = Path(__file__).resolve().parents[3]
+
+
+class WorkflowContract(unittest.TestCase):
+ def setUp(self):
+ self.manifest = json.loads((ROOT / '.github/release-manifest.json').read_text())
+ self.ci = (ROOT / '.github/workflows/ci.yml').read_text()
+ self.matrix = (ROOT / '.github/workflows/store6.yml').read_text()
+ self.full = (ROOT / '.github/workflows/store6-full-jvm.yml').read_text()
+
+ def test_complete_reusable_matrix_has_no_missing_job(self):
+ self.assertIn('workflow_call:', self.matrix)
+ jobs = set(re.findall(r'^ ([\w-]+):\n(?= (?:if:|runs-on:))', self.matrix, re.MULTILINE))
+ self.assertEqual(jobs - {'docs-sync-guard', 'validation-evidence'}, set(self.manifest['matrix_jobs']))
+ receipt = self.matrix.split(' validation-evidence:', 1)[1]
+ needs = re.search(r'needs: \[(.*?)\]', receipt)[1].split(', ')
+ self.assertEqual(set(needs), set(self.manifest['matrix_jobs']))
+
+ def test_publication_waits_for_same_run_validation(self):
+ publish = self.ci.split(' publish:', 1)[1]
+ needs = re.search(r'needs: \[(.*?)\]', publish)
+ self.assertIsNotNone(needs, 'Publication must list every required validation prerequisite')
+ self.assertEqual(set(needs[1].split(', ')), set(self.manifest['release_jobs']))
+ self.assertIn('uses: ./.github/workflows/store6.yml', self.ci)
+ self.assertIn('uses: ./.github/workflows/store6-full-jvm.yml', self.ci)
+ self.assertLess(publish.index('release_control.py gate'), publish.index('release_control.py reserve'))
+ self.assertLess(publish.index('release_control.py reserve'), publish.index('release_control.py publish'))
+ self.assertLess(publish.index('release_control.py publish'), publish.index('publication-receipt-${{'))
+ self.assertLess(publish.index('publication-receipt-${{'), publish.index('release_control.py record'))
+
+ def test_the_full_suite_is_one_forced_execution_split_into_shards(self):
+ self.assertIn('workflow_call:', self.full)
+ self.assertNotIn('sequence', self.full)
+ self.assertNotIn('first-execution', self.full)
+ self.assertNotIn('second-execution', self.full)
+ self.assertIn('shard: [1, 2, 3, 4]', self.full)
+ self.assertIn('configuration cache', (ROOT / 'mutations/build.gradle.kts').read_text())
+ self.assertIn('fail-fast: false', self.full)
+ self.assertIn('shard: ${{ matrix.shard }}/4', self.full)
+ self.assertEqual(self.full.count('uses: ./.github/workflows/store6-full-jvm-run.yml'), 2)
+ for name, timeout in [('full-mutations-jvm', 'timeout_minutes: 60'),
+ ('lincheck', 'timeout_minutes: 150'),
+ ('validation-evidence', 'timeout-minutes: 10')]:
+ with self.subTest(job=name):
+ self.assertIn(f' {name}:\n', self.full)
+ self.assertIn(timeout, self.full)
+ self.assertIn('lincheck_runner', self.full)
+ self.assertIn('macos-latest', self.full)
+ self.assertIn("runner: ${{ inputs.lincheck_runner || 'ubuntu-latest' }}", self.full)
+ config = (ROOT / 'mutations/build.gradle.kts').read_text()
+ self.assertIn('outputs.upToDateWhen { false }', config)
+ self.assertIn('outputs.doNotCacheIf(', config)
+ self.assertNotIn('lincheck.instrumentAllClasses', config)
+ self.assertNotIn('forkEvery', config)
+ runner = ROOT / '.github/workflows/store6-full-jvm-run.yml'
+ self.assertTrue(runner.exists(), 'Full-suite execution workflow must exist')
+ run = runner.read_text()
+ self.assertIn('--console=plain', run)
+ self.assertIn('release_control.py full-suite-execution', run)
+ self.assertIn('case "${LANE_TASK}" in', run)
+ self.assertIn('jvmTest|lincheckTest', run)
+ self.assertIn('./gradlew ":mutations:${LANE_TASK}"', run)
+ self.assertIn('arguments="-Pstore6.lincheckShard=${LANE_SHARD}"', run)
+ self.assertIn("arguments='-Pstore6.fullJvmSuite'", run)
+ self.assertIn('runs-on: ${{ inputs.runner }}', run)
+ self.assertIn('timeout-minutes: ${{ inputs.timeout_minutes }}', run)
+ self.assertIn('if: ${{ always() }}', run)
+ self.assertNotIn('docs/v6', run + self.full)
+ self.assertNotIn('gh issue', run + self.full)
+
+ def test_the_lincheck_scenario_count_matches_the_kotlin_plan(self):
+ control = (ROOT / '.github/scripts/release_control.py').read_text()
+ plan = (ROOT / 'mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations'
+ '/LincheckScenarioPlan.kt').read_text()
+ self.assertEqual(re.search(r'(?m)^LINCHECK_SCENARIO_COUNT = (\d+)$', control)[1],
+ re.search(r'(?m)^ *const val SCENARIO_COUNT: Int = (\d+)$', plan)[1])
+ self.assertEqual(re.search(r"(?m)^LINCHECK_SCENARIO_DIGEST = '([0-9a-f]+)'$", control)[1],
+ re.search(r'(?m)^ *const val SCENARIO_DIGEST: String = "([0-9a-f]+)"$', plan)[1])
+ self.assertIn(r' digest=([0-9a-f]+)', control)
+ self.assertIn(r'= Iteration (\d+) / (\d+) =', control)
+ self.assertIn('LoggingLevel.INFO', (ROOT / 'mutations/src/jvmTest/kotlin/org/mobilenativefoundation'
+ '/store6/mutations/MutationJournalLincheckTest.kt').read_text())
+ self.assertIn('CURATED_SCENARIO_INDEX', plan)
+ self.assertIn('must never be regenerated', plan)
+ self.assertEqual(re.search(r"(?m)^SCENARIO_MARKER = '([^']+)'$", control)[1],
+ re.search(r'SCENARIO_MARKER: String = "([^"]+)"', plan)[1])
+ self.assertEqual(re.search(r"(?m)^LINCHECK_CLASS = '([^']+)'$", control)[1].rsplit('.', 1)[1],
+ 'MutationJournalLincheckTest')
+
+ def test_publication_roster_matches_bom_and_root_version(self):
+ bom = (ROOT / 'bom/build.gradle.kts').read_text()
+ constraints = re.findall(r'api\("\$group:([^:]+):\$version"\)', bom)
+ self.assertEqual(set(constraints), set(self.manifest['artifacts']) - {'bom'})
+ self.assertNotIn('version="6.0.0-SNAPSHOT"', self.matrix)
+ self.assertIn('release_control.py version', self.matrix)
+ self.assertIn('release_control.py publication-versions', self.matrix)
+ rows = re.findall(r'^\| `([^`]+)` \|.*\| (alpha01[^|]*)\|$',
+ (ROOT / 'STABILITY.md').read_text(), re.MULTILINE)
+ self.assertEqual({module for module, _ in rows}, set(self.manifest['artifacts']))
+
+ def test_record_repair_cannot_invoke_maven(self):
+ path = ROOT / '.github/workflows/store6-release-record.yml'
+ self.assertTrue(path.exists(), 'An independent record-repair workflow must exist')
+ repair = path.read_text()
+ self.assertIn('release_control.py record', repair)
+ self.assertIn('actions/download-artifact@v4', repair)
+ self.assertNotIn('./gradlew', repair)
+ self.assertNotIn('release_control.py publish', repair)
+
+ def test_every_validation_leaf_emits_checked_source_provenance(self):
+ for workflow, names in [(self.ci, ['build-and-test', 'workflow-fixtures']),
+ (self.matrix, self.manifest['matrix_jobs'])]:
+ for name in names:
+ with self.subTest(job=name):
+ block = re.split(r'\n (?=\S)', workflow.split(f' {name}:\n', 1)[1], maxsplit=1)[0]
+ for field in ['source_sha', 'run_id', 'run_attempt', 'version']:
+ self.assertIn(f'{field}: ${{{{ steps.provenance.outputs.{field} }}}}', block)
+ self.assertIn('release_control.py provenance', block)
+
+ def test_full_suite_validation_aggregates_every_execution(self):
+ self.assertEqual(self.manifest.get('full_suite_jobs'), ['full-mutations-jvm', 'lincheck'])
+ self.assertIn('needs: [' + ', '.join(self.manifest['full_suite_jobs']) + ']', self.full)
+ self.assertIn('release_control.py full-suite --output full-suite-validation.json', self.full)
+ self.assertIn('actions/download-artifact@v4', self.full)
+ self.assertIn('FULL_SUITE_EXECUTIONS:', self.full)
+ self.assertIn("FULL_SUITE_SHARDS: '4'", self.full)
+ for field in ['source_sha', 'run_id', 'run_attempt', 'version']:
+ self.assertIn(f'value: ${{{{ jobs.validation-evidence.outputs.{field} }}}}', self.full)
+
+ def test_publication_verification_passes_each_expected_target(self):
+ self.assertIn('publications+=("${artifact_id}")', self.matrix)
+ self.assertIn('--publications "${publications[@]}"', self.matrix)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/.github/workflows/KMMBridge-Release.yml b/.github/workflows/KMMBridge-Release.yml
deleted file mode 100644
index d1a9a9b9e..000000000
--- a/.github/workflows/KMMBridge-Release.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-# Publish the release XCFramework to a GitHub Release.
-# Debug XCFrameworks are built locally on demand via `./gradlew :spmDevBuild`.
-name: KMMBridge-Publish
-on:
- workflow_dispatch:
-
-jobs:
- call-publish:
- permissions:
- contents: write
- packages: write
- uses: ./.github/workflows/create_swift_package.yml
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
new file mode 100644
index 000000000..7b97f7200
--- /dev/null
+++ b/.github/workflows/benchmarks.yml
@@ -0,0 +1,92 @@
+name: Store6 Benchmarks
+
+# Non-blocking measurement lane. Report-only: runs the smoke configuration and uploads JSON
+# results. NO step asserts against a number — no numeric performance target has been adopted
+# (see benchmarks/README.md for the CI boundary). This workflow is deliberately OUTSIDE
+# the exact-head-green ready-gate convention, which continues to mean: the Store6 and CI
+# workflows green at the head.
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: [ main, store6 ]
+ paths:
+ - 'benchmarks/**'
+ - '.github/workflows/benchmarks.yml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: benchmarks-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ benchmarks-smoke:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Checkout the repo
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ - name: Grant execute permission for Gradlew
+ run: chmod +x gradlew
+
+ - name: Run smoke benchmarks (report-only; no thresholds)
+ run: ./gradlew :benchmarks:smokeBenchmark --stacktrace
+
+ - name: Summarize results
+ shell: bash
+ run: |
+ set -euo pipefail
+ reports_dir="benchmarks/build/reports/benchmarks"
+ mapfile -t json_reports < <(find "${reports_dir}" -type f -name '*.json' -print | LC_ALL=C sort)
+ report_count="${#json_reports[@]}"
+ if [[ "${report_count}" -ne 1 ]]; then
+ echo "ERROR: expected exactly one benchmark JSON under ${reports_dir}; found ${report_count}" >&2
+ if [[ "${report_count}" -gt 0 ]]; then
+ printf ' %s\n' "${json_reports[@]}" >&2
+ fi
+ exit 1
+ fi
+ json="${json_reports[0]}"
+ if ! jq -e '
+ type == "array" and
+ length > 0 and
+ all(.[];
+ type == "object" and
+ ((.benchmark | type) == "string") and
+ ((.benchmark | length) > 0) and
+ ((has("params") | not) or ((.params | type) == "object")) and
+ ((.primaryMetric | type) == "object") and
+ ((.primaryMetric.score | type) == "number") and
+ ((.primaryMetric.scoreUnit | type) == "string") and
+ ((.primaryMetric.scoreUnit | length) > 0)
+ )
+ ' "${json}" >/dev/null; then
+ echo "ERROR: benchmark JSON failed structural validation: ${json}" >&2
+ exit 1
+ fi
+ echo "Results from ${json} (smoke-grade numbers; hosted-runner noise applies — see benchmarks/README.md):"
+ jq -r '.[] | [.benchmark,
+ ((.params // {}) | to_entries | map("\(.key)=\(.value)") | join(",")),
+ (.primaryMetric.score | tostring),
+ .primaryMetric.scoreUnit]
+ | @tsv' "${json}" | column -t -s $'\t'
+
+ - name: Upload benchmark results
+ uses: actions/upload-artifact@v4
+ with:
+ name: benchmarks-smoke-${{ github.run_id }}
+ path: benchmarks/build/reports/benchmarks/
+ if-no-files-found: error
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9cd3b5a91..6869acf35 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,21 +2,36 @@ name: CI
on:
push:
- branches: [ main ]
+ branches: [ main, store6 ]
+ tags: [ 'v*' ]
pull_request:
- branches: [ main ]
+ branches: [ main, store6 ]
+ workflow_dispatch:
+
+permissions:
+ contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
- timeout-minutes: 30
+ # The mutations Lincheck budget measured ~58-59m locally and 2h40m-3h13m hosted
+ # at the current suite (and once 9h23m locally at an earlier revision). The default
+ # jvmTest excludes it via the build-gated filter in mutations/build.gradle.kts
+ # (census-guarded below); the scheduled "Store6 full mutations JVM suite" workflow runs
+ # the full suite daily.
+ timeout-minutes: 120
strategy:
fail-fast: false
matrix:
api-level: [ 29 ]
+ outputs:
+ source_sha: ${{ steps.provenance.outputs.source_sha }}
+ run_id: ${{ steps.provenance.outputs.run_id }}
+ run_attempt: ${{ steps.provenance.outputs.run_attempt }}
+ version: ${{ steps.provenance.outputs.version }}
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v4
with:
# PR builds (including forks) check out the PR head from its source repo;
# push builds fall back to the pushed ref on this repo. Without the
@@ -31,7 +46,7 @@ jobs:
persist-credentials: false
- name: Set up JDK 17
- uses: actions/setup-java@v5
+ uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
@@ -43,32 +58,111 @@ jobs:
run: chmod +x gradlew
- name: Build and Test with Coverage
- run: ./gradlew clean build koverXmlReport --stacktrace --continue
-
- - name: Upload Coverage to Codecov
- # Secrets (including CODECOV_TOKEN) are not exposed to fork PRs, so the
- # upload would fail under fail_ci_if_error. Skip it for forks; coverage is
- # still uploaded and enforced for same-repo PRs and pushes to main.
- if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' }}
- uses: codecov/codecov-action@v6
+ env:
+ EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ run: |
+ test "$(git rev-parse HEAD)" = "${EXPECTED_SOURCE_SHA}"
+ ./gradlew clean build koverXmlReport --stacktrace
+ test "$(git rev-parse HEAD)" = "${EXPECTED_SOURCE_SHA}"
+
+ - name: Census — default jvmTest must execute exactly the non-Lincheck suites
+ shell: bash
+ run: |
+ set -euo pipefail
+ expected=$(( $(find mutations/src/commonTest mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') - 1 ))
+ executed=$(find mutations/build/test-results/jvmTest -name 'TEST-*.xml' | wc -l | tr -d ' ')
+ echo "expected=${expected} executed=${executed}"
+ [ "${expected}" -eq "${executed}" ]
+
+ - name: Upload test reports
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-reports-root-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ **/build/test-results/**/*.xml
+ **/build/reports/tests/**
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Upload Store6 Coverage to Codecov
+ # Both the upstream repo and the matt-ramotar/Store6 development fork carry a
+ # CODECOV_TOKEN secret. Fork-of-fork PRs cannot read secrets, so uploads are
+ # enforced only for same-repo PRs and pushes. Flag `store6` keeps this family
+ # separate from upstream main's v5 `unittests` flag in the shared Codecov project.
+ if: ${{ (github.repository == 'MobileNativeFoundation/Store' || github.repository == 'matt-ramotar/Store6') && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') }}
+ uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
- files: build/reports/kover/coverage.xml
- flags: unittests
- name: codecov-umbrella
+ files: coverage/build/reports/kover/report.xml
+ flags: store6
+ name: store6-coverage
fail_ci_if_error: true
verbose: true
+ - name: Record checked-source validation provenance
+ id: provenance
+ if: ${{ github.event_name != 'pull_request' }}
+ run: python3 .github/scripts/release_control.py provenance
+
+ workflow-fixtures:
+ runs-on: ubuntu-latest
+ outputs:
+ source_sha: ${{ steps.provenance.outputs.source_sha }}
+ run_id: ${{ steps.provenance.outputs.run_id }}
+ run_attempt: ${{ steps.provenance.outputs.run_attempt }}
+ version: ${{ steps.provenance.outputs.version }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Validate release failure fixtures
+ run: python3 -m unittest discover -s .github/scripts/tests -v
+
+ - name: Record checked-source validation provenance
+ id: provenance
+ if: ${{ github.event_name != 'pull_request' }}
+ run: python3 .github/scripts/release_control.py provenance
+
+ release-matrix:
+ if: >-
+ github.repository == 'MobileNativeFoundation/Store' &&
+ ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
+ github.event_name == 'workflow_dispatch')
+ uses: ./.github/workflows/store6.yml
+ permissions:
+ contents: read
+
+ release-full-suite:
+ if: >-
+ github.repository == 'MobileNativeFoundation/Store' &&
+ ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
+ github.event_name == 'workflow_dispatch')
+ uses: ./.github/workflows/store6-full-jvm.yml
+ permissions:
+ contents: read
+
publish:
- if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'MobileNativeFoundation/Store'
+ if: |
+ github.repository == 'MobileNativeFoundation/Store' && (
+ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
+ github.event_name == 'workflow_dispatch'
+ )
runs-on: macos-latest
- needs: build-and-test
+ needs: [build-and-test, release-matrix, release-full-suite, workflow-fixtures]
+ permissions:
+ contents: write
+ concurrency:
+ group: maven-publication-${{ github.ref }}
+ cancel-in-progress: false
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
- name: Set up JDK 17
- uses: actions/setup-java@v5
+ uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
@@ -79,19 +173,46 @@ jobs:
- name: Grant execute permission for Gradlew
run: chmod +x gradlew
- - name: Retrieve Version
- run: |
- echo "VERSION_NAME=$(grep -E '^store[[:space:]]*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)" >> $GITHUB_ENV
+ - name: Validate the release evidence and notes
+ env:
+ VALIDATION_NEEDS: ${{ toJSON(needs) }}
+ run: python3 .github/scripts/release_control.py gate
+
+ - name: Reserve the immutable release record
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: python3 .github/scripts/release_control.py reserve
- - name: Publish to Maven Central (Central Portal)
+ - name: Publish to Maven Central and record each completed module
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
+ run: python3 .github/scripts/release_control.py publish
+
+ - name: Preserve the publication receipt before updating GitHub
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: publication-receipt-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ publication-receipt.json
+ release-evidence.json
+ release-notes.md
+ if-no-files-found: error
+ retention-days: 90
+
+ - name: Complete the GitHub Release record
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: python3 .github/scripts/release_control.py record
+
+ - name: Preserve repair instructions in the run record
+ if: ${{ failure() }}
run: |
- if [[ "${VERSION_NAME}" == *-SNAPSHOT ]]; then
- ./gradlew publishToMavenCentral
- else
- ./gradlew publishAndReleaseToMavenCentral
- fi
\ No newline at end of file
+ cat >> "${GITHUB_STEP_SUMMARY}" <<'EOF'
+ ### Publication requires inspection
+
+ Inspect this run's publication receipt before taking another publication action. A completed Maven publication must be repaired using **Store6 release record repair**, with this run ID and attempt. That workflow only updates GitHub. An incomplete receipt requires reconciling the attempted module with Central; it cannot be repaired as a completed release. The reserved draft release prevents automatic publication of the immutable version again.
+ EOF
diff --git a/.github/workflows/create_swift_package.yml b/.github/workflows/create_swift_package.yml
deleted file mode 100644
index 14f99e9e3..000000000
--- a/.github/workflows/create_swift_package.yml
+++ /dev/null
@@ -1,67 +0,0 @@
-# Based on: https://github.com/touchlab/KMMBridgeSPMQuickStart/blob/main/.github/workflows/Base-Publish.yml
-# Publishes the release XCFrameworks to a GitHub Release.
-# For debugging Kotlin from Xcode, build a debug XCFramework locally with
-# `./gradlew :spmDevBuild` instead.
-name: Base-Publish
-
-on:
- workflow_call:
-
-permissions:
- contents: write
- packages: write
-
-jobs:
- kmmbridgepublish:
- concurrency: "kmmbridgepublish-${{ github.repository }}"
- runs-on: macos-latest
- steps:
- - name: Checkout the repo with tags
- uses: actions/checkout@v6
- with:
- fetch-depth: 0
- fetch-tags: true
-
- - name: Retrieve Version
- id: versionPropertyValue
- run: |
- VERSION=$(grep -E '^store[[:space:]]*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)
- echo "propVal=$VERSION" >> $GITHUB_OUTPUT
-
- - name: Set up JDK 17
- uses: actions/setup-java@v5
- with:
- distribution: 'zulu'
- java-version: '17'
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v6
-
- - name: Grant execute permission for Gradlew
- run: chmod +x gradlew
-
- - name: Create or Find Artifact Release
- id: devrelease
- uses: softprops/action-gh-release@v2
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- tag_name: "${{ steps.versionPropertyValue.outputs.propVal }}"
-
- - name: Build and Publish
- run: |
- ./gradlew kmmBridgePublish \
- -PNATIVE_BUILD_TYPE=RELEASE \
- -PGITHUB_ARTIFACT_RELEASE_ID=${{ steps.devrelease.outputs.id }} \
- -PGITHUB_PUBLISH_TOKEN=${{ secrets.GITHUB_TOKEN }} \
- -PGITHUB_REPO=${{ github.repository }} \
- -PENABLE_PUBLISHING=true \
- --no-daemon --info --stacktrace
- env:
- GRADLE_OPTS: -Dkotlin.incremental=false -Dorg.gradle.jvmargs="-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=512m"
-
- - uses: touchlab/ga-update-release-tag@v1
- id: update-release-tag
- with:
- commitMessage: "KMP SPM package release for ${{ steps.versionPropertyValue.outputs.propVal }}"
- tagMessage: "KMP release version ${{ steps.versionPropertyValue.outputs.propVal }}"
- tagVersion: ${{ steps.versionPropertyValue.outputs.propVal }}
diff --git a/.github/workflows/store6-full-jvm-run.yml b/.github/workflows/store6-full-jvm-run.yml
new file mode 100644
index 000000000..62e98803e
--- /dev/null
+++ b/.github/workflows/store6-full-jvm-run.yml
@@ -0,0 +1,126 @@
+name: Store6 full mutations JVM execution
+
+on:
+ workflow_call:
+ inputs:
+ task:
+ description: 'jvmTest for the rest of the suite, lincheckTest for one Lincheck shard.'
+ type: string
+ required: true
+ shard:
+ description: 'Lincheck shard as k/N. Empty for the jvmTest lane.'
+ type: string
+ required: false
+ default: ''
+ lane:
+ description: 'Filename-safe label for this lane; it names the evidence artifact.'
+ type: string
+ required: true
+ runner:
+ type: string
+ required: false
+ default: ubuntu-latest
+ timeout_minutes:
+ type: number
+ required: false
+ default: 60
+ outputs:
+ source_sha:
+ value: ${{ jobs.full-mutations-jvm.outputs.source_sha }}
+ run_id:
+ value: ${{ jobs.full-mutations-jvm.outputs.run_id }}
+ run_attempt:
+ value: ${{ jobs.full-mutations-jvm.outputs.run_attempt }}
+ version:
+ value: ${{ jobs.full-mutations-jvm.outputs.version }}
+
+permissions:
+ contents: read
+
+jobs:
+ full-mutations-jvm:
+ runs-on: ${{ inputs.runner }}
+ timeout-minutes: ${{ inputs.timeout_minutes }}
+ outputs:
+ source_sha: ${{ steps.evidence.outputs.source_sha }}
+ run_id: ${{ steps.evidence.outputs.run_id }}
+ run_attempt: ${{ steps.evidence.outputs.run_attempt }}
+ version: ${{ steps.evidence.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ - name: Grant execute permission for Gradlew
+ run: chmod +x gradlew
+
+ - name: Run this lane of the full mutations JVM suite
+ id: tests
+ continue-on-error: true
+ shell: bash
+ env:
+ LANE_TASK: ${{ inputs.task }}
+ LANE_SHARD: ${{ inputs.shard }}
+ run: |
+ set -uo pipefail
+ test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" || exit 1
+ case "${LANE_TASK}" in
+ jvmTest|lincheckTest) ;;
+ *) echo "LANE_TASK must be jvmTest or lincheckTest, got '${LANE_TASK}'" >&2; exit 1 ;;
+ esac
+ mkdir -p full-suite-evidence
+ rm -rf "mutations/build/test-results/${LANE_TASK}"
+ if [ "${LANE_TASK}" = 'lincheckTest' ]; then
+ arguments="-Pstore6.lincheckShard=${LANE_SHARD}"
+ else
+ arguments='-Pstore6.fullJvmSuite'
+ fi
+ set +e
+ ./gradlew ":mutations:${LANE_TASK}" "${arguments}" --stacktrace --console=plain 2>&1 | tee full-suite-evidence/gradle.log
+ result=${PIPESTATUS[0]}
+ echo "exit_code=${result}" >> "${GITHUB_OUTPUT}"
+ exit "${result}"
+
+ - name: Record task outcome and executed test identifiers
+ id: evidence
+ if: ${{ always() && steps.tests.outputs.exit_code != '' }}
+ env:
+ GRADLE_EXIT_CODE: ${{ steps.tests.outputs.exit_code }}
+ LANE_TASK: ${{ inputs.task }}
+ LANE_SHARD: ${{ inputs.shard }}
+ run: >-
+ python3 .github/scripts/release_control.py full-suite-execution
+ --log full-suite-evidence/gradle.log
+ --results "mutations/build/test-results/${LANE_TASK}"
+ --exit-code "${GRADLE_EXIT_CODE}"
+ --task "${LANE_TASK}"
+ --shard "${LANE_SHARD}"
+ --output full-suite-evidence/execution.json
+
+ - name: Archive this lane's result and its run provenance
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: full-jvm-results-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ inputs.lane }}
+ path: |
+ full-suite-evidence/**
+ mutations/build/test-results/**/*.xml
+ mutations/build/reports/tests/**
+ if-no-files-found: error
+ retention-days: 90
+
+ - name: Reject missing execution evidence
+ if: ${{ always() && steps.evidence.outcome != 'success' }}
+ run: |
+ echo "Fresh full-suite execution was not established. Inspect this run and its archived result." >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
diff --git a/.github/workflows/store6-full-jvm.yml b/.github/workflows/store6-full-jvm.yml
new file mode 100644
index 000000000..4b80c46d2
--- /dev/null
+++ b/.github/workflows/store6-full-jvm.yml
@@ -0,0 +1,88 @@
+name: Store6 full mutations JVM suite
+
+# One forced execution of the whole mutations JVM suite, split into lanes that fit a hosted
+# runner: :mutations:jvmTest carries every test class except the Lincheck model-checking one,
+# which runs alone in :mutations:lincheckTest over a quarter of its deterministic scenario plan.
+# validation-evidence proves no lane and no scenario was lost.
+
+on:
+ schedule:
+ - cron: '17 5 * * *'
+ workflow_dispatch:
+ inputs:
+ lincheck_runner:
+ description: "Runner for the Lincheck shard jobs only. Result as of 2026-09-11: macos-latest hit Lincheck's 20-second invocation deadline on three of four shards (run 34566978279) and is not adopted for the gate; the option stays for experiments only."
+ type: choice
+ required: false
+ default: ubuntu-latest
+ options:
+ - ubuntu-latest
+ - macos-latest
+ workflow_call:
+ outputs:
+ source_sha:
+ value: ${{ jobs.validation-evidence.outputs.source_sha }}
+ run_id:
+ value: ${{ jobs.validation-evidence.outputs.run_id }}
+ run_attempt:
+ value: ${{ jobs.validation-evidence.outputs.run_attempt }}
+ version:
+ value: ${{ jobs.validation-evidence.outputs.version }}
+
+permissions:
+ contents: read
+
+jobs:
+ full-mutations-jvm:
+ uses: ./.github/workflows/store6-full-jvm-run.yml
+ with:
+ task: jvmTest
+ lane: jvmTest
+ timeout_minutes: 60
+
+ lincheck:
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4]
+ uses: ./.github/workflows/store6-full-jvm-run.yml
+ with:
+ task: lincheckTest
+ shard: ${{ matrix.shard }}/4
+ lane: lincheck-${{ matrix.shard }}
+ runner: ${{ inputs.lincheck_runner || 'ubuntu-latest' }}
+ timeout_minutes: 150
+
+ validation-evidence:
+ if: ${{ always() }}
+ needs: [full-mutations-jvm, lincheck]
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ outputs:
+ source_sha: ${{ steps.evidence.outputs.source_sha }}
+ run_id: ${{ steps.evidence.outputs.run_id }}
+ run_attempt: ${{ steps.evidence.outputs.run_attempt }}
+ version: ${{ steps.evidence.outputs.version }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Collect every lane's execution record
+ uses: actions/download-artifact@v4
+ with:
+ pattern: full-jvm-results-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}-*
+ path: full-suite-artifacts
+ - name: Require one forced execution of every lane and every scenario
+ id: evidence
+ env:
+ VALIDATION_NEEDS: ${{ toJSON(needs) }}
+ FULL_SUITE_EXECUTIONS: full-suite-artifacts
+ FULL_SUITE_SHARDS: '4'
+ run: python3 .github/scripts/release_control.py full-suite --output full-suite-validation.json
+ - name: Archive the full-suite validation record
+ uses: actions/upload-artifact@v4
+ with:
+ name: full-jvm-validation-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
+ path: full-suite-validation.json
+ if-no-files-found: error
+ retention-days: 90
diff --git a/.github/workflows/store6-release-record.yml b/.github/workflows/store6-release-record.yml
new file mode 100644
index 000000000..13917f48c
--- /dev/null
+++ b/.github/workflows/store6-release-record.yml
@@ -0,0 +1,68 @@
+name: Store6 release record repair
+
+on:
+ workflow_dispatch:
+ inputs:
+ release_tag:
+ description: Existing release tag whose Maven publication completed
+ required: true
+ type: string
+ source_run_id:
+ description: CI run that saved the completed publication receipt
+ required: true
+ type: string
+ source_run_attempt:
+ description: Attempt that saved the completed publication receipt
+ required: true
+ type: string
+
+permissions:
+ contents: write
+ actions: read
+
+jobs:
+ repair-record:
+ if: ${{ github.repository == 'MobileNativeFoundation/Store' }}
+ runs-on: ubuntu-latest
+ concurrency:
+ group: maven-publication-refs/tags/${{ inputs.release_tag }}
+ cancel-in-progress: false
+ steps:
+ - name: Checkout the published tag
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.release_tag }}
+ persist-credentials: false
+
+ - name: Bind the receipt to the published source
+ id: source
+ env:
+ GH_TOKEN: ${{ github.token }}
+ SOURCE_RUN_ID: ${{ inputs.source_run_id }}
+ SOURCE_RUN_ATTEMPT: ${{ inputs.source_run_attempt }}
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ run: |
+ set -euo pipefail
+ [[ "${SOURCE_RUN_ID}" =~ ^[0-9]+$ ]]
+ [[ "${SOURCE_RUN_ATTEMPT}" =~ ^[0-9]+$ ]]
+ version="$(python3 .github/scripts/release_control.py version)"
+ test "${RELEASE_TAG}" = "v${version}"
+ [[ "${version}" != *-SNAPSHOT ]]
+ gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/attempts/${SOURCE_RUN_ATTEMPT}" > source-run.json
+ test "$(jq -r .head_sha source-run.json)" = "$(git rev-parse HEAD)"
+ test "$(jq -r .path source-run.json)" = ".github/workflows/ci.yml"
+ test "$(jq -r .event source-run.json)" = push
+ echo "sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"
+
+ - name: Retrieve the preserved publication receipt
+ uses: actions/download-artifact@v4
+ with:
+ name: publication-receipt-${{ steps.source.outputs.sha }}-${{ inputs.source_run_id }}-${{ inputs.source_run_attempt }}
+ run-id: ${{ inputs.source_run_id }}
+ github-token: ${{ github.token }}
+ path: publication-record
+
+ - name: Repair the GitHub Release without Maven publication
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: python3 .github/scripts/release_control.py record --receipt publication-record/publication-receipt.json
diff --git a/.github/workflows/store6.yml b/.github/workflows/store6.yml
new file mode 100644
index 000000000..7fc4cb939
--- /dev/null
+++ b/.github/workflows/store6.yml
@@ -0,0 +1,1031 @@
+name: Store6
+
+on:
+ workflow_dispatch: {}
+ workflow_call:
+ outputs:
+ source_sha:
+ value: ${{ jobs.validation-evidence.outputs.source_sha }}
+ run_id:
+ value: ${{ jobs.validation-evidence.outputs.run_id }}
+ run_attempt:
+ value: ${{ jobs.validation-evidence.outputs.run_attempt }}
+ version:
+ value: ${{ jobs.validation-evidence.outputs.version }}
+ push:
+ branches: [ main, store6 ]
+ tags: [ 'v*' ]
+ pull_request:
+ branches: [ main, store6 ]
+ types: [ opened, synchronize, reopened, labeled, unlabeled ]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: store6-${{ github.workflow }}-${{ github.ref }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && 'docs-sync-guard' || 'validation' }}
+ cancel-in-progress: true
+
+jobs:
+ docs-sync-guard:
+ if: ${{ github.event_name == 'pull_request' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ fetch-depth: 0
+
+ - name: Require documentation sync acknowledgment
+ shell: bash
+ env:
+ DOCS_SYNC_ACK: ${{ contains(github.event.pull_request.labels.*.name, 'docs-sync-ack') }}
+ run: |
+ set -euo pipefail
+ source_list=.github/docs-sync-sources.txt
+
+ if [[ ! -s "${source_list}" ]]; then
+ echo "ERROR: ${source_list} must be non-empty." >&2
+ exit 1
+ fi
+ if ! LC_ALL=C sort -c "${source_list}"; then
+ echo "ERROR: ${source_list} must be sorted." >&2
+ exit 1
+ fi
+
+ changed_sources="$(
+ comm -12 \
+ <(LC_ALL=C sort "${source_list}") \
+ <(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD" | LC_ALL=C sort)
+ )"
+ if [[ -z "${changed_sources}" || "${DOCS_SYNC_ACK}" == "true" ]]; then
+ exit 0
+ fi
+
+ printf 'Documentation sources changed:\n%s\n' "${changed_sources}" >&2
+ cat >&2 <<'EOF'
+ This PR edits sources published to the docs site (listed in .github/docs-sync-sources.txt; pinned by store-docs evidence/T4-store6-source-lock.json). Merging makes the site stale until the docs repo re-pins. Add the 'docs-sync-ack' label to proceed; the docs repo's scheduled drift check will open the re-pin PR after merge. If your edit inserts or deletes lines in STABILITY.md, ROADMAP.md, compose/README.md, or sqldelight/README.md above an existing section, prefer appending — the site applies line-anchored publication transforms to these files.
+ EOF
+ exit 1
+
+ linux-build-test:
+ runs-on: ubuntu-latest
+ # The mutations Lincheck budget measured ~58-59m locally and 2h40m-3h13m hosted
+ # at the current suite (and once 9h23m locally at an earlier revision). The default
+ # jvmTest excludes it via the build-gated filter in mutations/build.gradle.kts
+ # (census-guarded below); the scheduled "Store6 full mutations JVM suite" workflow runs
+ # the full suite daily.
+ timeout-minutes: 120
+ env:
+ # Kotlin JS/Wasm lock tasks must see one complete project graph across separate Gradle steps.
+ GRADLE_OPTS: -Dorg.gradle.configureondemand=false
+ outputs:
+ source_sha: ${{ steps.provenance.outputs.source_sha }}
+ run_id: ${{ steps.provenance.outputs.run_id }}
+ run_attempt: ${{ steps.provenance.outputs.run_attempt }}
+ version: ${{ steps.provenance.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ - name: Cache Kotlin/Native compiler
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }}
+ restore-keys: |
+ ${{ runner.os }}-konan-
+
+ - name: Grant execute permission for Gradlew
+ run: chmod +x gradlew
+
+ - name: Build Store6 core
+ run: >
+ ./gradlew :core:build :testing:build :sqldelight:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 quickstart
+ run: ./gradlew :quickstart:run --stacktrace
+
+ - name: Run Store6 sqldelight sample
+ run: ./gradlew :sqldelight-sample:run --stacktrace
+
+ - name: Build Store6 extension probe (seam-only consumer)
+ run: >
+ ./gradlew :extension-probe:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Build Store6 compose and demo
+ run: >
+ ./gradlew :compose:build :compose-demo:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Verify Compose stability of core public types
+ shell: bash
+ run: |
+ set -euo pipefail
+ reports_dir=compose-demo/build/compose-reports
+ # Force a complete, non-incremental report. Kotlin incremental compilation rewrites the
+ # stability report with ONLY the recompiled subset, and the report is an undeclared task
+ # output, so neither deleting it nor editing a source guarantees a whole-module snapshot.
+ # Discarding the module's build directory makes the next compile a full one. The demo
+ # module's compilations are also opted out of the build cache (see its build.gradle.kts)
+ # so this cannot be served from cache without running the compiler.
+ rm -rf compose-demo/build
+ ./gradlew :compose-demo:compileKotlin --stacktrace
+ if [[ "$(find "${reports_dir}" -name '*-composables.txt' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]]; then
+ echo "ERROR: no composables report under ${reports_dir}" >&2
+ echo "The compose metrics wiring in compose-demo/build.gradle.kts was removed or renamed." >&2
+ exit 1
+ fi
+ # All reports are concatenated (deterministic; probes exist only in the main compilation,
+ # so tier counts stay exact regardless of how many reports the plugin writes — the test
+ # compilation writes a separate, empty *_test-composables.txt and never clobbers it).
+ #
+ # Every probe parameter must be rendered EXPLICITLY `stable`. Asserting the mere absence
+ # of `unstable` would pass silently on the compiler's third rendering — a bare, unprefixed
+ # `value: X` line, which it emits for unknown/runtime stability.
+ #
+ # iface_strict=1 asserts that the shipped conf renders every probe parameter stable,
+ # including the interface-typed and generic ones. Set it to 0 only if a toolchain bump
+ # makes interface-typed parameters unprovable, which downgrades the iface tier to
+ # skippable-only and reports the residual instead of failing.
+ find "${reports_dir}" -name '*-composables.txt' | sort | xargs cat | awk -v iface_strict=1 '
+ /fun / {
+ tier = 0
+ if ($0 ~ /ProbeStrict/) { tier = 1; strict += 1 }
+ else if ($0 ~ /ProbeIface/) { tier = 2; iface += 1 }
+ if (tier > 0 && $0 !~ /skippable/) { print "NOT SKIPPABLE: " $0; bad = 1 }
+ next
+ }
+ /^\)/ { tier = 0; next }
+ tier > 0 {
+ if ($1 != "stable") {
+ label = (tier == 1) ? "strict" : "iface"
+ if (tier == 1 || iface_strict) {
+ print "PARAM NOT STABLE (" label " tier): " $0
+ bad = 1
+ } else {
+ print "INFO: non-stable iface-tier param (allowed when iface_strict=0): " $0
+ }
+ }
+ }
+ END {
+ if (strict != 8) { print "Expected 8 ProbeStrict composables, found " strict + 0; exit 1 }
+ if (iface != 5) { print "Expected 5 ProbeIface composables, found " iface + 0; exit 1 }
+ exit bad + 0
+ }
+ '
+
+ - name: Build Store6 Room adapter
+ run: >
+ ./gradlew :room:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 Room sample
+ run: ./gradlew :room-sample:run --stacktrace
+
+ - name: Build Store6 benchmarks
+ run: ./gradlew :benchmarks:build --stacktrace
+
+ - name: Build Store6 devtools modules and demo
+ run: >
+ ./gradlew :devtools:build :devtools-inspector:build :devtools-demo:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Build Store6 mutations
+ run: >
+ ./gradlew :mutations:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 mutations quickstart
+ run: ./gradlew :mutations-quickstart:run --stacktrace
+
+ - name: Census — default jvmTest must execute exactly the non-Lincheck suites
+ shell: bash
+ run: |
+ set -euo pipefail
+ expected=$(( $(find mutations/src/commonTest mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') - 1 ))
+ executed=$(find mutations/build/test-results/jvmTest -name 'TEST-*.xml' | wc -l | tr -d ' ')
+ echo "expected=${expected} executed=${executed}"
+ [ "${expected}" -eq "${executed}" ]
+
+ - name: Build Store6 mutation journal support
+ run: >
+ ./gradlew :mutations-testing:build :mutations-sqldelight:build :mutations-conflicts:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Build Store6 mutations-drain
+ run: >
+ ./gradlew :mutations-drain:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 mutations-drain sample
+ run: ./gradlew :mutations-drain-sample:run --stacktrace
+
+ - name: Build Store6 mutations-drain-meeseeks
+ run: >
+ ./gradlew :mutations-drain-meeseeks:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Build Store6 paging-androidx
+ run: >
+ ./gradlew :paging-androidx:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 paging sample
+ run: ./gradlew :paging-androidx-sample:run --stacktrace
+
+ - name: Build Store6 graphql
+ run: >
+ ./gradlew :graphql:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 graphql sample
+ run: ./gradlew :graphql-sample:run --stacktrace
+
+ - name: Build Store6 realtime
+ run: >
+ ./gradlew :realtime:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 realtime sample
+ run: ./gradlew :realtime-sample:run --stacktrace
+
+ - name: Build Store6 file adapter
+ run: >
+ ./gradlew :file:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 file sample
+ run: ./gradlew :file-sample:run --stacktrace
+
+ - name: Build Store6 ktor
+ run: >
+ ./gradlew :ktor:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 ktor sample
+ run: ./gradlew :ktor-sample:run --stacktrace
+
+ - name: Build Store6 opentelemetry
+ run: >
+ ./gradlew :opentelemetry:build
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Run Store6 opentelemetry sample
+ run: ./gradlew :opentelemetry-sample:run --stacktrace
+
+ - name: Reject core-internal access from extension modules
+ shell: bash
+ run: |
+ set -euo pipefail
+ status=0
+ for module in extension-probe testing sqldelight compose compose-demo room benchmarks devtools devtools-inspector devtools-demo mutations mutations-quickstart mutations-testing mutations-sqldelight mutations-drain mutations-drain/sample mutations-drain-meeseeks mutations-conflicts paging-androidx paging-androidx/sample graphql graphql/sample realtime realtime/sample file file/sample ktor ktor/sample opentelemetry opentelemetry/sample; do
+ source_dir="${module}/src"
+ if [[ ! -d "${source_dir}" ]]; then
+ echo "ERROR: expected extension source directory is missing: ${source_dir}" >&2
+ status=1
+ continue
+ fi
+ if grep -rnE 'InternalStoreApi|org[.]mobilenativefoundation[.]store6[.]core[.]internal' "${source_dir}"; then
+ echo "ERROR: ${module} accesses core internals" >&2
+ status=1
+ else
+ grep_status=$?
+ if [[ "${grep_status}" -ne 1 ]]; then
+ echo "ERROR: grep failed for ${source_dir} with status ${grep_status}" >&2
+ status=1
+ fi
+ fi
+ done
+ exit "${status}"
+
+ - name: Verify core seam inventory
+ shell: bash
+ run: |
+ set -euo pipefail
+ seam_dir="core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam"
+ expected=(
+ Bookkeeper.kt
+ Fetcher.kt
+ FetcherResult.kt
+ FreshnessEvidence.kt
+ FreshnessValidator.kt
+ KeyEvents.kt
+ Overlay.kt
+ SourceAdoption.kt
+ SourceOfTruth.kt
+ StoreResults.kt
+ StoreRuntime.kt
+ StoreTelemetry.kt
+ StoreWriteHandle.kt
+ TransactionalSourceOfTruth.kt
+ WallClock.kt
+ )
+ diff -u \
+ <(printf '%s\n' "${expected[@]}" | LC_ALL=C sort) \
+ <(LC_ALL=C ls -1A "${seam_dir}" | LC_ALL=C sort)
+
+ - name: Enforce the TD-8 primitive whitelist and single-writer residence
+ shell: bash
+ run: |
+ set -euo pipefail
+ status=0
+ banned_regex='(^|[^[:alnum:]_])(runBlocking|GlobalScope|atomicfu|Channel|actor)([^[:alnum:]_]|$)|kotlinx[.]coroutines[.]channels[.][*]'
+ shopt -s nullglob
+ production_source_dirs=(core/src/*Main testing/src/*Main sqldelight/src/*Main compose/src/*Main room/src/*Main devtools/src/*Main devtools-inspector/src/*Main mutations/src/*Main mutations-testing/src/*Main mutations-sqldelight/src/*Main mutations-drain/src/*Main mutations-drain-meeseeks/src/*Main mutations-conflicts/src/*Main paging-androidx/src/*Main graphql/src/*Main realtime/src/*Main file/src/*Main ktor/src/*Main opentelemetry/src/*Main)
+ for source_dir in "${production_source_dirs[@]}"; do
+ [[ -d "${source_dir}" ]] || continue
+ if grep -rnE --include='*.kt' "${banned_regex}" "${source_dir}"; then
+ echo "ERROR: banned concurrency primitive found in ${source_dir}" >&2
+ status=1
+ else
+ grep_status=$?
+ if [[ "${grep_status}" -ne 1 ]]; then
+ echo "ERROR: grep failed for ${source_dir} with status ${grep_status}" >&2
+ status=1
+ fi
+ fi
+ done
+ key_engine="core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt"
+ python3 - "${key_engine}" <<'PY' || status=1
+ import re
+ import sys
+ from pathlib import Path
+
+ assignment = re.compile(r"\bresidence\s*\.\s*value\s*=(?!=)")
+
+
+ def mask_non_code(source: str) -> str:
+ output: list[str] = []
+ state = "code"
+ block_depth = 0
+ index = 0
+
+ def mask(text: str) -> None:
+ output.extend(char if char in "\r\n" else " " for char in text)
+
+ while index < len(source):
+ if state == "code":
+ if source.startswith("//", index):
+ mask(source[index:index + 2])
+ index += 2
+ state = "line-comment"
+ elif source.startswith("/*", index):
+ mask(source[index:index + 2])
+ index += 2
+ block_depth = 1
+ state = "block-comment"
+ elif source.startswith('"""', index):
+ mask(source[index:index + 3])
+ index += 3
+ state = "raw-string"
+ elif source[index] == '"':
+ mask(source[index])
+ index += 1
+ state = "string"
+ elif source[index] == "'":
+ mask(source[index])
+ index += 1
+ state = "char"
+ else:
+ output.append(source[index])
+ index += 1
+ elif state == "line-comment":
+ mask(source[index])
+ if source[index] == "\n":
+ state = "code"
+ index += 1
+ elif state == "block-comment":
+ if source.startswith("/*", index):
+ mask(source[index:index + 2])
+ index += 2
+ block_depth += 1
+ elif source.startswith("*/", index):
+ mask(source[index:index + 2])
+ index += 2
+ block_depth -= 1
+ if block_depth == 0:
+ state = "code"
+ else:
+ mask(source[index])
+ index += 1
+ elif state in ("string", "char"):
+ delimiter = '"' if state == "string" else "'"
+ if source[index] == "\\":
+ end = min(index + 2, len(source))
+ mask(source[index:end])
+ index = end
+ else:
+ closing = source[index] == delimiter
+ mask(source[index])
+ index += 1
+ if closing:
+ state = "code"
+ elif state == "raw-string":
+ if source.startswith('"""', index):
+ mask(source[index:index + 3])
+ index += 3
+ state = "code"
+ else:
+ mask(source[index])
+ index += 1
+
+ if state not in ("code", "line-comment"):
+ raise ValueError(f"unterminated Kotlin lexical state: {state}")
+ return "".join(output)
+
+
+ source_path = Path(sys.argv[1])
+ try:
+ source = source_path.read_text(encoding="utf-8")
+ raw_count = len(assignment.findall(source))
+ code_count = len(assignment.findall(mask_non_code(source)))
+ except (OSError, UnicodeError, ValueError) as error:
+ print(f"ERROR: writer audit failed for {source_path}: {error}", file=sys.stderr)
+ raise SystemExit(1)
+
+ if raw_count != 1 or code_count != 1:
+ print(
+ f"ERROR: expected exactly one raw and one code-level residence.value "
+ f"assignment in {source_path}; raw_count={raw_count}, code_count={code_count}",
+ file=sys.stderr,
+ )
+ raise SystemExit(1)
+ print(
+ f"Verified residence.value assignment counts in {source_path}: "
+ f"raw_count={raw_count}, code_count={code_count}"
+ )
+ PY
+ exit "${status}"
+
+ - name: JS lock-discipline canary (full conformance suite on the JS lane)
+ run: ./gradlew :core:jsNodeTest :testing:jsNodeTest :mutations:jsNodeTest :mutations-testing:jsNodeTest :mutations-drain:jsNodeTest :mutations-drain-meeseeks:jsNodeTest :mutations-conflicts:jsNodeTest :paging-androidx:jsNodeTest :graphql:jsNodeTest :realtime:jsNodeTest :file:jsNodeTest :ktor:jsNodeTest --stacktrace
+
+ - name: Upload test reports
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-reports-store6-linux-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ */build/test-results/**/*.xml
+ */build/reports/tests/**
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Record checked-source validation provenance
+ id: provenance
+ if: ${{ github.event_name != 'pull_request' }}
+ run: python3 .github/scripts/release_control.py provenance
+
+ apple-tests:
+ runs-on: macos-latest
+ timeout-minutes: 40
+ outputs:
+ source_sha: ${{ steps.provenance.outputs.source_sha }}
+ run_id: ${{ steps.provenance.outputs.run_id }}
+ run_attempt: ${{ steps.provenance.outputs.run_attempt }}
+ version: ${{ steps.provenance.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ - name: Cache Kotlin/Native compiler
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }}
+ restore-keys: |
+ ${{ runner.os }}-konan-
+
+ - name: Grant execute permission for Gradlew
+ run: chmod +x gradlew
+
+ - name: Select an available iPhone simulator
+ id: ios_simulator
+ shell: bash
+ run: |
+ device_name="$(
+ xcrun simctl list devices available -j |
+ jq -r '[.devices[][] | select(.name | startswith("iPhone"))] | first | .name // empty'
+ )"
+ if [[ -z "${device_name}" ]]; then
+ echo "No available iPhone simulator was found." >&2
+ exit 1
+ fi
+ echo "Selected iPhone simulator: ${device_name}"
+ echo "device_name=${device_name}" >> "${GITHUB_OUTPUT}"
+
+ - name: Run Store6 Apple tests
+ env:
+ STORE6_IOS_SIMULATOR_DEVICE: ${{ steps.ios_simulator.outputs.device_name }}
+ run: |
+ ./gradlew \
+ :core:iosSimulatorArm64Test \
+ :core:macosArm64Test \
+ :extension-probe:iosSimulatorArm64Test \
+ :extension-probe:macosArm64Test \
+ :testing:iosSimulatorArm64Test \
+ :testing:macosArm64Test \
+ :sqldelight:iosSimulatorArm64Test \
+ :sqldelight:macosArm64Test \
+ :compose:iosSimulatorArm64Test \
+ :compose:macosArm64Test \
+ :room:iosSimulatorArm64Test \
+ :room:macosArm64Test \
+ :paging-androidx:iosSimulatorArm64Test \
+ :paging-androidx:macosArm64Test \
+ :graphql:iosSimulatorArm64Test \
+ :graphql:macosArm64Test \
+ :realtime:iosSimulatorArm64Test \
+ :realtime:macosArm64Test \
+ :ktor:iosSimulatorArm64Test \
+ :ktor:macosArm64Test \
+ :devtools:iosSimulatorArm64Test \
+ :devtools:macosArm64Test \
+ :devtools-inspector:iosSimulatorArm64Test \
+ :devtools-inspector:macosArm64Test \
+ :mutations:iosSimulatorArm64Test \
+ :mutations:macosArm64Test \
+ :mutations-testing:iosSimulatorArm64Test \
+ :mutations-testing:macosArm64Test \
+ :mutations-sqldelight:iosSimulatorArm64Test \
+ :mutations-sqldelight:macosArm64Test \
+ :mutations-drain:iosSimulatorArm64Test \
+ :mutations-drain:macosArm64Test \
+ :mutations-drain-meeseeks:iosSimulatorArm64Test \
+ :mutations-conflicts:iosSimulatorArm64Test \
+ :mutations-conflicts:macosArm64Test \
+ :file:iosSimulatorArm64Test \
+ :file:macosArm64Test \
+ "-Pstore6.iosSimulatorDevice=${STORE6_IOS_SIMULATOR_DEVICE}" \
+ --stacktrace
+
+ - name: Link Store6 devtools demo iOS framework
+ run: ./gradlew :devtools-demo:linkDebugFrameworkIosSimulatorArm64 --stacktrace
+
+ - name: Upload test reports
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-reports-store6-apple-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ */build/test-results/**/*.xml
+ */build/reports/tests/**
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Record checked-source validation provenance
+ id: provenance
+ if: ${{ github.event_name != 'pull_request' }}
+ run: python3 .github/scripts/release_control.py provenance
+
+ swift-dumps:
+ runs-on: macos-latest
+ timeout-minutes: 40
+ outputs:
+ source_sha: ${{ steps.provenance.outputs.source_sha }}
+ run_id: ${{ steps.provenance.outputs.run_id }}
+ run_attempt: ${{ steps.provenance.outputs.run_attempt }}
+ version: ${{ steps.provenance.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ - name: Cache Kotlin/Native compiler
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }}
+ restore-keys: |
+ ${{ runner.os }}-konan-
+
+ - name: Grant execute permission for Gradlew
+ run: chmod +x gradlew
+
+ - name: Check committed Swift dumps
+ run: ./gradlew checkSwiftDumps --stacktrace
+
+ - name: Upload Swift dump diagnostics
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: swift-dump-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ swift-dumps/**/build/swift-dump/**
+ swift-dumps/**/build/bin/iosArm64/debugFramework/**/*.h
+ swift-dumps/**/build/skie/**
+ core/api/swift/**
+ mutations/api/swift/**
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Record checked-source validation provenance
+ id: provenance
+ if: ${{ github.event_name != 'pull_request' }}
+ run: python3 .github/scripts/release_control.py provenance
+
+ swift-facade:
+ runs-on: macos-latest
+ timeout-minutes: 60
+ outputs:
+ source_sha: ${{ steps.provenance.outputs.source_sha }}
+ run_id: ${{ steps.provenance.outputs.run_id }}
+ run_attempt: ${{ steps.provenance.outputs.run_attempt }}
+ version: ${{ steps.provenance.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ - name: Cache Kotlin/Native compiler
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }}
+ restore-keys: |
+ ${{ runner.os }}-konan-
+
+ - name: Grant execute permission for Gradlew
+ run: chmod +x gradlew
+
+ - name: Run Kotlin bridge tests
+ run: ./gradlew :store6-swift:macosArm64Test --stacktrace
+
+ - name: Assemble Store6Kotlin XCFramework
+ run: ./gradlew :store6-swift:assembleStore6KotlinDebugXCFramework --stacktrace
+
+ - name: Run Swift package tests
+ run: swift test
+
+ - name: Upload Swift facade diagnostics
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: swift-facade-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ store6-swift/build/XCFrameworks/**/Info.plist
+ .build/**/*.xml
+ store6-swift/build/test-results/**
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Record checked-source validation provenance
+ id: provenance
+ if: ${{ github.event_name != 'pull_request' }}
+ run: python3 .github/scripts/release_control.py provenance
+
+ klib-publication-check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ outputs:
+ source_sha: ${{ steps.provenance.outputs.source_sha }}
+ run_id: ${{ steps.provenance.outputs.run_id }}
+ run_attempt: ${{ steps.provenance.outputs.run_attempt }}
+ version: ${{ steps.provenance.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ - name: Cache Kotlin/Native compiler
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }}
+ restore-keys: |
+ ${{ runner.os }}-konan-
+
+ - name: Grant execute permission for Gradlew
+ run: chmod +x gradlew
+
+ - name: Publish Store6 core to Maven local without signing
+ run: >
+ ./gradlew :core:publishToMavenLocal :testing:publishToMavenLocal :sqldelight:publishToMavenLocal :compose:publishToMavenLocal :room:publishToMavenLocal :devtools:publishToMavenLocal :devtools-inspector:publishToMavenLocal :mutations:publishToMavenLocal :mutations-testing:publishToMavenLocal :mutations-sqldelight:publishToMavenLocal :mutations-drain:publishToMavenLocal :mutations-drain-meeseeks:publishToMavenLocal :mutations-conflicts:publishToMavenLocal :paging-androidx:publishToMavenLocal :graphql:publishToMavenLocal :realtime:publishToMavenLocal :file:publishToMavenLocal :ktor:publishToMavenLocal :opentelemetry:publishToMavenLocal :bom:publishToMavenLocal
+ -Pkotlin.native.enableKlibsCrossCompilation=true
+ -Pkotlin.apple.xcodeCompatibility.nowarn=true
+ --stacktrace
+
+ - name: Verify common and target publications
+ shell: bash
+ run: |
+ group_id="org.mobilenativefoundation.store"
+ version="$(python3 .github/scripts/release_control.py version)"
+ repository="${HOME}/.m2/repository/org/mobilenativefoundation/store"
+
+ modules=(core testing sqldelight compose room devtools devtools-inspector mutations mutations-testing mutations-sqldelight mutations-drain mutations-drain-meeseeks mutations-conflicts paging-androidx graphql realtime file ktor opentelemetry bom)
+ suffixes=(
+ ""
+ -android
+ -iosarm64
+ -iossimulatorarm64
+ -iosx64
+ -js
+ -jvm
+ -linuxx64
+ -macosarm64
+ -mingwx64
+ -tvosarm64
+ -wasm-js
+ -watchosarm64
+ )
+
+ failed=0
+ publications=()
+ for module in "${modules[@]}"; do
+ for suffix in "${suffixes[@]}"; do
+ case "${module}:${suffix}" in
+ bom:-*)
+ # bom is a java-platform: it publishes only the parent POM, no
+ # target-suffix artifacts.
+ continue
+ ;;
+ paging-androidx:-iosx64)
+ # paging-androidx ships the paging-common-3.5.1 target subset; androidx.paging
+ # publishes no Intel artifacts since 3.4.0-rc01.
+ continue
+ ;;
+ room:-js|room:-wasm-js|room:-mingwx64|room:-iosx64)
+ # room ships Room 3's target subset; room3 publishes no iosX64 and no
+ # web/mingw klibs for this module's scope.
+ continue
+ ;;
+ mutations-drain-meeseeks:-wasm-js|mutations-drain-meeseeks:-macosarm64|mutations-drain-meeseeks:-watchosarm64|mutations-drain-meeseeks:-tvosarm64|mutations-drain-meeseeks:-linuxx64|mutations-drain-meeseeks:-mingwx64)
+ # mutations-drain-meeseeks ships the Meeseeks target subset; this module
+ # publishes no wasmJs/macos/watchOS/tvOS/linux/mingw klibs.
+ continue
+ ;;
+ devtools-inspector:-watchosarm64|devtools-inspector:-tvosarm64|devtools-inspector:-linuxx64|devtools-inspector:-mingwx64)
+ # devtools-inspector ships the CMP-UI subset; Compose foundation/material3
+ # publish no watchOS/tvOS/Linux/MinGW variants.
+ continue
+ ;;
+ opentelemetry:-iosarm64|opentelemetry:-iossimulatorarm64|opentelemetry:-iosx64|opentelemetry:-js|opentelemetry:-linuxx64|opentelemetry:-macosarm64|opentelemetry:-mingwx64|opentelemetry:-tvosarm64|opentelemetry:-wasm-js|opentelemetry:-watchosarm64)
+ # opentelemetry ships the JVM-family subset; opentelemetry-java publishes
+ # JVM bytecode only, so there are no Kotlin/Native or web variants.
+ continue
+ ;;
+ esac
+ artifact_id="${module}${suffix}"
+ publications+=("${artifact_id}")
+ artifact_dir="${repository}/${artifact_id}/${version}"
+ case "${suffix}" in
+ ""|-jvm)
+ if [[ "${module}" == "bom" ]]; then
+ extension="pom"
+ else
+ extension="jar"
+ fi
+ ;;
+ -android)
+ extension="aar"
+ ;;
+ *)
+ extension="klib"
+ ;;
+ esac
+ artifact_file="${artifact_dir}/${artifact_id}-${version}.${extension}"
+ if [[ ! -f "${artifact_file}" ]]; then
+ echo "Missing consumable publication artifact: ${artifact_file}" >&2
+ failed=1
+ continue
+ fi
+ echo "Verified ${group_id}:${artifact_id}:${version} (${extension})"
+ done
+ done
+ if [[ "${failed}" -ne 0 ]]; then exit "${failed}"; fi
+ python3 .github/scripts/release_control.py publication-versions \
+ --repository "${repository}" --modules "${modules[@]}" \
+ --publications "${publications[@]}"
+
+ - name: Record checked-source validation provenance
+ id: provenance
+ if: ${{ github.event_name != 'pull_request' }}
+ run: python3 .github/scripts/release_control.py provenance
+
+ native-stress:
+ runs-on: macos-latest
+ timeout-minutes: 40
+ outputs:
+ source_sha: ${{ steps.provenance.outputs.source_sha }}
+ run_id: ${{ steps.provenance.outputs.run_id }}
+ run_attempt: ${{ steps.provenance.outputs.run_attempt }}
+ version: ${{ steps.provenance.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ - name: Cache Kotlin/Native compiler
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }}
+ restore-keys: |
+ ${{ runner.os }}-konan-
+
+ - name: Grant execute permission for Gradlew
+ run: chmod +x gradlew
+
+ - name: Run Store6 Native stress and soak lane (macOS)
+ shell: bash
+ run: |
+ set -euo pipefail
+ ./gradlew :core:macosArm64Test \
+ --tests '*StoreEvictionStressTest' \
+ --tests '*StoreInvalidationStressTest' \
+ --tests '*StoreCloseLifecycleTest' \
+ --tests '*StoreBackpressureConformanceTest' \
+ --stacktrace
+
+ result_dir="core/build/test-results/macosArm64Test"
+ expected_test_classes=(
+ StoreEvictionStressTest
+ StoreInvalidationStressTest
+ StoreCloseLifecycleTest
+ StoreBackpressureConformanceTest
+ )
+ python3 - "${result_dir}" "${expected_test_classes[@]}" <<'PY'
+ import sys
+ from pathlib import Path
+ import xml.etree.ElementTree as ET
+
+
+ def local_name(tag: str) -> str:
+ return tag.rsplit("}", 1)[-1]
+
+
+ result_dir = Path(sys.argv[1])
+ expected_test_classes = sys.argv[2:]
+ result_files = sorted(result_dir.glob("TEST-*.xml"))
+ if not result_files:
+ print(f"ERROR: no Kotlin/Native test-result XML found in {result_dir}", file=sys.stderr)
+ raise SystemExit(1)
+
+ executed = {test_class: 0 for test_class in expected_test_classes}
+ for result_file in result_files:
+ try:
+ root = ET.parse(result_file).getroot()
+ except (OSError, ET.ParseError) as error:
+ print(f"ERROR: failed to parse {result_file}: {error}", file=sys.stderr)
+ raise SystemExit(1)
+
+ for testcase in root.iter():
+ if local_name(testcase.tag) != "testcase":
+ continue
+ if any(local_name(child.tag) == "skipped" for child in testcase):
+ continue
+ classname = testcase.get("classname", "")
+ for test_class in expected_test_classes:
+ if classname == test_class or classname.endswith(f".{test_class}"):
+ executed[test_class] += 1
+
+ missing = [test_class for test_class, count in executed.items() if count == 0]
+ for test_class, count in executed.items():
+ if count:
+ print(f"Verified {count} executed Native testcase(s) for {test_class}")
+ if missing:
+ print(
+ "ERROR: no non-skipped Native testcase evidence for: " + ", ".join(missing),
+ file=sys.stderr,
+ )
+ raise SystemExit(1)
+ PY
+
+ - name: Upload test reports
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-reports-store6-native-stress-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ */build/test-results/**/*.xml
+ */build/reports/tests/**
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Record checked-source validation provenance
+ id: provenance
+ if: ${{ github.event_name != 'pull_request' }}
+ run: python3 .github/scripts/release_control.py provenance
+
+ validation-evidence:
+ if: ${{ always() && github.event_name != 'pull_request' }}
+ needs: [linux-build-test, apple-tests, swift-dumps, swift-facade, klib-publication-check, native-stress]
+ runs-on: ubuntu-latest
+ outputs:
+ source_sha: ${{ steps.evidence.outputs.source_sha }}
+ run_id: ${{ steps.evidence.outputs.run_id }}
+ run_attempt: ${{ steps.evidence.outputs.run_attempt }}
+ version: ${{ steps.evidence.outputs.version }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Require the complete Store6 validation matrix
+ id: evidence
+ env:
+ VALIDATION_NEEDS: ${{ toJSON(needs) }}
+ run: python3 .github/scripts/release_control.py matrix --output store6-validation.json
+ - name: Archive the exact-source validation record
+ uses: actions/upload-artifact@v4
+ with:
+ name: store6-validation-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
+ path: store6-validation.json
+ if-no-files-found: error
+ retention-days: 90
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..186aa10de
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,66 @@
+# Agent instructions
+
+These instructions govern any agent working in this repository. They apply to every
+documentation surface: READMEs, KDoc and doc comments, inline comments, workflow YAML
+comments, commit messages, and pull-request bodies.
+
+## Documentation discipline
+
+The full rules are embedded in this repository as skills:
+`plugins/internal/documentation/skills/documentation-discipline/` (governs every sentence) and
+`plugins/internal/documentation/skills/code-documentation/` (governs evidence, artifact shape, mutation, and
+verification). It composes with the discipline skill. Agents with skill support invoke them by
+name before documentation work. Agents without it read the `SKILL.md` files and their
+`references/` directly. The core rules below are the load-bearing summary and apply either
+way.
+
+### The master test
+
+Keep a documentation element only when the reader needs it to use, change, operate, or
+reason about the documented system correctly. Cut throat-clearing, hype, vague benefits,
+decorative language, narration of visible syntax or control flow, and invented precision.
+
+### Protected technical content
+
+A wording pass never alters identifiers, signatures, commands, paths, URLs, versions,
+numbers, units, measurements, schema fields, error names, compatibility statements,
+behavioral guarantees, or evidence classifications. Semantic directives and behavior-bearing
+comments (suppressions, build constraints, generator markers, tool configuration) are
+protected even though they are syntactically comments. If a requested change requires
+altering a protected token, stop and report it as a technical change needing its own
+authorization. Do not fold it into a style pass silently.
+
+### No internal organizational context in code surfaces
+
+Code documentation must be self-contained and durable. Do not put issue-tracker IDs,
+internal project or initiative names, ruling or approval shorthand, landing status, team
+shorthand, or internal revision labels into source comments, workflow comments, or step
+names. State the technical fact with a durable attribution instead. For example,
+"measured 2h40m-3h13m on hosted runners at the current suite" rather than a tracker
+reference. Pull-request bodies and commit messages may reference issues and process records.
+Source files may not.
+
+### Evidence before claims
+
+State confirmed facts. Label uncertainty explicitly. Verify a claim before writing it. A
+coverage or completeness claim ("every X now does Y") requires an actual sweep, not an
+extrapolation from the files you happened to touch. Never present a failed command or
+example as working.
+
+### Repository comment conventions
+
+Some comment blocks are deliberately byte-identical across sibling files (for example the
+test-deadline wrapper comment that appears with the shared `runTest` shim in test files).
+Match the established shape exactly when extending such a pattern. Do not reword one copy.
+When editing near an existing comment, preserve it unless it is wrong. Revise only the
+evidenced deficiency.
+
+### Three-pass review
+
+Before finishing documentation work, run three separate passes:
+
+1. **Accuracy.** Every protected token, contract, and classification unchanged and correct
+ against the source.
+2. **Warrant.** Every remaining claim supported by evidence or labeled as uncertain.
+3. **Reader utility.** The intended reader can complete their task without missing
+ prerequisites, boundaries, units, risks, or operational consequences.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c22f032e..02016aa4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,128 @@
### Thank you to all our wonderful contributors and users
+## [6.0.0-alpha01] (unreleased; date pending)
+
+The first Store 6 alpha. Store 6 is the next major line, a Kotlin Multiplatform library for reading
+and writing data that lives in more than one place: a network, a local database, and memory. The
+stability policy is in [STABILITY.md](./STABILITY.md); each artifact's tier is stated there.
+
+**New Features**
+
+* The alpha roster contains stable-track `core`, experimental `testing`, `mutations`,
+ `mutations-sqldelight`, `mutations-testing`, `mutations-conflicts`, `sqldelight`, `room`,
+ `compose`, `graphql`, `realtime`, `file`, `ktor`, `opentelemetry`, and `paging-androidx`, plus
+ `bom` for version alignment. Other modules remain deferred as listed in
+ [STABILITY.md](STABILITY.md); a passing build does not change release eligibility.
+* `KtorExchange`'s constructor is public, so a `KtorErrorMapper` can be unit-tested without
+ driving a fetch.
+* The OpenTelemetry instrumentation-scope version is generated from the build version; a
+ hardcoded constant would otherwise have reported `6.0.0-SNAPSHOT` regardless of the version
+ actually published.
+* With durable journal storage, recovery from a committed `ACKED` receipt resumes source adoption,
+ effects, and retirement without another push. A crash before that receipt commits can resend
+ the same generation. Endpoints must treat a repeated idempotency key as the same request;
+ the default in-memory journal does not survive process death.
+* A conformance suite under `core/src/commonTest` names every zero-config behavior as a readable
+ test, including single-flight, freshness policy, overlay projection, invalidation, and engine
+ eviction.
+
+**Bug Fixes**
+
+* Settle admitted SQLDelight mutations and their reader notifications before returning when the
+ caller is cancelled. Explicit failures before commit still roll back.
+* Park value-codec failures during initial mutation preparation and precondition copies without
+ counting a push attempt, and release completed execution caches after safe retirement.
+* Cancel suspended `Store.get` work when the Store closes.
+* Report bookkeeping read failures through Store's typed persistence errors and keep freshness
+ conservative until successful revalidation. SQLDelight and Room status reads preserve storage
+ failures instead of treating them as fresh metadata.
+* Settle admitted Room bookkeeping transactions across caller cancellation.
+* Bind acknowledgement content and metadata to the same writer, preserve invalidations after
+ the first push, and retain conservative freshness when earlier evidence is unavailable.
+* Exclude an acknowledged optimistic prefix only after proven source adoption; queued suffixes
+ and durable recovery records remain available.
+* Keep a committed fetch behind its causal source-reader observation when a suspended absence
+ replan resumes during the bookkeeping tail.
+* Preserve a captured writer's source origin when a later writer changes reader resolution.
+* Reject nonfinite GraphQL floats, normalize signed zero, and preserve distinct integer and float
+ canonical identities. See the [persisted-key migration note](graphql/README.md#persisted-numeric-keys).
+* Fix `Store.get` surfacing a joined-fetch failure when a concurrent write already committed a
+ fresher value; the resident value is served instead.
+* Fix invalidation telemetry and key events firing for invalidations that were superseded before
+ signaling.
+* Fix a silent wedge when a mutation `stales` function throws: the intent now parks durably with a
+ dead-letter row instead of blocking its key's queue invisibly.
+* Fix cross-namespace canonical acknowledgements looping forever: alias rejection is terminal and
+ the accepted generation is never re-pushed after parking.
+* Make post-acknowledgement codec blocks visible through the event stream while the execution stays
+ `ACKED` without re-pushing.
+* Fix nested writes across different Room databases silently deadlocking on opposite stripe order;
+ admission now fails fast with an exception naming both databases.
+* Stop adapter bookkeepers from swallowing `OutOfMemoryError` and other VM failures as "stale".
+* Replace quadratic UTF-8 truncation in the in-memory journal storage with a single pass.
+* Refuse `204 No Content` and `205 Reset Content` in the Ktor kit's default mapping instead of
+ adopting them; neither carries a representation, so an empty body never replaces a resident
+ value. Handle those statuses in a `KtorErrorMapper`.
+* Refuse a `KtorOutcome.NotModified` returned by a mapper for an exchange that sent no validator:
+ freshness cannot be refreshed from an unconditional request.
+* Refuse a `304 Not Modified` whose request carried a validator header the Ktor kit did not set.
+ The kit cannot tell which validator the server compared, so it fails closed rather than
+ trusting the response.
+* Default the paging refresh key to the previous key of the page closest to the anchor, so a
+ forward-only source restarts from the initial page instead of resuming at the next page and
+ skipping the anchored content.
+* Reject a `file` namespace or canonical id holding an unpaired surrogate before any file or
+ mirror change. UTF-8 encoding replaces an unpaired surrogate with U+FFFD, so distinct malformed
+ keys would otherwise map onto one on-disk name.
+
+**Known limitations**
+
+* The zero-config in-memory source of truth and bookkeeper retain one entry per distinct key for
+ the store's lifetime and are not bounded by `maxIdleKeys`. Install persistent implementations
+ when key cardinality can grow without limit; bounded defaults are tracked for the beta line.
+* A `clearNamespace` or `clearAll` call racing demand that starts during the sweep can let that
+ demand commit into the cleared namespace after the call returns. Tracked for beta01.
+* On a long-lived `stream(MaxAge)` collection started against fresh data, nothing replans when the
+ value ages past the window until another event touches the key. Drive refreshes from your own
+ timer if this matters.
+* An overlay frame's `refreshing` bit can lag one projection cycle behind a fetch that started
+ after the identical overlay value became visible. It self-corrects on the next distinct frame.
+* Room echo publication backpressures writers behind a stopped collector instead of dropping the
+ mutation; one wedged collector freezes writes to its database. This tradeoff is documented at
+ the adapter.
+
+**Community issues**
+
+* Closes [#402](https://github.com/MobileNativeFoundation/Store/issues/402). `Freshness.MustBeFresh`
+ refetches even when the resident value is fresh: `mustBeFreshRefetchesFreshResident` in
+ [FreshnessPolicyConformanceTest](core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt).
+* Closes [#536](https://github.com/MobileNativeFoundation/Store/issues/536). `Freshness.LocalOnly`
+ serves a pre-populated source of truth without calling the fetcher:
+ `localOnly_prePopulatedSot_getServesWithoutFetcher` in
+ [SourceOfTruthConformanceTest](core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt).
+* Closes [#702](https://github.com/MobileNativeFoundation/Store/issues/702) and
+ [#602](https://github.com/MobileNativeFoundation/Store/issues/602). `paging-androidx` builds an
+ androidx `PagingSource` and a `RemoteMediator` over any Store: `refreshLoad_mapsFirstDataFrameToPage`
+ and `appendLoad_usesPageKeyFromParams` in
+ [StorePagingSourceTest](paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StorePagingSourceTest.kt),
+ and `mediatorRefresh_invalidatesThenGetsFresh` and `mediatorAppend_getsCachedOrFetch` in
+ [StoreRemoteMediatorTest](paging-androidx/src/commonTest/kotlin/org/mobilenativefoundation/store6/paging/StoreRemoteMediatorTest.kt).
+* Answers [#534](https://github.com/MobileNativeFoundation/Store/issues/534) with the published
+ roadmap, [ROADMAP.md](ROADMAP.md).
+* Answers [#570](https://github.com/MobileNativeFoundation/Store/issues/570) with the committed
+ JVM and KLIB ABI dumps described in [STABILITY.md](STABILITY.md#verification), which are
+ committed at every released tag and checked on every pull request.
+* Answers [#722](https://github.com/MobileNativeFoundation/Store/issues/722) and
+ [#578](https://github.com/MobileNativeFoundation/Store/issues/578) with the mutations floor in
+ this alpha — `mutations`, `mutations-sqldelight`, and `mutations-testing` — described in
+ [STABILITY.md](STABILITY.md#mutations).
+
+The release date and the next-alpha target month await the release owner; the target month is
+stated as one month after the cut date, per the monthly cadence in
+[STABILITY.md](STABILITY.md#cadence). Posting and closing the issues above is a release-owner
+action. These notes are a draft and do not establish artifact availability.
+
## [5.1.0-alpha10] (2026-07-13)
**Bug Fixes**
@@ -459,4 +581,4 @@ This is a first alpha release of Store ported to RxJava 2.
[3.0.0-alpha]: https://github.com/MobileNativeFoundation/Store/releases/tag/3.0.0-alpha
-[1.x]: https://github.com/NYTimes/Store/blob/develop/CHANGELOG.md
\ No newline at end of file
+[1.x]: https://github.com/NYTimes/Store/blob/develop/CHANGELOG.md
diff --git a/Images/friendly_robot.png b/Images/friendly_robot.png
deleted file mode 100644
index 424a3f78c..000000000
Binary files a/Images/friendly_robot.png and /dev/null differ
diff --git a/Images/friendly_robot_icon.png b/Images/friendly_robot_icon.png
deleted file mode 100755
index 558096a81..000000000
Binary files a/Images/friendly_robot_icon.png and /dev/null differ
diff --git a/Images/store-1.jpg b/Images/store-1.jpg
deleted file mode 100644
index e64bac66b..000000000
Binary files a/Images/store-1.jpg and /dev/null differ
diff --git a/Images/store-2.jpg b/Images/store-2.jpg
deleted file mode 100644
index 9f97598f6..000000000
Binary files a/Images/store-2.jpg and /dev/null differ
diff --git a/Images/store-3.jpg b/Images/store-3.jpg
deleted file mode 100644
index 99a991293..000000000
Binary files a/Images/store-3.jpg and /dev/null differ
diff --git a/Images/store-4.jpg b/Images/store-4.jpg
deleted file mode 100644
index 20bd929d3..000000000
Binary files a/Images/store-4.jpg and /dev/null differ
diff --git a/Images/store-5.jpg b/Images/store-5.jpg
deleted file mode 100644
index 873aecab2..000000000
Binary files a/Images/store-5.jpg and /dev/null differ
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 000000000..e797302ef
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,42 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "Store6",
+ platforms: [
+ .iOS(.v15),
+ .macOS(.v12),
+ ],
+ products: [
+ .library(name: "Store6", targets: ["Store6"]),
+ .library(name: "Store6SwiftUI", targets: ["Store6SwiftUI"]),
+ ],
+ targets: [
+ // Built by `./gradlew :store6-swift:assembleStore6KotlinDebugXCFramework`.
+ // Switched to a url/checksum release asset when the facade ships in a release.
+ .binaryTarget(
+ name: "Store6Kotlin",
+ path: "store6-swift/build/XCFrameworks/debug/Store6Kotlin.xcframework"
+ ),
+ .target(
+ name: "Store6",
+ dependencies: ["Store6Kotlin"],
+ path: "store6-swift/swift/Sources/Store6"
+ ),
+ .target(
+ name: "Store6SwiftUI",
+ dependencies: ["Store6"],
+ path: "store6-swift/swift/Sources/Store6SwiftUI"
+ ),
+ .testTarget(
+ name: "Store6Tests",
+ dependencies: ["Store6"],
+ path: "store6-swift/swift/Tests/Store6Tests"
+ ),
+ .testTarget(
+ name: "Store6SwiftUITests",
+ dependencies: ["Store6SwiftUI"],
+ path: "store6-swift/swift/Tests/Store6SwiftUITests"
+ ),
+ ]
+)
diff --git a/README.md b/README.md
index f67ae8b45..6f109a283 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,44 @@
-# Store5
+# Store6
-[](https://codecov.io/gh/MobileNativeFoundation/Store)
+[](https://app.codecov.io/gh/matt-ramotar/Store6/tree/store6)
+
+## Store 6
+
+Store 6 is the next major line, using `core`, `testing`, `mutations`, and the other Store 6
+artifacts in the `org.mobilenativefoundation.store` group, alongside Store 5 for the whole 6.x major. It is a Kotlin Multiplatform library for reading and writing data that lives in
+more than one place: a network, a local database, and memory. You describe a key and a fetcher, and
+Store handles single-flighting concurrent demand, staleness, and invalidation, and it bounds engine
+residency with a `maxIdleKeys` LRU. Every zero-config behavior is named and covered by a conformance
+test you can read; the zero-config in-memory persistence itself is unbounded by design — install a
+persistent source of truth and bookkeeper when key cardinality can grow without limit.
+
+**Status: in development, targeting 6.0.0-alpha01.** The alpha artifacts are not yet available
+from Maven Central.
+
+Two things about the first alpha, stated up front rather than discovered later:
+
+- **Mutations ship experimental.** `mutations` is a separate artifact and every public symbol
+ is `@ExperimentalStoreApi`. The tier is on the artifact, never annotation-gated inside a stable
+ one.
+- **Mutations ship the two-step durable ack posture.** The non-transactional acknowledgement path
+ records `ACKED` durably before adopting the server echo and retires the journal row last.
+ Process-death recovery requires durable journal storage; the default is in memory.
+ Recovery from durable `ACKED` resumes adoption and effects without another push. A crash before
+ that receipt is durable can cause the push to be re-sent with the same idempotency key; endpoints
+ must treat it as the same request. Atomic journal/source acknowledgement is beta01 work.
+
+The full policy — API tiers, the deprecation cycle, the cadence commitment, and how you can verify
+all of it from a released tag — is in [STABILITY.md](./STABILITY.md). The public roadmap is at
+[ROADMAP.md](./ROADMAP.md), and the quickstart is at
+[docs/store6/quickstart.md](./docs/store6/quickstart.md).
+The [platform matrix](docs/store6/platforms.md) lists declared targets and the scope of completed
+verification.
+The Swift Package Manager facade is deferred from this alpha. Local development instructions are in
+[store6-swift/README.md](./store6-swift/README.md).
+
+---
#### Documentation
diff --git a/RELEASING.md b/RELEASING.md
index 4d42171d9..834acdc58 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,28 +1,137 @@
Releasing
========
-1. Change the version in top level `gradle.properties` to a non-SNAPSHOT version.
-2. Update the `cocoapods` version in `build.gradle.kts` in `:store`.
-3. Modify `create_swift_package.yml` workflow.
- * https://github.com/MobileNativeFoundation/Store/blob/e526400cdf51aa2f78b6b7e9e87f4a6845e6dcea/.github/workflows/create_swift_package.yml
-4. Update the `CHANGELOG.md` for the impending release.
-5. Update the `README.md` with the new version.
-6. `git commit -sam "Prepare for release X.Y.Z."` (where X.Y.Z is the new version)
-7. `git tag -a X.Y.X -m "Version X.Y.Z"` (where X.Y.Z is the new version)
- * Run `git tag` to verify it.
-8. `git push && git push --tags`
- * This should be pushed to your fork.
-9. Create a PR with this commit and merge it.
-10. Update the top level `build.gradle` to the next SNAPSHOT version.
-11. Modify `create_swift_package.yml` workflow to only run manually.
- * https://github.com/MobileNativeFoundation/Store/blob/de9ed1764408eeaafe5e58fe602205c875a8b0b0/.github/workflows/create_swift_package.yml
-12. `git commit -am "Prepare next development version."`
-13. Create a PR with this commit and merge it.
-14. Login to Sonatype to promote the artifacts https://central.sonatype.org/pages/releasing-the-deployment.html
- * This part is automated. If it fails in CI, follow the steps below.
- * Click on Staging Repositories under Build Promotion
- * Select all the Repositories that contain the content you want to release
- * Click on Close and refresh until the Release button is active
- * Click Release and submit
-15. Update the sample module's `build.gradle` to point to the newly released version. (It may take ~2 hours for artifact to be available after release)
-
\ No newline at end of file
+Store 6 follows the release policy in [STABILITY.md](./STABILITY.md). `VERSION_NAME` in
+[`gradle.properties`](./gradle.properties) is the single version source. The publication
+manifest, BOM, and STABILITY release column must describe the same shipping artifacts.
+
+## Preparing a release
+
+1. Select the release version, release date, next alpha target, and community issue resolved by
+ a named conformance guarantee. Prepare the matching `CHANGELOG.md` section. Immutable releases
+ require exactly one `## [VERSION] (YYYY-MM-DD)` heading and nonempty notes without unfinished
+ placeholders. Draft notes are not publication evidence.
+2. Set the root `VERSION_NAME`. A release tag requires a version without `-SNAPSHOT`; manual
+ CI dispatch accepts snapshots only. Module properties must not override the root version.
+ Local publication verification reads this same property, so there is no second version to edit.
+3. Regenerate changed modules' JVM, Android, and KLIB ABI output with their `apiDump` tasks.
+ If Swift-facing source changed, run `./gradlew refreshSwiftDumps`, inspect generated output,
+ and run `./gradlew checkSwiftDumps`. Commit generated output with its source changes.
+4. Validate the candidate's complete matrix, publication metadata, examples, and external
+ consumers. Keep the source revision, command, environment, task outcome, test identifiers,
+ failures, skips, and artifact inventory with the candidate. Compilation, KLIB file existence,
+ cached results, and fresh test execution are different evidence classes.
+5. Open the release pull request. Changes listed in `.github/docs-sync-sources.txt` require the
+ `docs-sync-ack` label. The label acknowledges the synchronization work; it does not establish
+ that the documentation site has synchronized. Verify the site separately before claiming it
+ is ready.
+6. After release-owner approval, merge and tag the release commit, then push the tag to
+ `MobileNativeFoundation/Store`. The tag must be exactly `v${VERSION_NAME}`. A new source,
+ configuration, or version change requires validation at that new revision.
+
+## Publication gate
+
+[CI](./.github/workflows/ci.yml) permits publication only in `MobileNativeFoundation/Store`.
+It requires the root build, release-workflow fixtures, all six jobs in the reusable
+[Store6 matrix](./.github/workflows/store6.yml), and the
+[full mutations suite](./.github/workflows/store6-full-jvm.yml). The PR-only documentation
+acknowledgment check is not a release-tag job. Required jobs must succeed at the checked-out
+source SHA, workflow run, attempt, and root version before publication begins. Every required
+job records those values. The matrix and full-suite censuses reject missing or mismatched job
+records, so reusing success from an earlier attempt cannot authorize publication.
+
+The full mutations suite executes once, split across five jobs on `ubuntu-latest`:
+`full-mutations-jvm` runs `:mutations:jvmTest`, which carries every test class except the
+model-checking one, and the four-shard `lincheck` matrix runs `:mutations:lincheckTest` over the
+101 scenarios of the pinned plan (100 generated from the seed plus one curated), split round-robin
+by index across the four shards (26/25/25/25). The matrix does not fail fast, and every lane must
+pass. Each lane records its own execution with
+`release_control.py full-suite-execution`; `validation-evidence` aggregates those records with
+`release_control.py full-suite`.
+
+The JVM test task disables cache and up-to-date reuse when `store6.fullJvmSuite` is set;
+`lincheckTest` disables both unconditionally, and `jvmTest` always excludes the Lincheck class.
+Compilation caching remains available. Each result artifact records task outcome, executed test
+identifiers, XML hashes, and run provenance, and each record also carries its task, its shard,
+its executed class list, and, for a shard, its scenario indices. The gate proves that the shards
+cover scenarios 0 through 100 exactly once and that the Lincheck class never ran in the jvmTest
+lane. Missing, cached, incomplete, and failed test evidence cannot satisfy the gate. A first
+failure is retained in that Actions run's summary and result artifact for classification, not
+rerun unchanged for green.
+
+Beyond that scenario-coverage union, two more checks run per shard. Every shard's
+`store6-lincheck-scenarios` marker line prints a plan digest that `release_control.py` requires to
+equal its pinned `LINCHECK_SCENARIO_DIGEST`, which
+[`test_workflow_contract.py`](./.github/scripts/tests/test_workflow_contract.py) in turn pins
+equal to `SCENARIO_DIGEST`, the golden constant in
+[`LincheckScenarioPlan.kt`](./mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/LincheckScenarioPlan.kt).
+A shard that validated a different plan — a changed `SCENARIO_SEED`, curated scenario, or
+thread/actor shape — fails the gate instead of passing quietly. Separately, Lincheck's own
+`= Iteration k / n =` lines are counted out of the console log and must equal the shard's planned
+scenario count, so a shard that stopped short of its plan — a hang or an early abort — fails even
+where the Gradle task itself reported success.
+
+When the plan legitimately changes — a new `SCENARIO_SEED`, a different curated scenario, or a
+changed thread/actor shape — regenerate the digest from the new plan and update the constant in
+both `LincheckScenarioPlan.kt` (`SCENARIO_DIGEST`) and `release_control.py`
+(`LINCHECK_SCENARIO_DIGEST`); `test_workflow_contract.py` fails the build until the two agree
+again.
+
+Both checks depend on Gradle test output that carries no other role in the build:
+`testLogging.showStandardStreams` in [`mutations/build.gradle.kts`](./mutations/build.gradle.kts)
+and `.logLevel(LoggingLevel.INFO)` in
+[`MutationJournalLincheckTest`](./mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalLincheckTest.kt)
+are gate inputs, not incidental verbosity — removing either blinds the evidence recorder to the
+marker or iteration lines it depends on.
+
+Budget the wall-clock cost from the first hosted execution of this gate, measured on
+`ubuntu-latest` on 2026-09-11: 51 minutes end to end, with the jvmTest lane under 2 minutes and
+the shards between 36 and 51 minutes.
+
+A Lincheck `Unable to transform` diagnostic in the console log or XML `system-err` output
+also rejects the run when test cases pass: the affected class may have run without model-checking
+instrumentation. Inspect the preserved diagnostic before changing or repeating the candidate.
+
+Local publication verification requires the consumable artifact, POM, and Gradle module metadata
+for every expected module and target publication. Missing or unexpected target publications fail
+validation.
+
+The publication controller reads the shipping modules from
+[`.github/release-manifest.json`](./.github/release-manifest.json), which lists fifteen libraries
+plus the BOM at this revision. Immutable publication uses `publishAndReleaseToMavenCentral`;
+snapshots use `publishToMavenCentral`. The rest of the publication metadata also comes from the
+root [`gradle.properties`](./gradle.properties): the group, the version, and the
+`mobilenativefoundation` / Mobile Native Foundation developer identity written into every
+generated POM. Credentials and signing material come from CI secrets. Local fixtures exercise
+these steps with command stubs and do not establish signed Central deployment.
+
+Before immutable Maven publication, CI reserves a draft GitHub Release for the tag. It records
+an attempted module before invoking Maven and appends each completed module to
+`publication-receipt.json`. The receipt, validation evidence, and notes are uploaded as an
+Actions artifact before the GitHub Release is made public. Existing release records block
+automatic publication of the same immutable version again.
+
+## Partial publication and record repair
+
+Inspect the original run's receipt before taking another publication action. A failed command
+can leave Central state uncertain; reconcile the attempted module with Central and the recorded
+inventory. Do not treat a failed workflow as proof that no artifacts were released. Published
+Maven versions are immutable, and removing a GitHub record does not undo Maven publication.
+
+When the receipt records every shipping module as complete but the GitHub record failed, run
+[`Store6 release record repair`](./.github/workflows/store6-release-record.yml) with the release
+tag, original CI run ID, and original attempt. It checks the tag/source and downloads that
+attempt's preserved receipt. It only uploads the receipt and updates GitHub notes and release
+visibility; it never invokes Maven. Repeating this record repair is supported. Incomplete or
+missing receipts require reconciliation before repair and are rejected by this workflow.
+
+Full-suite results and release provenance/receipt artifacts are retained for 90 days. Ordinary
+matrix and root-build failure reports are retained for 7 days; preserve those raw reports before
+day 7. Artifact resolution from Central and the final GitHub record must be checked before
+announcing availability or replacing prerelease-only installation text.
+
+## After the release
+
+Set the root `VERSION_NAME` to the next development version. Preserve the released source SHA,
+artifact and BOM/POM inventory, consumer evidence, release receipt, notes, and known limitations.
+Keep the selected next-alpha target in the published notes.
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 000000000..620107593
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,121 @@
+# Store 6 roadmap
+
+Store 6's plan, with dates on it. Some of those dates will move. What will not move is the rule
+that governs how they move, stated in the first section below. Where a window is an estimate, this
+page says so and gives the range.
+
+This is the roadmap [#534][534] asked for.
+
+## Operating principles
+
+These are commitments, not aspirations.
+
+1. **Cut scope, never cadence.** A slip threatens a release's contents, never its date.
+2. **The read core never waits on an extension.** If paging, the Swift facade, or Store 5 interop
+ slips, 6.0 still ships as a complete, stable read library without it. Mutations are not an
+ extension for this rule's purposes — writing is functionality Store 5 already shipped, and Store 6
+ is not publishable to this community without it. It lives in a separate artifact because its API
+ is experimental, which is a packaging decision, not a dependency one.
+3. **Experimental code lives in separate artifacts.** Never annotation-gated inside a stable
+ artifact, so a tier is always visible on the thing you depend on.
+4. **Docs are launch gates, not follow-ups.** A release without its documentation is not done, and
+ migration guides ship with the migration.
+5. **Gates are written down before the work starts**, so a feature ships when its criteria pass
+ rather than when enthusiasm peaks.
+
+## Release train
+
+### Foundation (Q3–Q4 2026)
+
+The build, the target matrix, the CI lanes, and the API-review discipline, proven end to end before
+depth is added. Binary-compatibility and generated-Swift dumps gated in CI from the first alpha.
+Store 6 is developed in a fork and lands in this repository under `store6.*` before the alpha01 cut.
+History, stars, and watchers stay here.
+
+### 6.0.0-alpha01 — target Q4 2026 (confidence range Q4 2026 – Q1 2027)
+
+The list is split into a floor that defines the release and deliverables that may slip a month
+under principle 1. The confidence range above is real: treat Q1 2027 as the honest outer bound.
+
+**The floor — these are alpha01:**
+
+| | |
+|---|---|
+| `core`, `testing` | The engine and its conformance kit. |
+| `mutations` | The write path: journal, drain, rebase, conflict stack, restart replay. Experimental artifact, in the floor rather than the may-slip list. |
+| `graphql`, `realtime` | Experimental integration artifacts: GraphQL fetcher integration and server-message bindings onto stores. |
+| `mutations-sqldelight`, `mutations-testing` | Experimental companions to `mutations`: SQLDelight-backed journal storage and the journal and mutator contract kits. |
+| `sqldelight`, `room`, `compose` | Experimental persistence and UI adapters in the alpha shipping roster. |
+| `paging-androidx`, `opentelemetry`, `ktor`, `file`, `mutations-conflicts` | Experimental extension artifacts: Paging 3 interop, an OpenTelemetry sink for JVM and Android, a Ktor fetcher kit, file-backed persistence, and a conflict-strategy pack. |
+| `bom` | Version alignment for the fifteen shipping libraries. |
+| STABILITY.md + this roadmap | The published policy: tiers, deprecation cycle, cadence commitment. |
+| Quickstart + Important Defaults | The mental model before the API reference. |
+
+**Deferred from alpha01:** the devtools MVP (`devtools`, `devtools-inspector`), the drain
+scheduler (`mutations-drain`, `mutations-drain-meeseeks`), the Swift facade, and the remaining
+documentation pages. Passing a build does not add an artifact to that roster; the publication
+allowlist, BOM, and [stability table](STABILITY.md) must agree before it ships.
+
+### Mutations beta train + 6.0.0-beta01 (Q1–Q2 2027)
+
+Ack-path atomicity and its crash matrix, the Swift SPM facade against the freeze-candidate core,
+the outbox inspector demo, and Store 5 interop with migration lint.
+
+beta01 is the **core API freeze candidate**. From beta01 forward, no source-breaking core change
+without an RC reset.
+
+A word on what "freeze" means here, because it is the promise most worth being precise about. The
+seam you implement to plug in your own fetcher, source of truth, bookkeeper, clock, telemetry, or
+overlay becomes a freeze **candidate** once a real producer has exercised it end to end, which the
+mutations work does before alpha01. It becomes **frozen** only after the ack-path atomicity work and
+its test matrix are green. If that work misses beta01, the overlay and write-handle surfaces ship
+experimental outside the frozen tier and the rest of the core freezes on schedule. Two stages, both
+stated, neither skipped.
+
+### 6.0.0 GA — target Q3 2027 (confidence range Q3 – Q4 2027)
+
+Core, testing, the adapters, Store 5 interop, and the BOM in the stable tier, the adapters having
+run the contract kit throughout the alpha line. Paging, experimental since alpha01, stays alongside
+GA as a supported experimental artifact with the tier on the tin. The 5→6 and "Store 4 → 6 in an
+afternoon" migration guides both block GA. Store 5 moves to fixes-only maintenance with a dated
+end-of-life published at GA.
+
+### After GA
+
+6.1 brings the first mutations graduation review. 6.2 is gated rather than dated. 6.3 is the target
+window for mutations graduation to stable.
+
+## Cadence
+
+**Monthly alphas from 6.0.0-alpha01.** Each release names the next release's target month, and each
+one closes at least one community issue with a link to the named guarantee that resolves it — a
+conformance test, not a changelog line.
+
+Full policy, including the deprecation cycle and how to verify any of this from a released tag, is
+in [STABILITY.md](./STABILITY.md).
+
+## Mutations graduation
+
+Mutations stay experimental past GA. The first review is at 6.1, and the target window for
+graduation is roughly 6.3. Graduation requires the API unchanged across two consecutive minors,
+crash-matrix and soak lanes green in production-representative apps, and at least three external
+production adopters reporting. If those are not met, it stays experimental and the review repeats.
+There is no date-driven graduation.
+
+## How to contribute
+
+- **Documentation.** Every page in this line names the source it was written from, and code blocks
+ come from modules CI compiles. If a page loses you, open an issue saying where. That is a useful
+ bug report, and it is the one we most want.
+- **Semantics.** The conformance suite under
+ [`core/src/commonTest`](core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/)
+ is the specification. If you can describe a behavior you expected and a test that would have
+ caught it, that is a complete contribution before a line of implementation.
+- **Adapters and platforms.** The source-of-truth seam is small on purpose. An adapter for a store
+ we do not cover is a self-contained contribution.
+- **Where to talk.** The [#store](https://kotlinlang.slack.com/archives/C06007Z01HU) channel on
+ Kotlin Slack, or an issue on this repository.
+
+Issues that name a concrete expectation get answered with a test. That is the on-ramp.
+
+[534]: https://github.com/MobileNativeFoundation/Store/issues/534
diff --git a/STABILITY.md b/STABILITY.md
new file mode 100644
index 000000000..e3ca50ece
--- /dev/null
+++ b/STABILITY.md
@@ -0,0 +1,246 @@
+# Store 6 stability policy
+
+## 1. What this document is
+
+What each Store 6 artifact promises, how an API is allowed to change, how often we ship, and how
+you can verify all of it from a released tag. Where a promise is not yet earned, this document says
+so rather than rounding up.
+
+It is also the standing answer to [#570][570] on binary compatibility and [#534][534] on a published
+roadmap.
+
+Scope: the Store 6 artifacts, effective with the 6.0.0-alpha01 release. Store 5 continues under
+its own coordinates, and [§6](#6-migrating-from-store-5) covers living with both.
+
+## 2. API tiers
+
+
+
+Store 6 uses three opt-in markers. Each is a real annotation in `core`, and the meaning below
+is the one carried in its own KDoc.
+
+| Marker | Means |
+|---|---|
+| `@ExperimentalStoreApi` | API under active development that **may change or be removed in any release**. Experimental API ships in separate artifacts wherever possible; the marker exists for the cases where an experimental member must live beside stable API. |
+| `@DelicateStoreApi` | API that is **stable but easy to misuse** — for example implementing `Store` directly instead of building one through the `store { }` DSL. Opting in asserts that you uphold the documented contract of the marked declaration. |
+| `@InternalStoreApi` | API **internal to the Store libraries**. It may change or disappear without notice even in patch releases, and must never be used outside `org.mobilenativefoundation.store` artifacts. |
+
+All three are `RequiresOptIn.Level.ERROR`: you cannot use them by accident. `Store` additionally
+carries `@SubclassOptInRequired(DelicateStoreApi::class)`, so implementing the interface yourself is
+a deliberate act, not a default.
+
+**Experimental code lives in separate artifacts, never annotation-gated inside a stable one.** When
+a capability needs its own release rhythm, it gets its own artifact, and the tier is stated on the
+artifact rather than buried in an annotation on a member you have already depended on.
+
+**SemVer is scoped to the stable tier.** A breaking change to an `@ExperimentalStoreApi` surface in
+a minor release is not a SemVer violation, because that surface never claimed the guarantee. That is
+the whole point of stating the tier on the tin.
+
+## 3. Artifacts and tiers, as of 6.0.0-alpha01
+
+Group coordinates are unchanged: `org.mobilenativefoundation.store`. Packages are
+`org.mobilenativefoundation.store6.*`.
+
+| Artifact | Tier | In 6.0.0-alpha01 |
+|---|---|---|
+| `core` | Stable-track. The API is **not frozen** until the beta01 freeze candidate. | alpha01 |
+| `testing` | Experimental (`@ExperimentalStoreApi`) — every public declaration in the artifact carries the marker today. | alpha01 |
+| `sqldelight` | Experimental adapter (`@ExperimentalStoreApi`). Graduates to stable at 6.0.0, having run the contract kit throughout the alpha line. | alpha01 |
+| `room` | Experimental adapter, same graduation. | alpha01 |
+| `compose` | Experimental adapter, same graduation. | alpha01 |
+| `graphql` | Experimental (`@ExperimentalStoreApi`). Fetcher integration for GraphQL operations. | alpha01 |
+| `realtime` | Experimental (`@ExperimentalStoreApi`). Server-message bindings onto stores. | alpha01 |
+| `mutations` | **Experimental, separate artifact — every public symbol is `@ExperimentalStoreApi`.** See [§8](#mutations). | alpha01 |
+| `mutations-sqldelight` | Experimental mutations-family artifact (`@ExperimentalStoreApi`). SQLDelight-backed durable journal storage. | alpha01 |
+| `mutations-testing` | Experimental mutations-family artifact (`@ExperimentalStoreApi`). Contract kits for journal storage and mutator purity, plus deterministic crash-test storage. | alpha01 |
+| `paging-androidx` | Experimental (`@ExperimentalStoreApi`). Paging 3 interop. The default refresh key restarts from the initial page unless the page closest to the anchor has a previous key. | alpha01 |
+| `opentelemetry` | Experimental (`@ExperimentalStoreApi`). Telemetry sink over the OpenTelemetry API; JVM and Android only. | alpha01 |
+| `ktor` | Experimental (`@ExperimentalStoreApi`). HTTP fetcher kit. A `304 Not Modified` is adopted only when the request carried exactly the validator the kit wrote; any other 304 is refused as an error. `204` and `205` responses are refused by default (previously they reached `decode`). | alpha01 |
+| `file` | Experimental (`@ExperimentalStoreApi`). Filesystem source of truth and bookkeeper. Malformed UTF-16 key components are rejected before any file or mirror change. | alpha01 |
+| `mutations-conflicts` | Experimental (`@ExperimentalStoreApi`). Canned conflict merge policies registered in the `conflicts { }` door of `mutationStore`. | alpha01 |
+| `bom` | Version alignment only; no API surface of its own. | alpha01 |
+| `devtools` | Experimental (`@ExperimentalStoreApi`). | alpha02 (target) |
+| `devtools-inspector` | Experimental (`@ExperimentalStoreApi`). | alpha02 (target) |
+| `mutations-drain` | Experimental (`@ExperimentalStoreApi`). | alpha02 (target) |
+| `mutations-drain-meeseeks` | Experimental (`@ExperimentalStoreApi`). Upstream JVM scheduling fixes and concurrent scheduling uniqueness verification remain required. | alpha02 (target) |
+| `store6-swift` | Prerelease Swift facade and local XCFramework workflow. Undeclared Kotlin exceptions in maintenance and closed-store calls require bridge correction before distribution. | Deferred distribution; not an alpha01 Maven artifact. |
+
+Inside `core`, the `org.mobilenativefoundation.store6.core.seam` package contains 15 files for
+fetchers, persistence, bookkeeping, clocks, telemetry, overlays, and acknowledgement evidence.
+It is a **freeze candidate, not frozen.** Today these types are `@ExperimentalStoreApi`, so
+implementing one is an explicit opt-in; that is the exception §2 names, and it is why the seam sits
+inside a stable-track artifact rather than shipping separately.
+
+Recompile custom `StoreWriteHandle` and `Overlay` implementations against this version. The
+new acknowledgement and adoption-aware projection methods have Kotlin source defaults, but the
+JVM build emits abstract interface methods with `DefaultImpls`; previously compiled implementations
+do not gain binary compatibility from those defaults. The default acknowledgement implementation
+marks stale and applies without confirming freshness or supplying adoption proof.
+
+The candidate-versus-frozen distinction is load-bearing and we state it in two stages deliberately.
+A real producer has to exercise a seam end to end before we will call it a candidate. The
+`Overlay` and `StoreWriteHandle` surfaces become frozen only once the ack-path atomicity work and
+its test matrix are green; if that work misses beta01, those two ship `@ExperimentalStoreApi`
+outside the frozen tier and the rest of core freezes on schedule. CI enforces the 15-file list on
+every pull request, so the seam cannot grow quietly.
+
+`store5-interop` targets 6.0.0 and is not in alpha01. Passing a module's build does not add it
+to the release. A roster change must update the publication manifest, BOM constraints, and
+this table together. Deferred artifacts have no alpha01 installation promise; a later release
+must name their target and satisfy their validation requirements before distribution.
+
+The [platform matrix](docs/store6/platforms.md) separates declared targets, executed local tests,
+compile/artifact checks, independent consumers, and pending release verification.
+
+## 4. Deprecation cycle
+
+
+
+Every removal from the stable tier goes through three stages:
+
+1. **`WARNING` with `ReplaceWith`.** The replacement is mechanical wherever the shape allows it.
+2. **`ERROR`, no earlier than two minor releases later.** You get at least two minors of warning
+ before your build breaks.
+3. **`HIDDEN` at the next major.** Binary compatibility is preserved until then.
+
+**No silent capability drops.** A removed capability gets the same cycle and a migration note. You
+should never find out a capability is gone by upgrading.
+
+## 5. Release cadence
+
+
+
+**Monthly alphas from 6.0.0-alpha01.** The governing rule is **cut scope, never cadence**: a slip
+threatens a release's contents, never its date. If something is not ready, it ships in the next
+alpha a month later and the release notes say so.
+
+We will not repeat a 30-month alpha line, and we will not break API in beta again.
+
+Each alpha closes at least one community issue with a link to the named guarantee that resolves it —
+a conformance test, not a changelog line. The next alpha's target month is stated in each release's
+notes. This document states the policy. Each release states the date.
+
+The public roadmap is at [ROADMAP.md](./ROADMAP.md).
+
+## 6. Migrating from Store 5
+
+Store 5 and Store 6 artifacts live **side by side for the whole 6.x major** in
+`org.mobilenativefoundation.store`. You can depend on both in one build and migrate a screen at a
+time. There is no flag day.
+
+`store5-interop` is supported for all of 6.x. The 5→6 and 4→6 migration guides are launch
+gates for 6.0.0 — they block GA, they are not follow-ups.
+
+## 7. How stability is verified
+
+
+
+Every claim in this document is checkable from a released tag.
+
+- **`explicitApi()` strict** on every Store 6 library module. Nothing becomes public by omission.
+- **Binary-compatibility-validator (0.17.0) with klib validation enabled.** Each module commits a
+ JVM `.api` dump and a `.klib.api` dump — for example `core/api/jvm/core.api` and
+ `core/api/core.klib.api`. The check runs as part of `build` on every pull request,
+ so an unintended ABI change fails CI before review.
+- **Generated-Swift dumps diffed on every pull request** across the supported bridges — Obj-C export
+ and SKIE today (`core/api/swift/objc`, `core/api/swift/skie`). The bridge set follows
+ the Swift Export disposition recorded at the alpha01 cut, so read this as a commitment to the
+ mechanism rather than to a fixed list of lanes.
+- **ABI dumps are committed at every released tag**, so the surface of any release is diffable from
+ the repository without resolving artifacts.
+- **The conformance suite is public documentation of what is guaranteed.** The behaviors this
+ library promises are named tests you can read:
+ [`core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/`](core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/)
+ (`*ConformanceTest.kt`). When a release closes one of your issues, the notes link the test, not a
+ bullet point.
+
+## 8. Mutations at 6.0.0-alpha01
+
+
+
+`mutations` is in the alpha01 floor, not the may-slip list: an app that writes should not
+have to wait for a later alpha. Three things about it are worth stating plainly.
+
+### (a) The tier
+
+Experimental, in its own artifact, every public symbol `@ExperimentalStoreApi`. The written
+graduation criteria are published alongside the 6.0.0-alpha01 release and linked from this section
+then; the first review is at 6.1. The target window for graduation to stable is roughly 6.3, and it
+is a target rather than a schedule: graduation requires the API unchanged across two consecutive
+minors,
+crash-matrix and soak lanes green in production-representative apps, and at least three external
+production adopters reporting. If those are not met, it stays experimental and the review repeats.
+Nothing graduates because a date arrived.
+
+### (b) The durable acknowledgement posture
+
+Every public mutation store has a journal storage, including the in-memory default, which does not
+survive process restart. After the server returns an acknowledgement, Store records the receipt, any
+pending alias or tombstone, and the `ACKED` execution phase in one journal transaction. Only after
+it commits does Store adopt the result, apply effects, and finalize retirement.
+
+If the server accepts a push before the local acknowledgement commits, the intent remains `INFLIGHT`;
+a later drain can resend the same immutable generation and key. Durable storage preserves that replay.
+This is the same conservative crash-window stance used for reads: prefer doing work twice over
+losing it.
+
+Once `ACKED` is committed, recovery resumes adoption, effects, and retirement without calling
+`MutationServer.push` again for that generation. Those post-acknowledgement steps may repeat
+conservatively after a failure, but the accepted write is not sent twice from that durable phase.
+
+The consequence: design mutation endpoints to treat a repeated idempotency key as the same request.
+This covers the remote-acceptance window before the local acknowledgement transaction commits.
+
+### (c) The surface has been reviewed — and stays experimental
+
+The mutations API review ran and ruled the surface (twenty rulings, 2026-08-01): the entry point
+is the required-input `mutationStore` factory with an overlay-free builder, restart-safe key
+recovery is a compile-time-required resolver, the value state is an explicit presence algebra,
+and the persistence a caller installs is retained for the transactional ack-path decorator.
+The module remains experimental — shapes can change in any release, and this document still
+deliberately freezes no mutations signature into policy prose.
+
+### (d) Parked work and retained history
+
+A `PARKED` intent remains durable and appears in `MutationStore.deadLetters()`. It is not
+automatically retried. Its client sequence can pin the retirement high-water mark, so later
+completed work does not make all older history eligible for confirmed pruning. Storage can
+continue growing behind that gap.
+
+Inspect the dead letter and retain the codec versions needed to read existing history before
+changing the application. Correcting future writes does not retire an already parked intent.
+The alpha exposes no discard or requeue API for parked work. Recovery that requires changing
+that intent needs an application-specific journal migration; normal drain retries do not
+provide it. An `ACKED` adoption failure is a different state: recovery resumes its stored
+acknowledgement without pushing that generation again.
+
+## 9. Reading pending writes and staleness
+
+Two affordances that look similar are not, and getting them backwards produces UI bugs that are
+hard to trace.
+
+- **A "pending write" affordance keys on `origin == OVERLAY`.**
+- **A "stale cache" affordance keys on `isStale`.**
+
+`isStale` is **never set on an `OVERLAY` frame.** Overlay frames are fresh by definition: they are
+stamped `age = Duration.ZERO` and `isStale = false` unconditionally, because an optimistic value
+genuinely is new — the user just wrote it. On an overlay frame, only `refreshing` is live. So a
+spinner driven by `isStale` will never fire for a pending write, and that is intended. Drive the
+pending-write indicator off the origin and narrate the `OVERLAY` → `SOT` flip.
+
+**`Store.get` is unprojected.** Overlays apply only to `stream`, so an optimistic mutation is
+invisible to `get`. This is a documented consequence of the read contract, not a defect: `get` is a
+point read of committed truth. If you need to observe your own optimistic write, observe `stream`.
+
+## 10. Kotlin floor
+
+The `store6` line requires **Kotlin 2.3**, raised only in minor releases and with notice.
+
+The floor is what the published artifacts actually imply, not an aspiration: every published
+`core` variant — JVM, Android, JS, wasmJs, and each native target — declares
+`org.jetbrains.kotlin:kotlin-stdlib:2.3.20`, and the build sets no `apiVersion` or `languageVersion`
+compatibility pin that would lower it. Room 3 is what drove the toolchain here.
+
+[570]: https://github.com/MobileNativeFoundation/Store/issues/570
+[534]: https://github.com/MobileNativeFoundation/Store/issues/534
diff --git a/benchmarks/README.md b/benchmarks/README.md
new file mode 100644
index 000000000..3d1b5fcca
--- /dev/null
+++ b/benchmarks/README.md
@@ -0,0 +1,116 @@
+# benchmarks
+
+`benchmarks` is an unpublished JVM harness for Store v6. It measures
+end-to-end collector attachment plus write-to-final-observation under
+a controlled schedule against the raw Source of Truth flow, and records
+structural-plus-measured evidence for telemetry unset versus configured-noop
+overhead. It is neither a published artifact nor a public API.
+
+## Run
+
+From the repository root:
+
+```shell
+./gradlew :benchmarks:benchmark
+./gradlew :benchmarks:smokeBenchmark
+./gradlew :benchmarks:calibrateBenchmark
+```
+
+`benchmark` is the default local profile. `smokeBenchmark` is the short,
+report-only CI shape. `calibrateBenchmark` uses three forks and is the only
+profile whose results may support a performance-target proposal when run on a
+documented, quiet, plugged-in machine.
+
+Result JSON is discovered recursively beneath
+`benchmarks/build/reports/benchmarks/`. The timestamped layout observed
+with `kotlinx-benchmark` 0.4.17 is evidence, not a stable path contract.
+
+Start from a clean report directory. From the repository root, this snippet
+requires exactly one non-empty, structurally valid JSON result before
+summarizing it. It sorts parameter names before rendering them.
+
+```shell
+reports_dir="benchmarks/build/reports/benchmarks"
+report_count="$(
+ find "$reports_dir" -type f -name '*.json' -print |
+ wc -l |
+ tr -d ' '
+)"
+test "$report_count" -eq 1
+report="$(find "$reports_dir" -type f -name '*.json' -print)"
+jq -e '
+ type == "array" and
+ length > 0 and
+ all(.[];
+ (.benchmark | type == "string" and length > 0) and
+ (.primaryMetric.score | type == "number") and
+ (.primaryMetric.scoreUnit | type == "string" and length > 0)
+ )
+' "$report" >/dev/null
+jq -r '
+ .[] |
+ [
+ .benchmark,
+ ((.params // {}) |
+ to_entries |
+ sort_by(.key) |
+ map("\(.key)=\(.value)") |
+ join(",")),
+ (.primaryMetric.score | tostring),
+ .primaryMetric.scoreUnit
+ ] |
+ @tsv
+' "$report"
+```
+
+Treat a clean invocation as valid only when it produces one non-empty JSON
+array with the expected benchmark inventory and numeric primary metrics.
+
+## What the numbers mean
+
+1. **The measured ratio.** The metric is the `storeStream` / `rawSotFlow`
+ average-time ratio for an end-to-end timed invocation. Within each invocation, W=1000
+ begins only after every collector receives a public result and observes one
+ epoch-unique readiness-marker write. That precondition is outside W but
+ inside the timed operation. The score includes collector launch, attachment,
+ readiness, the W schedule, and final observation. It is not pure W-only
+ latency or per-emission cost. Both sides may conflate intermediate writes.
+ `paced=true` cooperatively yields the writer; it is not an acknowledgement or
+ a guarantee that all writes are observed. `paced=false` is
+ burst/conflation.
+2. **Headline and topology boundary.** `collectors=1` is the engine-overhead
+ headline because both sides use `FakeSourceOfTruth` with matching reader
+ multiplicity and common write cost. `collectors=8` is fan-out/topology data:
+ raw opens eight reader chains while Store shares one upstream and fans out.
+ It does not isolate engine overhead.
+3. **Dispatch hops count.** Store's `Dispatchers.Default` engine hops are part
+ of Store cost. The raw side cooperates on `runBlocking`.
+4. **The telemetry-off zero-overhead claim is structural plus measured.**
+ Structural tests and code establish that unset telemetry remains null and
+ allocates no fetch mark.
+ `none`-vs-`noop` estimates incremental configured-noop overhead relative to
+ unset: non-null branches, the mark, and virtual no-op calls. It does not prove
+ literal zero cost or bound total machinery against a telemetry-free engine.
+ The ABBA allocation probe covers only the caller-thread resident path; a
+ local JMH GC profiler, when available, covers cross-thread allocations.
+5. **Hosted CI is smoke-grade.** No hosted number may support a performance
+ target. Only a local quiet-machine `calibrate` result may support a target
+ proposal.
+6. **No numeric CI gate exists.** Until a numeric performance target is
+ adopted, workflows validate execution and schema only. They contain no
+ performance threshold.
+7. **Invocation isolation.** Every multi-write stream invocation uses
+ epoch-unique readiness and sentinel values. Stores close per thread-scoped
+ trial; cold stores close per invocation.
+
+## CI boundary
+
+The blocking `:benchmarks:build` step in
+`.github/workflows/store6.yml` compiles the harness and executes every benchmark
+body once through smoke tests. It is a rot guard, not a performance gate.
+
+`.github/workflows/benchmarks.yml` runs `smokeBenchmark`, validates the
+result shape, and uploads the JSON in a non-blocking, report-only measurement
+lane. It remains outside the exact-head-green release gate. No workflow may
+assert a timing or allocation threshold until a numeric performance target is
+adopted.
diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts
new file mode 100644
index 000000000..586258cf0
--- /dev/null
+++ b/benchmarks/build.gradle.kts
@@ -0,0 +1,61 @@
+plugins {
+ id("org.jetbrains.kotlin.jvm")
+ id("org.jetbrains.kotlin.plugin.allopen") version libs.versions.baseKotlin.get()
+ alias(libs.plugins.kotlinx.benchmark)
+}
+
+kotlin { jvmToolchain(11) }
+
+// JMH requires @State classes to be non-final. The kotlinx.benchmark annotations typealias to
+// JMH's on the JVM target, so allopen keys on the JMH FQN (kotlinx-benchmark README, Kotlin/JVM
+// setup). Benchmark classes are additionally declared `open` for clarity.
+allOpen {
+ annotation("org.openjdk.jmh.annotations.State")
+}
+
+dependencies {
+ implementation(projects.core)
+ // FakeSourceOfTruth: the shared, contract-kit-passing SoT on BOTH sides of every ratio.
+ implementation(projects.testing)
+ implementation(libs.kotlinx.benchmark.runtime)
+ testImplementation(kotlin("test"))
+}
+
+benchmark {
+ configurations {
+ named("main") {
+ warmups = 5
+ iterations = 10
+ iterationTime = 1
+ iterationTimeUnit = "s"
+ mode = "avgt"
+ outputTimeUnit = "us"
+ }
+ // Fast, report-only signal; never a hard performance gate.
+ register("smoke") {
+ warmups = 2
+ iterations = 3
+ iterationTime = 500
+ iterationTimeUnit = "ms"
+ mode = "avgt"
+ outputTimeUnit = "us"
+ }
+ // Longer calibration profile for an otherwise quiet machine.
+ register("calibrate") {
+ warmups = 8
+ iterations = 15
+ iterationTime = 2
+ iterationTimeUnit = "s"
+ mode = "avgt"
+ outputTimeUnit = "us"
+ advanced("jvmForks", "3")
+ }
+ }
+ targets {
+ register("main") {
+ this as kotlinx.benchmark.gradle.JvmBenchmarkTarget
+ // PIN: JMH backend pinned for reproducibility.
+ jmhVersion = "1.37"
+ }
+ }
+}
diff --git a/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt
new file mode 100644
index 000000000..6a24e0802
--- /dev/null
+++ b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt
@@ -0,0 +1,12 @@
+package org.mobilenativefoundation.store6.benchmarks
+
+import org.mobilenativefoundation.store6.core.StoreKey
+import org.mobilenativefoundation.store6.core.StoreNamespace
+
+internal val BENCH_NAMESPACE = StoreNamespace("bench")
+
+internal class BenchKey(private val id: String) : StoreKey {
+ override val namespace: StoreNamespace = BENCH_NAMESPACE
+
+ override fun canonicalId(): String = id
+}
diff --git a/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt
new file mode 100644
index 000000000..7e1bd60d2
--- /dev/null
+++ b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt
@@ -0,0 +1,51 @@
+package org.mobilenativefoundation.store6.benchmarks
+
+import kotlinx.benchmark.Benchmark
+import kotlinx.benchmark.Blackhole
+import kotlinx.benchmark.Scope
+import kotlinx.benchmark.Setup
+import kotlinx.benchmark.State
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.mobilenativefoundation.store6.core.ExperimentalStoreApi
+import org.mobilenativefoundation.store6.core.Freshness
+import org.mobilenativefoundation.store6.core.StoreResult
+import org.mobilenativefoundation.store6.core.store
+import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth
+
+/**
+ * Supplementary: quickstart-shape spin-up. The store side deliberately includes builder cost,
+ * engine construction, and close() per invocation — that is the measurand (what a fresh
+ * store-per-screen pattern would pay). The raw side is a fresh reader collection's first row.
+ * Not part of the METRIC-1 headline ratio.
+ */
+@OptIn(ExperimentalStoreApi::class)
+@State(Scope.Thread)
+open class ColdStartBenchmark {
+ private lateinit var sot: FakeSourceOfTruth
+ private val key = BenchKey("cold")
+
+ @Setup
+ fun setup() {
+ sot = FakeSourceOfTruth()
+ runBlocking { sot.write(key, "seed") }
+ }
+
+ @Benchmark
+ fun storeColdConstructAndFirstData(bh: Blackhole) = runBlocking {
+ val store = store {
+ fetcher { error("unreachable: all reads use Freshness.LocalOnly") }
+ persistence(sot)
+ }
+ try {
+ bh.consume(store.stream(key, Freshness.LocalOnly).first { it is StoreResult.Data })
+ } finally {
+ store.close()
+ }
+ }
+
+ @Benchmark
+ fun rawColdFirstRead(bh: Blackhole) = runBlocking {
+ bh.consume(sot.reader(key).first())
+ }
+}
diff --git a/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt
new file mode 100644
index 000000000..cf1bc688a
--- /dev/null
+++ b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt
@@ -0,0 +1,56 @@
+package org.mobilenativefoundation.store6.benchmarks
+
+import kotlinx.benchmark.Benchmark
+import kotlinx.benchmark.Blackhole
+import kotlinx.benchmark.Scope
+import kotlinx.benchmark.Setup
+import kotlinx.benchmark.State
+import kotlinx.benchmark.TearDown
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.mobilenativefoundation.store6.core.ExperimentalStoreApi
+import org.mobilenativefoundation.store6.core.Freshness
+import org.mobilenativefoundation.store6.core.Store
+import org.mobilenativefoundation.store6.core.store
+import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth
+
+/**
+ * Supplementary: the one-shot resident read path. Between invocations the key quiesces, so ops
+ * exercise the resident/idle-revive path (maxIdleKeys default 128 keeps the engine parked, never
+ * destroyed) — stated in the first-data doc alongside the number.
+ */
+@OptIn(ExperimentalStoreApi::class)
+@State(Scope.Thread)
+open class GetPathBenchmark {
+ private lateinit var sot: FakeSourceOfTruth
+ private lateinit var store: Store
+ private val key = BenchKey("get")
+
+ @Setup
+ fun setup() {
+ sot = FakeSourceOfTruth()
+ store = store {
+ fetcher { error("unreachable: all reads use Freshness.LocalOnly") }
+ persistence(sot)
+ }
+ runBlocking {
+ sot.write(key, "seed")
+ store.get(key, Freshness.LocalOnly)
+ }
+ }
+
+ @TearDown
+ fun tearDown() {
+ store.close()
+ }
+
+ @Benchmark
+ fun storeGetResident(bh: Blackhole) = runBlocking {
+ bh.consume(store.get(key, Freshness.LocalOnly))
+ }
+
+ @Benchmark
+ fun rawReaderFirst(bh: Blackhole) = runBlocking {
+ bh.consume(sot.reader(key).first())
+ }
+}
diff --git a/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt
new file mode 100644
index 000000000..86f9fe8da
--- /dev/null
+++ b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt
@@ -0,0 +1,14 @@
+package org.mobilenativefoundation.store6.benchmarks
+
+import org.mobilenativefoundation.store6.core.DelicateStoreApi
+import org.mobilenativefoundation.store6.core.ExperimentalStoreApi
+import org.mobilenativefoundation.store6.core.seam.StoreTelemetry
+
+/**
+ * Configured-but-empty sink. Every hook keeps its interface-default no-op body. Comparing a store
+ * built with this sink to one with telemetry unset estimates incremental configured-noop overhead
+ * relative to the null fast path: non-null branches, the fetch-duration mark in
+ * KeyEngine.launchFetch, and virtual dispatch into empty bodies.
+ */
+@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class)
+internal object NoopTelemetry : StoreTelemetry
diff --git a/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt
new file mode 100644
index 000000000..8bd6055bd
--- /dev/null
+++ b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt
@@ -0,0 +1,149 @@
+package org.mobilenativefoundation.store6.benchmarks
+
+import kotlinx.benchmark.Benchmark
+import kotlinx.benchmark.Blackhole
+import kotlinx.benchmark.Param
+import kotlinx.benchmark.Scope
+import kotlinx.benchmark.Setup
+import kotlinx.benchmark.State
+import kotlinx.benchmark.TearDown
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.yield
+import org.mobilenativefoundation.store6.core.ExperimentalStoreApi
+import org.mobilenativefoundation.store6.core.Freshness
+import org.mobilenativefoundation.store6.core.Store
+import org.mobilenativefoundation.store6.core.StoreResult
+import org.mobilenativefoundation.store6.core.store
+import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth
+
+/**
+ * METRIC-1: stream-emission overhead versus the raw SoT flow.
+ *
+ * Both sides observe the SAME FakeSourceOfTruth class under the SAME write schedule, awaiting the
+ * SAME epoch-unique sentinel. Within each timed invocation, before workload writes, every
+ * collector first receives a public emission and then observes an epoch-unique attachment marker.
+ * That marker is one additional common write/observation per invocation outside the W=1000
+ * workload but inside the timed operation. It is an attachment precondition, not workload data,
+ * and proves the Store's long-lived reader/fan-out pipeline is attached before W begins.
+ *
+ * With collectors=1, reader multiplicity matches and storeStream/rawSotFlow is the METRIC-1
+ * engine-overhead headline: registry, reader pipeline, planning, conflation, projection, telemetry
+ * null-guard, and dispatch hops. The engine runs on Dispatchers.Default while the raw side is
+ * cooperative on the runBlocking thread; that asymmetry is Store cost and stays in the ratio.
+ *
+ * collectors=8 is a separately interpreted fan-out/topology cell: raw opens eight FakeSourceOfTruth
+ * reader chains while Store shares one upstream and fans out. It is useful end-to-end scaling data,
+ * not an isolated engine-overhead ratio.
+ *
+ * The reported score includes collector launch, attachment, readiness, the W schedule, and final
+ * observation. It is an end-to-end attach-plus-schedule measurand, not pure W-only latency or
+ * per-emission unit cost. Both sides may conflate arbitrary intermediate writes. paced=true is a
+ * cooperatively yielded writer schedule, not an acknowledgement or per-emission guarantee;
+ * paced=false is the burst/conflation schedule. The two bracket real workloads.
+ */
+@OptIn(ExperimentalStoreApi::class)
+@State(Scope.Thread)
+open class StreamEmissionBenchmark {
+ @Param("1000")
+ var writes: Int = 0
+
+ @Param("1", "8")
+ var collectors: Int = 0
+
+ @Param("false", "true")
+ var paced: Boolean = false
+
+ private lateinit var sot: FakeSourceOfTruth
+ private lateinit var store: Store
+ private val key = BenchKey("stream")
+ private var epoch = 0L
+
+ @Setup
+ fun setup() {
+ sot = FakeSourceOfTruth()
+ store = store {
+ fetcher { error("unreachable: all reads use Freshness.LocalOnly") }
+ persistence(sot)
+ }
+ runBlocking { sot.write(key, "seed") }
+ }
+
+ @TearDown
+ fun tearDown() {
+ store.close()
+ }
+
+ @Benchmark
+ fun rawSotFlow(bh: Blackhole) = runBlocking {
+ epoch += 1
+ val readiness = "ready-$epoch"
+ val sentinel = "v-$epoch-$writes"
+ coroutineScope {
+ val initialReadies = List(collectors) { CompletableDeferred() }
+ val attachedReadies = List(collectors) { CompletableDeferred() }
+ repeat(collectors) { c ->
+ launch {
+ var first = true
+ bh.consume(
+ sot.reader(key).first {
+ if (first) {
+ first = false
+ initialReadies[c].complete(Unit)
+ }
+ if (it == readiness) attachedReadies[c].complete(Unit)
+ it == sentinel
+ },
+ )
+ }
+ }
+ initialReadies.forEach { it.await() }
+ sot.write(key, readiness)
+ attachedReadies.forEach { it.await() }
+ runSchedule()
+ }
+ }
+
+ @Benchmark
+ fun storeStream(bh: Blackhole) = runBlocking {
+ epoch += 1
+ val readiness = "ready-$epoch"
+ val sentinel = "v-$epoch-$writes"
+ coroutineScope {
+ val initialReadies = List(collectors) { CompletableDeferred() }
+ val attachedReadies = List(collectors) { CompletableDeferred() }
+ repeat(collectors) { c ->
+ launch {
+ var first = true
+ bh.consume(
+ store.stream(key, Freshness.LocalOnly).first {
+ if (first) {
+ first = false
+ initialReadies[c].complete(Unit)
+ }
+ if (it is StoreResult.Data && it.value == readiness) {
+ attachedReadies[c].complete(Unit)
+ }
+ it is StoreResult.Data && it.value == sentinel
+ },
+ )
+ }
+ }
+ initialReadies.forEach { it.await() }
+ sot.write(key, readiness)
+ attachedReadies.forEach { it.await() }
+ runSchedule()
+ }
+ }
+
+ /** The identical write schedule both benchmark methods run after all collectors attach. */
+ private suspend fun runSchedule() {
+ for (i in 1..writes) {
+ sot.write(key, "v-$epoch-$i")
+ if (paced) yield()
+ }
+ }
+}
diff --git a/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt
new file mode 100644
index 000000000..494272c9b
--- /dev/null
+++ b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt
@@ -0,0 +1,54 @@
+package org.mobilenativefoundation.store6.benchmarks
+
+import kotlinx.benchmark.Benchmark
+import kotlinx.benchmark.Blackhole
+import kotlinx.benchmark.Scope
+import kotlinx.benchmark.Setup
+import kotlinx.benchmark.State
+import kotlinx.benchmark.TearDown
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.mobilenativefoundation.store6.core.ExperimentalStoreApi
+import org.mobilenativefoundation.store6.core.Freshness
+import org.mobilenativefoundation.store6.core.Store
+import org.mobilenativefoundation.store6.core.StoreResult
+import org.mobilenativefoundation.store6.core.store
+import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth
+
+/**
+ * Supplementary: attach -> first Data -> cancel against a long-lived store, repeatedly. This is
+ * the registry/reader-pipeline lifecycle that READER_PIPELINE_GRACE_MILLIS parks between
+ * collections. The raw side is the same churn against the bare reader.
+ */
+@OptIn(ExperimentalStoreApi::class)
+@State(Scope.Thread)
+open class SubscriptionChurnBenchmark {
+ private lateinit var sot: FakeSourceOfTruth
+ private lateinit var store: Store
+ private val key = BenchKey("churn")
+
+ @Setup
+ fun setup() {
+ sot = FakeSourceOfTruth()
+ store = store {
+ fetcher { error("unreachable: all reads use Freshness.LocalOnly") }
+ persistence(sot)
+ }
+ runBlocking { sot.write(key, "seed") }
+ }
+
+ @TearDown
+ fun tearDown() {
+ store.close()
+ }
+
+ @Benchmark
+ fun storeAttachFirstDataCancel(bh: Blackhole) = runBlocking {
+ bh.consume(store.stream(key, Freshness.LocalOnly).first { it is StoreResult.Data })
+ }
+
+ @Benchmark
+ fun rawAttachFirstRowCancel(bh: Blackhole) = runBlocking {
+ bh.consume(sot.reader(key).first { it != null })
+ }
+}
diff --git a/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt
new file mode 100644
index 000000000..1625a81c4
--- /dev/null
+++ b/benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt
@@ -0,0 +1,123 @@
+package org.mobilenativefoundation.store6.benchmarks
+
+import kotlinx.benchmark.Benchmark
+import kotlinx.benchmark.Blackhole
+import kotlinx.benchmark.Param
+import kotlinx.benchmark.Scope
+import kotlinx.benchmark.Setup
+import kotlinx.benchmark.State
+import kotlinx.benchmark.TearDown
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.yield
+import org.mobilenativefoundation.store6.core.ExperimentalStoreApi
+import org.mobilenativefoundation.store6.core.Freshness
+import org.mobilenativefoundation.store6.core.Store
+import org.mobilenativefoundation.store6.core.StoreResult
+import org.mobilenativefoundation.store6.core.store
+import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth
+
+/**
+ * The measured half of the telemetry "zero cost when unset" claim; the allocation-count half is
+ * TelemetryAllocationProbe (see StoreTelemetryTest.kt:114).
+ *
+ * This evidence is measured plus structural, not a literal differential against a telemetry-free
+ * engine. Structural inspection and tests establish that telemetry=none leaves the install point
+ * null, each call site takes its null fast path, and KeyEngine.launchFetch allocates no
+ * fetch-duration mark. telemetry=noop installs NoopTelemetry, so this benchmark estimates the
+ * incremental configured-noop overhead relative to that unset/null fast path: non-null branches,
+ * the fetch-duration mark, and virtual calls into no-op bodies. There is no seam-less engine to
+ * compare, so the delta neither proves literal zero cost nor bounds total telemetry machinery cost
+ * relative to such an engine.
+ *
+ * fetchGet: full fetch cycle per op (onFetchStarted + mark + onFetchSucceeded + onServe), via
+ * MustBeFresh against a constant fetcher on the DSL-default in-memory SoT (public builder path).
+ * residentServe: resident LocalOnly get (onServe only). streamEmissions: each timed invocation
+ * launches one collector, waits for its first public result and an epoch-unique readiness marker,
+ * then runs a 100-write cooperatively yielded schedule through the attached stream. Its score
+ * includes that precondition, the schedule, and final observation. Both variants may conflate
+ * intermediate writes, and onServe runs once per public delivery. The none/noop pair uses the same
+ * schedule.
+ */
+@OptIn(ExperimentalStoreApi::class)
+@State(Scope.Thread)
+open class TelemetryOverheadBenchmark {
+ @Param("none", "noop")
+ var telemetry: String = "none"
+
+ private lateinit var sot: FakeSourceOfTruth
+ private lateinit var fetchStore: Store
+ private lateinit var localStore: Store
+ private val key = BenchKey("telemetry")
+ private var epoch = 0L
+
+ @Setup
+ fun setup() {
+ sot = FakeSourceOfTruth()
+ fetchStore = store {
+ fetcher { "fetched" }
+ if (telemetry == "noop") telemetry(NoopTelemetry)
+ }
+ localStore = store {
+ fetcher { error("unreachable: all localStore reads use Freshness.LocalOnly") }
+ persistence(sot)
+ if (telemetry == "noop") telemetry(NoopTelemetry)
+ }
+ runBlocking {
+ sot.write(key, "seed")
+ localStore.get(key, Freshness.LocalOnly)
+ }
+ }
+
+ @TearDown
+ fun tearDown() {
+ fetchStore.close()
+ localStore.close()
+ }
+
+ @Benchmark
+ fun fetchGet(bh: Blackhole) = runBlocking {
+ bh.consume(fetchStore.get(key, Freshness.MustBeFresh))
+ }
+
+ @Benchmark
+ fun residentServe(bh: Blackhole) = runBlocking {
+ bh.consume(localStore.get(key, Freshness.LocalOnly))
+ }
+
+ @Benchmark
+ fun streamEmissions(bh: Blackhole) = runBlocking {
+ epoch += 1
+ val readiness = "ready-$epoch"
+ val sentinel = "v-$epoch-100"
+ coroutineScope {
+ val initialReady = CompletableDeferred()
+ val attachedReady = CompletableDeferred()
+ launch {
+ var first = true
+ bh.consume(
+ localStore.stream(key, Freshness.LocalOnly).first {
+ if (first) {
+ first = false
+ initialReady.complete(Unit)
+ }
+ if (it is StoreResult.Data && it.value == readiness) {
+ attachedReady.complete(Unit)
+ }
+ it is StoreResult.Data && it.value == sentinel
+ },
+ )
+ }
+ initialReady.await()
+ sot.write(key, readiness)
+ attachedReady.await()
+ for (i in 1..100) {
+ sot.write(key, "v-$epoch-$i")
+ yield()
+ }
+ }
+ }
+}
diff --git a/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt b/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt
new file mode 100644
index 000000000..04334c014
--- /dev/null
+++ b/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt
@@ -0,0 +1,95 @@
+package org.mobilenativefoundation.store6.benchmarks
+
+import kotlinx.benchmark.Blackhole
+import kotlin.test.Test
+
+/**
+ * Executes every benchmark method once with tiny parameters, without the JMH runner. This test is
+ * what makes the blocking `:benchmarks:build` CI step a rot guard: benchmark code cannot
+ * silently decay while the measurement lane stays non-blocking. Numbers are not read here.
+ */
+class HarnessSmokeTest {
+ // JMH's sanctioned escape hatch for constructing a Blackhole outside the runner; the string is
+ // JMH API (org.openjdk.jmh.infra.Blackhole's guarded constructor).
+ private val bh = Blackhole(
+ "Today's password is swordfish. I understand instantiating Blackholes directly is dangerous.",
+ )
+
+ @Test
+ fun streamEmissionBenchmark_bothSides_runOnce() {
+ val b = StreamEmissionBenchmark()
+ b.writes = 8
+ b.collectors = 2
+ b.paced = true
+ b.setup()
+ try {
+ b.rawSotFlow(bh)
+ b.storeStream(bh)
+ } finally {
+ b.tearDown()
+ }
+ }
+
+ @Test
+ fun streamEmissionBenchmark_burstRegime_runsOnce() {
+ val b = StreamEmissionBenchmark()
+ b.writes = 8
+ b.collectors = 1
+ b.paced = false
+ b.setup()
+ try {
+ b.rawSotFlow(bh)
+ b.storeStream(bh)
+ } finally {
+ b.tearDown()
+ }
+ }
+
+ @Test
+ fun coldStartBenchmark_bothSides_runOnce() {
+ val b = ColdStartBenchmark()
+ b.setup()
+ b.storeColdConstructAndFirstData(bh)
+ b.rawColdFirstRead(bh)
+ }
+
+ @Test
+ fun getPathBenchmark_bothSides_runOnce() {
+ val b = GetPathBenchmark()
+ b.setup()
+ try {
+ b.storeGetResident(bh)
+ b.rawReaderFirst(bh)
+ } finally {
+ b.tearDown()
+ }
+ }
+
+ @Test
+ fun subscriptionChurnBenchmark_bothSides_runOnce() {
+ val b = SubscriptionChurnBenchmark()
+ b.setup()
+ try {
+ b.storeAttachFirstDataCancel(bh)
+ b.rawAttachFirstRowCancel(bh)
+ } finally {
+ b.tearDown()
+ }
+ }
+
+ @Test
+ fun telemetryOverheadBenchmark_bothVariants_runOnce() {
+ for (variant in listOf("none", "noop")) {
+ val b = TelemetryOverheadBenchmark()
+ b.telemetry = variant
+ b.setup()
+ try {
+ b.fetchGet(bh)
+ b.residentServe(bh)
+ b.streamEmissions(bh)
+ } finally {
+ b.tearDown()
+ }
+ }
+ }
+}
diff --git a/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt b/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt
new file mode 100644
index 000000000..dcf29b8e8
--- /dev/null
+++ b/benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt
@@ -0,0 +1,173 @@
+package org.mobilenativefoundation.store6.benchmarks
+
+import kotlinx.coroutines.runBlocking
+import org.mobilenativefoundation.store6.core.ExperimentalStoreApi
+import org.mobilenativefoundation.store6.core.Freshness
+import org.mobilenativefoundation.store6.core.Store
+import org.mobilenativefoundation.store6.core.store
+import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth
+import java.lang.management.ManagementFactory
+import kotlin.test.Test
+
+/**
+ * Allocation evidence for the measured-plus-structural zero-cost-when-unset claim: this module
+ * performs the allocation-count measurement StoreTelemetryTest.kt:114 references. Reports
+ * CALLER-THREAD allocated bytes/op on the resident-serve path for telemetry-unset vs
+ * NoopTelemetry-configured stores.
+ *
+ * REPORT-ONLY by design: prints a table, asserts nothing numeric (no threshold is defined yet), and
+ * skips gracefully off HotSpot. Known scope limit, stated wherever the numbers are quoted: the
+ * fetch-duration mark allocates on the ENGINE thread (KeyEngine.launchFetch), so
+ * a caller-thread probe cannot see it — the JMH none-vs-noop timing deltas and the optional local
+ * `-prof gc` run cover the full cross-thread path.
+ */
+class TelemetryAllocationProbe {
+ @OptIn(ExperimentalStoreApi::class)
+ @Test
+ fun residentServe_callerThreadAllocationDelta_reported() {
+ val mx = ManagementFactory.getThreadMXBean()
+ if (mx !is com.sun.management.ThreadMXBean || !mx.isThreadAllocatedMemorySupported) {
+ println("TelemetryAllocationProbe: thread-allocation measurement unsupported on this JVM; skipping.")
+ return
+ }
+
+ val allocatedMemoryWasEnabled = mx.isThreadAllocatedMemoryEnabled
+ try {
+ mx.isThreadAllocatedMemoryEnabled = true
+ runAbbaProbe(mx)
+ } finally {
+ mx.isThreadAllocatedMemoryEnabled = allocatedMemoryWasEnabled
+ }
+ }
+
+ @OptIn(ExperimentalStoreApi::class)
+ private fun runAbbaProbe(mx: com.sun.management.ThreadMXBean) {
+ val warmupOps = 20_000
+ val measuredOps = 100_000
+ val key = BenchKey("alloc-probe")
+ val unsetSot = FakeSourceOfTruth()
+ val unsetStore = store {
+ fetcher { error("unreachable: LocalOnly") }
+ persistence(unsetSot)
+ }
+ val samples: AbbaSamples? = try {
+ val noopSot = FakeSourceOfTruth()
+ val noopStore = store {
+ fetcher { error("unreachable: LocalOnly") }
+ persistence(noopSot)
+ telemetry(NoopTelemetry)
+ }
+ try {
+ runBlocking {
+ unsetSot.write(key, "seed")
+ noopSot.write(key, "seed")
+ repeat(warmupOps) { unsetStore.get(key, Freshness.LocalOnly) }
+ repeat(warmupOps) { noopStore.get(key, Freshness.LocalOnly) }
+
+ val tid = Thread.currentThread().id
+ val unsetFirst = measureSamplePerOp(
+ mx = mx,
+ tid = tid,
+ store = unsetStore,
+ key = key,
+ measuredOps = measuredOps,
+ label = "unset A",
+ ) ?: return@runBlocking null
+ val noopFirst = measureSamplePerOp(
+ mx = mx,
+ tid = tid,
+ store = noopStore,
+ key = key,
+ measuredOps = measuredOps,
+ label = "noop A",
+ ) ?: return@runBlocking null
+ val noopSecond = measureSamplePerOp(
+ mx = mx,
+ tid = tid,
+ store = noopStore,
+ key = key,
+ measuredOps = measuredOps,
+ label = "noop B",
+ ) ?: return@runBlocking null
+ val unsetSecond = measureSamplePerOp(
+ mx = mx,
+ tid = tid,
+ store = unsetStore,
+ key = key,
+ measuredOps = measuredOps,
+ label = "unset B",
+ ) ?: return@runBlocking null
+ AbbaSamples(
+ unsetFirst = unsetFirst,
+ unsetSecond = unsetSecond,
+ noopFirst = noopFirst,
+ noopSecond = noopSecond,
+ )
+ }
+ } finally {
+ noopStore.close()
+ }
+ } finally {
+ unsetStore.close()
+ }
+
+ if (samples == null) return
+ val unsetMean = (samples.unsetFirst + samples.unsetSecond) / 2
+ val noopMean = (samples.noopFirst + samples.noopSecond) / 2
+ println("TelemetryAllocationProbe (resident LocalOnly get, caller-thread bytes/op; warmed ABBA):")
+ println(" telemetry unset samples : ${samples.unsetFirst}, ${samples.unsetSecond} B/op")
+ println(" NoopTelemetry samples : ${samples.noopFirst}, ${samples.noopSecond} B/op")
+ println(" telemetry unset mean : $unsetMean B/op")
+ println(" NoopTelemetry mean : $noopMean B/op")
+ println(" aggregate delta (noop-unset): ${noopMean - unsetMean} B/op")
+ }
+
+ private suspend fun measureSamplePerOp(
+ mx: com.sun.management.ThreadMXBean,
+ tid: Long,
+ store: Store,
+ key: BenchKey,
+ measuredOps: Int,
+ label: String,
+ ): Long? {
+ if (Thread.currentThread().id != tid) {
+ println(
+ "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " +
+ "caller thread changed before $label.",
+ )
+ return null
+ }
+ val before = mx.getThreadAllocatedBytes(tid)
+ if (before < 0) {
+ println(
+ "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " +
+ "$label returned before=$before.",
+ )
+ return null
+ }
+ repeat(measuredOps) { store.get(key, Freshness.LocalOnly) }
+ if (Thread.currentThread().id != tid) {
+ println(
+ "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " +
+ "caller thread changed during $label.",
+ )
+ return null
+ }
+ val after = mx.getThreadAllocatedBytes(tid)
+ if (after < 0 || after < before) {
+ println(
+ "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " +
+ "$label returned before=$before, after=$after.",
+ )
+ return null
+ }
+ return (after - before) / measuredOps
+ }
+
+ private data class AbbaSamples(
+ val unsetFirst: Long,
+ val unsetSecond: Long,
+ val noopFirst: Long,
+ val noopSecond: Long,
+ )
+}
diff --git a/bom/README.md b/bom/README.md
new file mode 100644
index 000000000..ca10534ff
--- /dev/null
+++ b/bom/README.md
@@ -0,0 +1,13 @@
+# bom
+
+`org.mobilenativefoundation.store:bom` is a Maven BOM (packaging `pom`). Importing it as a
+platform dependency pins every Store 6 artifact of the same release to one version, so you
+align the modules you use without stating each version:
+
+```kotlin
+implementation(platform("org.mobilenativefoundation.store:bom:6.0.0-SNAPSHOT"))
+```
+
+The BOM has no API surface and declares no dependencies; it only constrains versions of the
+artifacts that ship in the corresponding release (STABILITY.md §3). An artifact joins the BOM
+in the release that first ships it.
diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts
new file mode 100644
index 000000000..c208d4beb
--- /dev/null
+++ b/bom/build.gradle.kts
@@ -0,0 +1,46 @@
+plugins {
+ `java-platform`
+ id("com.vanniktech.maven.publish.base")
+}
+
+// Publication coordinates come from gradle properties (GROUP, POM_ARTIFACT_ID,
+// VERSION_NAME), matching every other store6 module. Project constraints are spelled
+// out as coordinates for the same reason: the library modules leave project.group /
+// project.version at their Gradle defaults and only the publisher maps them onto the
+// property values, so a project("") constraint here would publish the wrong version.
+// VERSION_NAME lives only in the root gradle.properties, so the constraint versions
+// below and every module's publication coordinates resolve the same single value.
+
+dependencies {
+ constraints {
+ val version = providers.gradleProperty("VERSION_NAME").get()
+ val group = providers.gradleProperty("GROUP").get()
+
+ api("$group:core:$version")
+ api("$group:testing:$version")
+ api("$group:sqldelight:$version")
+ api("$group:room:$version")
+ api("$group:compose:$version")
+ api("$group:graphql:$version")
+ api("$group:realtime:$version")
+ api("$group:mutations:$version")
+ api("$group:mutations-testing:$version")
+ api("$group:mutations-sqldelight:$version")
+ api("$group:paging-androidx:$version")
+ api("$group:opentelemetry:$version")
+ api("$group:ktor:$version")
+ api("$group:file:$version")
+ api("$group:mutations-conflicts:$version")
+ }
+}
+
+configure {
+ configure(com.vanniktech.maven.publish.JavaPlatform())
+ publishToMavenCentral(automaticRelease = true)
+
+ if (providers.gradleProperty("signingInMemoryKey").isPresent) {
+ signAllPublications()
+ }
+
+ pomFromGradleProperties()
+}
diff --git a/bom/gradle.properties b/bom/gradle.properties
new file mode 100644
index 000000000..b5ec65948
--- /dev/null
+++ b/bom/gradle.properties
@@ -0,0 +1,2 @@
+POM_NAME=bom
+POM_ARTIFACT_ID=bom
diff --git a/build.gradle.kts b/build.gradle.kts
index b44c87ae5..b324fd889 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,39 +1,110 @@
-import org.jetbrains.kotlin.gradle.dsl.JvmTarget
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
plugins {
- alias(libs.plugins.android.kotlin.multiplatform) apply false
- alias(libs.plugins.android.library) apply false
- alias(libs.plugins.kotlin.multiplatform) apply false
- alias(libs.plugins.kotlin.serialization) apply false
- alias(libs.plugins.dokka) apply false
- alias(libs.plugins.vanniktech.maven.publish) apply false
- alias(libs.plugins.atomicfu) apply false
- alias(libs.plugins.kotlin.cocoapods) apply false
alias(libs.plugins.ktlint)
- alias(libs.plugins.spotless)
- alias(libs.plugins.binary.compatibility.validator) apply false
- alias(libs.plugins.kmmbridge.github) apply false
+ id("com.diffplug.spotless") version "6.4.1"
+ // 010/011/012 toolchain: loaded once here (apply false) so every module shares one
+ // plugin classloader + version; module branches only `alias(...)` without versions.
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.sqldelight) apply false
+ alias(libs.plugins.room3) apply false
+ alias(libs.plugins.kotlin.compose.compiler) apply false
+ alias(libs.plugins.jetbrains.compose) apply false
+ alias(libs.plugins.kotlinx.benchmark) apply false
}
-tasks {
- withType {
- compilerOptions {
- jvmTarget = JvmTarget.fromTarget(libs.versions.jvmCompat.get())
+buildscript {
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ google()
+ }
+
+ dependencies {
+ classpath(libs.android.gradle.plugin)
+ classpath(libs.kotlin.gradle.plugin)
+ classpath(libs.kotlin.serialization.plugin)
+ classpath(libs.dokka.gradle.plugin)
+ classpath(libs.kover.gradle.plugin)
+ classpath(libs.ktlint.gradle.plugin)
+ classpath(libs.jacoco.gradle.plugin)
+ classpath(libs.maven.publish.plugin)
+ classpath(libs.atomic.fu.gradle.plugin)
+ classpath(libs.kmmBridge.gradle.plugin)
+ classpath(libs.binary.compatibility.validator)
+ }
+}
+
+allprojects {
+ repositories {
+ mavenCentral()
+ google()
+ }
+}
+
+subprojects {
+ tasks.withType().configureEach {
+ compilerOptions.jvmDefault.set(org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode.DISABLE)
+ }
+
+ pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
+ extensions.configure {
+ targets.withType().configureEach {
+ binaries.withType().configureEach {
+ // Kotlin 2.2.20+ exports KDoc by default; preserve the frozen Swift dumps.
+ exportKdoc.set(false)
+ }
+ }
+ }
+ }
+
+ // Store 6 modules use their own formatting conventions.
+ return@subprojects
+
+ apply(plugin = "org.jlleitschuh.gradle.ktlint")
+ apply(plugin = "com.diffplug.spotless")
+
+ ktlint {
+ disabledRules.add("import-ordering")
+ }
+
+ spotless {
+ kotlin {
+ target("src/**/*.kt")
}
}
+}
+
+tasks {
+ withType {
+ compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
withType().configureEach {
- sourceCompatibility = libs.versions.jvmCompat.get()
- targetCompatibility = libs.versions.jvmCompat.get()
+ sourceCompatibility = JavaVersion.VERSION_11.name
+ targetCompatibility = JavaVersion.VERSION_11.name
}
}
// Workaround for https://youtrack.jetbrains.com/issue/KT-62040
tasks.getByName("wrapper")
-tasks.named("updateDaemonJvm") {
- // JDK 17 is the minimum version supported by the org.gradle.toolchains.foojay-resolver-convention plugin
- languageVersion = JavaLanguageVersion.of(17)
- vendor.set(JvmVendorSpec.AZUL)
+tasks.register("refreshSwiftDumps") {
+ dependsOn(
+ ":swift-dumps-objc:refreshSwiftDump",
+ ":swift-dumps-skie:refreshSwiftDump",
+ ":swift-dumps-mutations-objc:refreshSwiftDump",
+ ":swift-dumps-mutations-skie:refreshSwiftDump",
+ ":store6-swift:refreshSwiftDump",
+ )
+}
+
+tasks.register("checkSwiftDumps") {
+ dependsOn(
+ ":swift-dumps-objc:checkSwiftDump",
+ ":swift-dumps-skie:checkSwiftDump",
+ ":swift-dumps-mutations-objc:checkSwiftDump",
+ ":swift-dumps-mutations-skie:checkSwiftDump",
+ ":store6-swift:checkSwiftDump",
+ )
}
diff --git a/cache/README.md b/cache/README.md
deleted file mode 100644
index e994c7589..000000000
--- a/cache/README.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# Cache
-
-Store depends on a subset of [Guava](https://github.com/google/guava).
-This is a shaded artifact that is Kotlin Multiplatform compatible.
-
-## Usage
-
-```kotlin
-implementation("org.mobilenativefoundation.store:cache:${STORE_VERSION}")
-```
-
-## Implementation
-
-### Model the key
-
-```kotlin
-data class Key(
- val id: String
-)
-```
-
-### Model the value
-
-```kotlin
-data class Post(
- val title: String
-)
-```
-
-### Build the cache
-
-```kotlin
- val cache = CacheBuilder()
- .maximumSize(100)
- .expireAfterWrite(1.day)
- .build()
-```
-
-## See Also
-
-https://github.com/google/guava/wiki/CachesExplained
-
-## License
-
-```text
-Copyright (c) 2017 The New York Times Company
-
-Copyright (c) 2010 The Guava Authors
-
-Licensed under the Apache License, Version 2.0 (the "License"); you may not use this library except in
-compliance with the License. You may obtain a copy of the License at
-
-www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
-"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
-language governing permissions and limitations under the License.
-```
diff --git a/cache/api/jvm/cache.api b/cache/api/jvm/cache.api
deleted file mode 100644
index 76111af4c..000000000
--- a/cache/api/jvm/cache.api
+++ /dev/null
@@ -1,69 +0,0 @@
-public abstract interface class org/mobilenativefoundation/store/cache5/Cache {
- public fun getAllPresent ()Ljava/util/Map;
- public abstract fun getAllPresent (Ljava/util/List;)Ljava/util/Map;
- public abstract fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object;
- public abstract fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object;
- public abstract fun invalidate (Ljava/lang/Object;)V
- public abstract fun invalidateAll ()V
- public abstract fun invalidateAll (Ljava/util/List;)V
- public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;)V
- public abstract fun putAll (Ljava/util/Map;)V
- public abstract fun size ()J
-}
-
-public final class org/mobilenativefoundation/store/cache5/Cache$DefaultImpls {
- public static fun getAllPresent (Lorg/mobilenativefoundation/store/cache5/Cache;)Ljava/util/Map;
-}
-
-public final class org/mobilenativefoundation/store/cache5/CacheBuilder {
- public static final field Companion Lorg/mobilenativefoundation/store/cache5/CacheBuilder$Companion;
- public fun ()V
- public final fun build ()Lorg/mobilenativefoundation/store/cache5/Cache;
- public final fun concurrencyLevel (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder;
- public final fun expireAfterAccess-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder;
- public final fun expireAfterWrite-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder;
- public final fun maximumSize (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder;
- public final fun ticker (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder;
- public final fun weigher (JLkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder;
-}
-
-public final class org/mobilenativefoundation/store/cache5/CacheBuilder$Companion {
-}
-
-public final class org/mobilenativefoundation/store/cache5/StoreMultiCache : org/mobilenativefoundation/store/cache5/Cache {
- public static final field Companion Lorg/mobilenativefoundation/store/cache5/StoreMultiCache$Companion;
- public fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V
- public synthetic fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public fun getAllPresent ()Ljava/util/Map;
- public fun getAllPresent (Ljava/util/List;)Ljava/util/Map;
- public synthetic fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object;
- public fun getIfPresent (Lorg/mobilenativefoundation/store/core5/StoreKey;)Lorg/mobilenativefoundation/store/core5/StoreData;
- public synthetic fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object;
- public fun getOrPut (Lorg/mobilenativefoundation/store/core5/StoreKey;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/core5/StoreData;
- public synthetic fun invalidate (Ljava/lang/Object;)V
- public fun invalidate (Lorg/mobilenativefoundation/store/core5/StoreKey;)V
- public fun invalidateAll ()V
- public fun invalidateAll (Ljava/util/List;)V
- public synthetic fun put (Ljava/lang/Object;Ljava/lang/Object;)V
- public fun put (Lorg/mobilenativefoundation/store/core5/StoreKey;Lorg/mobilenativefoundation/store/core5/StoreData;)V
- public fun putAll (Ljava/util/Map;)V
- public fun size ()J
-}
-
-public final class org/mobilenativefoundation/store/cache5/StoreMultiCache$Companion {
- public final fun invalidKeyErrorMessage (Ljava/lang/Object;)Ljava/lang/String;
-}
-
-public final class org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor {
- public fun (Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V
- public final fun getAllPresent ()Ljava/util/Map;
- public final fun getCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection;
- public final fun getSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Lorg/mobilenativefoundation/store/core5/StoreData$Single;
- public final fun invalidateAll ()V
- public final fun invalidateCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Z
- public final fun invalidateSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Z
- public final fun putCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;Lorg/mobilenativefoundation/store/core5/StoreData$Collection;)Z
- public final fun putSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Z
- public final fun size ()J
-}
-
diff --git a/cache/build.gradle.kts b/cache/build.gradle.kts
deleted file mode 100644
index 40c462fc0..000000000
--- a/cache/build.gradle.kts
+++ /dev/null
@@ -1,22 +0,0 @@
-plugins {
- id("org.mobilenativefoundation.store.multiplatform")
-}
-
-kotlin {
-
- sourceSets {
- commonMain {
- dependencies {
- api(libs.kotlinx.atomic.fu)
- api(projects.core)
- implementation(libs.kotlinx.coroutines.core)
- }
- }
- commonTest {
- dependencies {
- implementation(libs.junit)
- implementation(libs.kotlinx.coroutines.test)
- }
- }
- }
-}
diff --git a/cache/config/ktlint/baseline.xml b/cache/config/ktlint/baseline.xml
deleted file mode 100644
index 7d1ab2676..000000000
--- a/cache/config/ktlint/baseline.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/cache/gradle.properties b/cache/gradle.properties
deleted file mode 100644
index ac546f2a1..000000000
--- a/cache/gradle.properties
+++ /dev/null
@@ -1,3 +0,0 @@
-POM_NAME=org.mobilenativefoundation.store
-POM_ARTIFACT_ID=cache5
-POM_PACKAGING=jar
\ No newline at end of file
diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt
deleted file mode 100644
index b2e89d042..000000000
--- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package org.mobilenativefoundation.store.cache5
-
-interface Cache {
- /**
- * @return [Value] associated with [key] or `null` if there is no cached value for [key].
- */
- fun getIfPresent(key: Key): Value?
-
- /**
- * @return [Value] associated with [key], obtaining the value from [valueProducer] if necessary.
- * No observable state associated with this cache is modified until loading completes.
- * @param [valueProducer] Must not return `null`. It may either return a non-null value or throw an exception.
- * @throws ExecutionExeption If a checked exception was thrown while loading the value.
- * @throws UncheckedExecutionException If an unchecked exception was thrown while loading the value.
- * @throws ExecutionError If an error was thrown while loading the value.
- */
- fun getOrPut(
- key: Key,
- valueProducer: () -> Value,
- ): Value
-
- /**
- * @return Map of the [Value] associated with each [Key] in [keys]. Returned map only contains entries already present in the cache.
- * The default implementation provided here throws a [NotImplementedError] to maintain backward compatibility for existing implementations.
- */
- fun getAllPresent(keys: List<*>): Map
-
- /**
- * @return Map of the [Value] associated with each [Key] in the cache.
- */
- fun getAllPresent(): Map = throw NotImplementedError()
-
- /**
- * Associates [value] with [key].
- * If the cache previously contained a value associated with [key], the old value is replaced by [value].
- * Prefer [getOrPut] when using the conventional "If cached, then return. Otherwise create, cache, and then return" pattern.
- */
- fun put(
- key: Key,
- value: Value,
- )
-
- /**
- * Copies all of the mappings from the specified map to the cache. The effect of this call is
- * equivalent to that of calling [put] on this map once for each mapping from [Key] to [Value] in the specified map.
- * The behavior of this operation is undefined if the specified map is modified while the operation is in progress.
- */
- fun putAll(map: Map)
-
- /**
- * Discards any cached value associated with [key].
- */
- fun invalidate(key: Key)
-
- /**
- * Discards any cached value associated for [keys].
- */
- fun invalidateAll(keys: List)
-
- /**
- * Discards all entries in the cache.
- */
- fun invalidateAll()
-
- /**
- * @return Approximate number of entries in the cache.
- */
- fun size(): Long
-}
diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt
deleted file mode 100644
index 9a0782da5..000000000
--- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package org.mobilenativefoundation.store.cache5
-
-import kotlin.time.Duration
-
-class CacheBuilder {
- internal var concurrencyLevel = 4
- private set
- internal val initialCapacity = 16
- internal var maximumSize = UNSET
- private set
- internal var maximumWeight = UNSET
- private set
- internal var expireAfterAccess: Duration = Duration.INFINITE
- private set
- internal var expireAfterWrite: Duration = Duration.INFINITE
- private set
- internal var weigher: Weigher? = null
- private set
- internal var ticker: Ticker? = null
- private set
-
- fun concurrencyLevel(producer: () -> Int): CacheBuilder =
- apply {
- concurrencyLevel = producer.invoke()
- }
-
- fun maximumSize(maximumSize: Long): CacheBuilder =
- apply {
- if (maximumSize < 0) {
- throw IllegalArgumentException("Maximum size must be non-negative.")
- }
- this.maximumSize = maximumSize
- }
-
- fun expireAfterAccess(duration: Duration): CacheBuilder =
- apply {
- if (duration.isNegative()) {
- throw IllegalArgumentException("Duration must be non-negative.")
- }
- expireAfterAccess = duration
- }
-
- fun expireAfterWrite(duration: Duration): CacheBuilder =
- apply {
- if (duration.isNegative()) {
- throw IllegalArgumentException("Duration must be non-negative.")
- }
- expireAfterWrite = duration
- }
-
- fun ticker(ticker: Ticker): CacheBuilder =
- apply {
- this.ticker = ticker
- }
-
- fun weigher(
- maximumWeight: Long,
- weigher: Weigher,
- ): CacheBuilder =
- apply {
- if (maximumWeight < 0) {
- throw IllegalArgumentException("Maximum weight must be non-negative.")
- }
-
- this.maximumWeight = maximumWeight
- this.weigher = weigher
- }
-
- fun build(): Cache {
- if (maximumSize != -1L && weigher != null) {
- throw IllegalStateException("Maximum size cannot be combined with weigher.")
- }
- return LocalCache.LocalManualCache(this)
- }
-
- companion object {
- private const val UNSET = -1L
- }
-}
diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt
deleted file mode 100644
index a71382a9f..000000000
--- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt
+++ /dev/null
@@ -1,2082 +0,0 @@
-/*
- * Copyright (C) 2009 The Guava Authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * KMP conversion
- * Copyright (C) 2022 André Claßen
- */
-package org.mobilenativefoundation.store.cache5
-
-import kotlinx.atomicfu.AtomicArray
-import kotlinx.atomicfu.AtomicRef
-import kotlinx.atomicfu.atomic
-import kotlinx.atomicfu.atomicArrayOfNulls
-import kotlinx.atomicfu.locks.reentrantLock
-import kotlinx.atomicfu.loop
-import kotlin.math.min
-import kotlin.time.Duration
-
-internal class LocalCache(builder: CacheBuilder) {
- /**
- * Mask value for indexing into segments. The upper bits of a key's hash code are used to choose
- * the segment.
- */
- private val segmentMask: Int
-
- /**
- * Shift value for indexing within segments. Helps prevent entries that end up in the same segment
- * from also ending up in the same bucket.
- */
- private val segmentShift: Int
-
- /**
- * The segments, each of which is a specialized hash table.
- */
-
- private val segments: Array?>
-
- /**
- * Strategy for referencing values.
- */
- private val valueStrength: Strength = Strength.Strong
-
- /**
- * The maximum weight of this map. UNSET_LONG if there is no maximum.
- */
- private val maxWeight: Long
-
- /**
- * Weigher to weigh cache entries.
- */
- private val weigher: Weigher
-
- /**
- * How long after the last access to an entry the map will retain that entry.
- */
- private val expireAfterAccessNanos: Long
-
- /**
- * How long after the last write to an entry the map will retain that entry.
- */
- private val expireAfterWriteNanos: Long
-
- /**
- * Measures time in a testable way.
- */
- private val ticker: Ticker
-
- /**
- * Factory used to create new entries.
- */
- private val entryFactory: EntryFactory
-
- private val evictsBySize: Boolean get() = maxWeight >= 0
-
- private val customWeigher: Boolean get() = weigher !== OneWeigher
-
- private val expiresAfterWrite: Boolean get() = expireAfterWriteNanos > 0
-
- private val expiresAfterAccess: Boolean get() = expireAfterAccessNanos > 0
-
- private val usesAccessQueue: Boolean get() = expiresAfterAccess || evictsBySize
-
- private val usesWriteQueue: Boolean get() = expiresAfterWrite
-
- private val recordsWrite: Boolean get() = expiresAfterWrite
-
- private val recordsAccess: Boolean get() = expiresAfterAccess
-
- private val recordsTime: Boolean get() = recordsWrite || recordsAccess
-
- private val usesWriteEntries: Boolean get() = usesWriteQueue || recordsWrite
-
- private val usesAccessEntries: Boolean get() = usesAccessQueue || recordsAccess
-
- private sealed class Strength {
- /*
- * TODO(kevinb): If we strongly reference the value and aren't loading, we needn't wrap the
- * value. This could save ~8 bytes per entry.
- */
- object Strong : Strength() {
- override fun referenceValue(
- segment: Segment?,
- entry: ReferenceEntry?,
- value: V,
- weight: Int,
- ): ValueReference {
- return if (weight == 1) {
- StrongValueReference(value)
- } else {
- WeightedStrongValueReference(
- value,
- weight,
- )
- }
- }
- }
-
- /**
- * Creates a reference for the given value according to this value strength.
- */
- abstract fun referenceValue(
- segment: Segment?,
- entry: ReferenceEntry?,
- value: V,
- weight: Int,
- ): ValueReference
- }
-
- /**
- * Creates new entries.
- */
- private sealed class EntryFactory {
- object Strong : EntryFactory() {
- override fun newEntry(
- segment: Segment?,
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ): ReferenceEntry {
- return StrongEntry(key, hash, next)
- }
- }
-
- object StrongAccess : EntryFactory() {
- override fun newEntry(
- segment: Segment?,
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ): ReferenceEntry {
- return StrongAccessEntry(key, hash, next)
- }
-
- override fun copyEntry(
- segment: Segment?,
- original: ReferenceEntry,
- newNext: ReferenceEntry?,
- ): ReferenceEntry {
- val newEntry = super.copyEntry(segment, original, newNext)
- copyAccessEntry(original, newEntry)
- return newEntry
- }
- }
-
- object StrongWrite : EntryFactory() {
- override fun newEntry(
- segment: Segment?,
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ): ReferenceEntry {
- return StrongWriteEntry(key, hash, next)
- }
-
- override fun copyEntry(
- segment: Segment?,
- original: ReferenceEntry,
- newNext: ReferenceEntry?,
- ): ReferenceEntry {
- val newEntry = super.copyEntry(segment, original, newNext)
- copyWriteEntry(original, newEntry)
- return newEntry
- }
- }
-
- object StrongAccessWrite : EntryFactory() {
- override fun newEntry(
- segment: Segment?,
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ): ReferenceEntry {
- return StrongAccessWriteEntry(key, hash, next)
- }
-
- override fun copyEntry(
- segment: Segment?,
- original: ReferenceEntry,
- newNext: ReferenceEntry?,
- ): ReferenceEntry {
- val newEntry = super.copyEntry(segment, original, newNext)
- copyAccessEntry(original, newEntry)
- copyWriteEntry(original, newEntry)
- return newEntry
- }
- }
-
- /**
- * Creates a new entry.
- *
- * @param segment to create the entry for
- * @param key of the entry
- * @param hash of the key
- * @param next entry in the same bucket
- */
- abstract fun newEntry(
- segment: Segment?,
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ): ReferenceEntry
-
- /**
- * Copies an entry, assigning it a new `next` entry.
- *
- * @param original the entry to copy
- * @param newNext entry in the same bucket
- */
- // Guarded By Segment.this
- open fun copyEntry(
- segment: Segment?,
- original: ReferenceEntry,
- newNext: ReferenceEntry?,
- ): ReferenceEntry {
- return newEntry(segment, original.key, original.hash, newNext)
- }
-
- // Guarded By Segment.this
- fun copyAccessEntry(
- original: ReferenceEntry,
- newEntry: ReferenceEntry,
- ) {
- // TODO(fry): when we link values instead of entries this method can go
- // away, as can connectAccessOrder, nullifyAccessOrder.
- newEntry.accessTime = original.accessTime
- connectAccessOrder(original.previousInAccessQueue, newEntry)
- connectAccessOrder(newEntry, original.nextInAccessQueue)
- nullifyAccessOrder(original)
- }
-
- // Guarded By Segment.this
- fun copyWriteEntry(
- original: ReferenceEntry,
- newEntry: ReferenceEntry,
- ) {
- // TODO(fry): when we link values instead of entries this method can go
- // away, as can connectWriteOrder, nullifyWriteOrder.
- newEntry.writeTime = original.writeTime
- connectWriteOrder(original.previousInWriteQueue, newEntry)
- connectWriteOrder(newEntry, original.nextInWriteQueue)
- nullifyWriteOrder(original)
- }
-
- companion object {
- /**
- * Masks used to compute indices in the following table.
- */
- private const val ACCESS_MASK = 1
- private const val WRITE_MASK = 2
-
- /**
- * Look-up table for factories.
- */
- private val factories = arrayOf(Strong, StrongAccess, StrongWrite, StrongAccessWrite)
-
- fun getFactory(
- usesAccessQueue: Boolean,
- usesWriteQueue: Boolean,
- ): EntryFactory {
- val flags = ((if (usesAccessQueue) ACCESS_MASK else 0) or if (usesWriteQueue) WRITE_MASK else 0)
- return factories[flags]
- }
- }
- }
-
- /**
- * A reference to a value.
- */
- private interface ValueReference {
- /**
- * Returns the value. Does not block or throw exceptions.
- */
- fun get(): V?
-
- /**
- * Returns the weight of this entry. This is assumed to be static between calls to setValue.
- */
- val weight: Int
-
- /**
- * Returns the entry associated with this value reference, or `null` if this value
- * reference is independent of any entry.
- */
- val entry: ReferenceEntry?
-
- /**
- * Creates a copy of this reference for the given entry.
- *
- *
- *
- * `value` may be null only for a loading reference.
- */
-
- fun copyFor(
- value: V?,
- entry: ReferenceEntry?,
- ): ValueReference
-
- /**
- * Notifify pending loads that a new value was set. This is only relevant to loading
- * value references.
- */
- fun notifyNewValue(newValue: V)
-
- /**
- * Returns true if this reference contains an active value, meaning one that is still considered
- * present in the cache. Active values consist of live values, which are returned by cache
- * lookups, and dead values, which have been evicted but awaiting removal. Non-active values
- * consist strictly of loading values, though during refresh a value may be both active and
- * loading.
- */
- val isActive: Boolean
- }
-
- /**
- * An entry in a reference map.
- *
- *
- * Entries in the map can be in the following states:
- *
- *
- * Valid:
- * - Live: valid key/value are set
- * - Loading: loading is pending
- *
- *
- * Invalid:
- * - Expired: time expired (key/value may still be set)
- * - Collected: key/value was partially collected, but not yet cleaned up
- * - Unset: marked as unset, awaiting cleanup or reuse
- */
- private interface ReferenceEntry {
- /**
- * Returns the value reference from this entry.
- */
- /**
- * Sets the value reference for this entry.
- */
- var valueReference: ValueReference?
- get() = throw UnsupportedOperationException()
- set(_) = throw UnsupportedOperationException()
-
- /**
- * Returns the next entry in the chain.
- */
- val next: ReferenceEntry?
- get() = throw UnsupportedOperationException()
-
- /**
- * Returns the entry's hash.
- */
- val hash: Int
- get() = throw UnsupportedOperationException()
-
- /**
- * Returns the key for this entry.
- */
- val key: K
- get() = throw UnsupportedOperationException()
- /*
- * Used by entries that use access order. Access entries are maintained in a doubly-linked list.
- * New entries are added at the tail of the list at write time; stale entries are expired from
- * the head of the list.
- */
- /**
- * Returns the time that this entry was last accessed, in ns.
- */
- /**
- * Sets the entry access time in ns.
- */
- var accessTime: Long
- get() = throw UnsupportedOperationException()
- set(_) = throw UnsupportedOperationException()
- /**
- * Returns the next entry in the access queue.
- */
- /**
- * Sets the next entry in the access queue.
- */
- var nextInAccessQueue: ReferenceEntry
- get() = throw UnsupportedOperationException()
- set(_) = throw UnsupportedOperationException()
- /**
- * Returns the previous entry in the access queue.
- */
- /**
- * Sets the previous entry in the access queue.
- */
- var previousInAccessQueue: ReferenceEntry
- get() = throw UnsupportedOperationException()
- set(_) = throw UnsupportedOperationException()
- /*
- * Implemented by entries that use write order. Write entries are maintained in a
- * doubly-linked list. New entries are added at the tail of the list at write time and stale
- * entries are expired from the head of the list.
- */
- /**
- * Returns the time that this entry was last written, in ns.
- */
- /**
- * Sets the entry write time in ns.
- */
- var writeTime: Long
- get() = throw UnsupportedOperationException()
- set(_) = throw UnsupportedOperationException()
- /**
- * Returns the next entry in the write queue.
- */
- /**
- * Sets the next entry in the write queue.
- */
- var nextInWriteQueue: ReferenceEntry
- get() = throw UnsupportedOperationException()
- set(_) = throw UnsupportedOperationException()
- /**
- * Returns the previous entry in the write queue.
- */
- /**
- * Sets the previous entry in the write queue.
- */
- var previousInWriteQueue: ReferenceEntry
- get() = throw UnsupportedOperationException()
- set(_) = throw UnsupportedOperationException()
- }
-
- private object NullEntry : ReferenceEntry {
- override var valueReference: ValueReference?
- get() = null
- set(_) {}
-
- override val next: ReferenceEntry?
- get() = null
-
- override val hash: Int
- get() = 0
-
- override val key: Any
- get() = Unit
-
- override var accessTime: Long
- get() = 0
- set(_) {}
-
- override var nextInAccessQueue: ReferenceEntry
- get() = this
- set(_) {}
-
- override var previousInAccessQueue: ReferenceEntry
- get() = this
- set(_) {}
-
- override var writeTime: Long
- get() = 0
- set(_) {}
-
- override var nextInWriteQueue: ReferenceEntry
- get() = this
- set(_) {}
-
- override var previousInWriteQueue: ReferenceEntry
- get() = this
- set(_) {}
- }
-
- /*
- * Note: All of this duplicate code sucks, but it saves a lot of memory. If only Java had mixins!
- * To maintain this code, make a change for the strong reference type. Then, cut and paste, and
- * replace "Strong" with "Soft" or "Weak" within the pasted text. The primary difference is that
- * strong entries store the key reference directly while soft and weak entries delegate to their
- * respective superclasses.
- */
-
- /**
- * Used for strongly-referenced keys.
- */
- private open class StrongEntry(
- override val key: K, // The code below is exactly the same for each entry type.
- override val hash: Int,
- override val next: ReferenceEntry?,
- ) : ReferenceEntry {
- private val _valueReference = atomic?>(unset())
- override var valueReference: ValueReference? = _valueReference.value
- }
-
- private class StrongAccessEntry(
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ) :
- StrongEntry(key, hash, next) {
- // The code below is exactly the same for each access entry type.
-
- private val _accessTime = atomic(Long.MAX_VALUE)
- override var accessTime = _accessTime.value
-
- // Guarded By Segment.this
- override var nextInAccessQueue: ReferenceEntry = nullEntry()
-
- // Guarded By Segment.this
- override var previousInAccessQueue: ReferenceEntry = nullEntry()
- }
-
- private class StrongWriteEntry(
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ) :
- StrongEntry(key, hash, next) {
- // The code below is exactly the same for each write entry type.
- private val _writeTime = atomic(Long.MAX_VALUE)
- override var writeTime = _writeTime.value
-
- // Guarded By Segment.this
- override var nextInWriteQueue: ReferenceEntry = nullEntry()
-
- // Guarded By Segment.this
- override var previousInWriteQueue: ReferenceEntry = nullEntry()
- }
-
- private class StrongAccessWriteEntry(
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ) :
- StrongEntry(key, hash, next) {
- // The code below is exactly the same for each access entry type.
- private val _accessTime = atomic(Long.MAX_VALUE)
- override var accessTime: Long = _accessTime.value
-
- // Guarded By Segment.this
- override var nextInAccessQueue: ReferenceEntry = nullEntry()
-
- // Guarded By Segment.this
- override var previousInAccessQueue: ReferenceEntry = nullEntry()
-
- // The code below is exactly the same for each write entry type.
- private val _writeTime = atomic(Long.MAX_VALUE)
- override var writeTime: Long = _writeTime.value
-
- // Guarded By Segment.this
- override var nextInWriteQueue: ReferenceEntry = nullEntry()
-
- // Guarded By Segment.this
- override var previousInWriteQueue: ReferenceEntry = nullEntry()
- }
-
- /**
- * References a strong value.
- */
- private open class StrongValueReference(private val referent: V) :
- ValueReference {
- override fun get(): V = referent
-
- override val weight: Int = 1
- override val entry: ReferenceEntry? = null
-
- override fun copyFor(
- value: V?,
- entry: ReferenceEntry?,
- ): ValueReference = this
-
- override val isActive: Boolean = true
-
- override fun notifyNewValue(newValue: V) {}
- }
-
- /**
- * References a strong value.
- */
- private class WeightedStrongValueReference(
- referent: V,
- override val weight: Int,
- ) :
- StrongValueReference(referent)
-
- /**
- * This method is a convenience for testing. Code should call [Segment.newEntry] directly.
- */
-
- private fun newEntry(
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ): ReferenceEntry {
- val segment = segmentFor(hash)
- segment.reentrantLock.lock()
- return try {
- segment.newEntry(key, hash, next)
- } finally {
- segment.reentrantLock.unlock()
- }
- }
-
- /**
- * This method is a convenience for testing. Code should call [Segment.copyEntry] directly.
- */
- // Guarded By Segment.this
- private fun copyEntry(
- original: ReferenceEntry,
- newNext: ReferenceEntry?,
- ): ReferenceEntry? {
- val hash = original.hash
- return segmentFor(hash).copyEntry(original, newNext)
- }
-
- /**
- * This method is a convenience for testing. Code should call [Segment.setValue] instead.
- */
- // Guarded By Segment.this
- private fun newValueReference(
- entry: ReferenceEntry,
- value: V,
- weight: Int,
- ): ValueReference {
- val hash = entry.hash
- return valueStrength.referenceValue(segmentFor(hash), entry, value, weight)
- }
-
- private fun hash(key: K): Int = rehash(key.hashCode())
-
- /**
- * Returns the segment that should be used for a key with the given hash.
- *
- * @param hash the hash code for the key
- * @return the segment
- */
- private fun segmentFor(hash: Int): Segment =
- // TODO(fry): Lazily create segments?
- segments[hash ushr segmentShift and segmentMask] as Segment
-
- private fun createSegment(
- initialCapacity: Int,
- maxSegmentWeight: Long,
- ): Segment = Segment(this, initialCapacity, maxSegmentWeight)
- // expiration
-
- /**
- * Returns true if the entry has expired.
- */
- private fun isExpired(
- entry: ReferenceEntry,
- now: Long,
- ): Boolean =
- if (expiresAfterAccess && now - entry.accessTime >= expireAfterAccessNanos) {
- true
- } else {
- expiresAfterWrite && now - entry.writeTime >= expireAfterWriteNanos
- }
-
- // Inner Classes
-
- private class SegmentTable(val size: Int) {
- private val table: AtomicArray?> = atomicArrayOfNulls(size)
-
- operator fun get(idx: Int) = table[idx].value
-
- operator fun set(
- idx: Int,
- value: ReferenceEntry?,
- ) {
- table[idx].value = value
- }
- }
-
- /**
- * Segments are specialized versions of hash tables.
- */
- private class Segment(
- private val map: LocalCache,
- initialCapacity: Int,
- private val maxSegmentWeight: Long,
- ) {
- /*
- * TODO(fry): Consider copying variables (like evictsBySize) from outer class into this class.
- * It will require more memory but will reduce indirection.
- */
- /*
- * Segments maintain a table of entry lists that are ALWAYS kept in a consistent state, so can
- * be read without locking. Next fields of nodes are immutable (final). All list additions are
- * performed at the front of each bin. This makes it easy to check changes, and also fast to
- * traverse. When nodes would otherwise be changed, new nodes are created to replace them. This
- * works well for hash tables since the bin lists tend to be short. (The average length is less
- * than two.)
- *
- * Read operations can thus proceed without locking, but rely on selected uses of volatiles to
- * ensure that completed write operations performed by other threads are noticed. For most
- * purposes, the "count" field, tracking the number of elements, serves as that volatile
- * variable ensuring visibility. This is convenient because this field needs to be read in many
- * read operations anyway:
- *
- * - All (unsynchronized) read operations must first read the "count" field, and should not
- * look at table entries if it is 0.
- *
- * - All (synchronized) write operations should write to the "count" field after structurally
- * changing any bin. The operations must not take any action that could even momentarily
- * cause a concurrent read operation to see inconsistent data. This is made easier by the
- * nature of the read operations in Map. For example, no operation can reveal that the table
- * has grown but the threshold has not yet been updated, so there are no atomicity requirements
- * for this with respect to reads.
- *
- * As a guide, all critical volatile reads and writes to the count field are marked in code
- * comments.
- */
-
- val reentrantLock = reentrantLock()
-
- /**
- * The number of live elements in this segment's region.
- */
- private val count = atomic(0)
-
- /**
- * The weight of the live elements in this segment's region.
- */
- private var totalWeight: Long = 0
-
- /**
- * Number of updates that alter the size of the table. This is used during bulk-read methods to
- * make sure they see a consistent snapshot: If modCounts change during a traversal of segments
- * loading size or checking containsValue, then we might have an inconsistent view of state
- * so (usually) must retry.
- */
- private var modCount = 0
-
- /**
- * The table is expanded when its size exceeds this threshold. (The value of this field is
- * always `(int) (capacity * 0.75)`.)
- */
- private var threshold = 0
-
- /**
- * The per-segment table.
- */
- private val table: AtomicRef>
-
- /**
- * The recency queue is used to record which entries were accessed for updating the access
- * list's ordering. It is drained as a batch operation when either the DRAIN_THRESHOLD is
- * crossed or a write occurs on the segment.
- */
- private val recencyQueue: Queue>
-
- /**
- * A counter of the number of reads since the last write, used to drain queues on a small
- * fraction of read operations.
- */
- private val readCount = atomic(0)
-
- /**
- * A queue of elements currently in the map, ordered by write time. Elements are added to the
- * tail of the queue on write.
- */
- private val writeQueue: MutableQueue>
-
- /**
- * A queue of elements currently in the map, ordered by access time. Elements are added to the
- * tail of the queue on access (note that writes count as accesses).
- */
- private val accessQueue: MutableQueue>
-
- fun newEntry(
- key: K,
- hash: Int,
- next: ReferenceEntry?,
- ): ReferenceEntry = map.entryFactory.newEntry(this, key, hash, next)
-
- /**
- * Copies `original` into a new entry chained to `newNext`. Returns the new entry,
- * or `null` if `original` was already garbage collected.
- */
- fun copyEntry(
- original: ReferenceEntry,
- newNext: ReferenceEntry?,
- ): ReferenceEntry? {
- val valueReference = original.valueReference
- val value = valueReference!!.get()
- if (value == null && valueReference.isActive) {
- // value collected
- return null
- }
- val newEntry = map.entryFactory.copyEntry(this, original, newNext)
- newEntry.valueReference = valueReference.copyFor(value, newEntry)
- return newEntry
- }
-
- /**
- * Sets a new value of an entry. Adds newly created entries at the end of the access queue.
- */
- fun setValue(
- entry: ReferenceEntry,
- key: K,
- value: V,
- now: Long,
- ) {
- val previous = entry.valueReference
- val weight = map.weigher(key, value)
- if (weight < 0) throw IllegalStateException("Weights must be non-negative")
- entry.valueReference = map.valueStrength.referenceValue(this, entry, value, weight)
- recordWrite(entry, weight, now)
- previous?.notifyNewValue(value)
- }
-
- // recency queue, shared by expiration and eviction
-
- /**
- * Records the relative order in which this read was performed by adding `entry` to the
- * recency queue. At write-time, or when the queue is full past the threshold, the queue will
- * be drained and the entries therein processed.
- *
- *
- *
- * Note: locked reads should use [.recordLockedRead].
- */
- private fun recordRead(
- entry: ReferenceEntry,
- now: Long,
- ) {
- if (map.recordsAccess) {
- entry.accessTime = now
- }
- recencyQueue.add(entry)
- }
-
- /**
- * Updates the eviction metadata that `entry` was just read. This currently amounts to
- * adding `entry` to relevant eviction lists.
- *
- *
- *
- * Note: this method should only be called under lock, as it directly manipulates the
- * eviction queues. Unlocked reads should use [.recordRead].
- */
- private fun recordLockedRead(
- entry: ReferenceEntry,
- now: Long,
- ) {
- if (map.recordsAccess) {
- entry.accessTime = now
- }
- accessQueue.add(entry)
- }
-
- /**
- * Updates eviction metadata that `entry` was just written. This currently amounts to
- * adding `entry` to relevant eviction lists.
- */
- private fun recordWrite(
- entry: ReferenceEntry,
- weight: Int,
- now: Long,
- ) {
- // we are already under lock, so drain the recency queue immediately
- drainRecencyQueue()
- totalWeight += weight.toLong()
- if (map.recordsAccess) {
- entry.accessTime = now
- }
- if (map.recordsWrite) {
- entry.writeTime = now
- }
- accessQueue.add(entry)
- writeQueue.add(entry)
- }
-
- /**
- * Drains the recency queue, updating eviction metadata that the entries therein were read in
- * the specified relative order. This currently amounts to adding them to relevant eviction
- * lists (accounting for the fact that they could have been removed from the map since being
- * added to the recency queue).
- */
- private fun drainRecencyQueue() {
- while (true) {
- val e = recencyQueue.poll() ?: break
- // An entry may be in the recency queue despite it being removed from
- // the map . This can occur when the entry was concurrently read while a
- // writer is removing it from the segment or after a clear has removed
- // all of the segment's entries.
- if (accessQueue.contains(e)) {
- accessQueue.add(e)
- }
- }
- }
- // expiration
-
- /**
- * Cleanup expired entries when the lock is available.
- */
- private fun tryExpireEntries(now: Long) {
- if (reentrantLock.tryLock()) {
- try {
- expireEntries(now)
- } finally {
- reentrantLock.unlock()
- // don't call postWriteCleanup as we're in a read
- }
- }
- }
-
- private fun expireEntries(now: Long) {
- drainRecencyQueue()
- while (true) {
- val e = writeQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break
- if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) {
- throw AssertionError()
- }
- }
-
- while (true) {
- val e = accessQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break
- if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) {
- throw AssertionError()
- }
- }
- }
-
- // eviction
- private fun enqueueNotification(
- entry: ReferenceEntry,
- cause: RemovalCause?,
- ) {
- enqueueNotification(entry.key, entry.hash, entry.valueReference, cause)
- }
-
- private fun enqueueNotification(
- key: K?,
- hash: Int,
- valueReference: ValueReference?,
- cause: RemovalCause?,
- ) {
- valueReference?.weight?.toLong()?.apply {
- totalWeight -= this
- }
- }
-
- /**
- * Performs eviction if the segment is over capacity. Avoids flushing the entire cache if the
- * newest entry exceeds the maximum weight all on its own.
- *
- * @param newest the most recently added entry
- */
- private fun evictEntries(newest: ReferenceEntry) {
- if (!map.evictsBySize) {
- return
- }
- drainRecencyQueue()
-
- // If the newest entry by itself is too heavy for the segment, don't bother evicting
- // anything else, just that
- if (newest.valueReference!!.weight > maxSegmentWeight) {
- if (!removeEntry(newest, newest.hash, RemovalCause.SIZE)) {
- throw AssertionError()
- }
- }
- while (totalWeight > maxSegmentWeight) {
- val e = nextEvictable
- if (!removeEntry(e, e.hash, RemovalCause.SIZE)) {
- throw AssertionError()
- }
- }
- }
-
- // TODO(fry): instead implement this with an eviction head
-
- private val nextEvictable: ReferenceEntry
- get() {
- for (e in accessQueue) {
- val weight = e.valueReference!!.weight
- if (weight > 0) {
- return e
- }
- }
- throw AssertionError()
- }
-
- /**
- * Returns first entry of bin for given hash.
- */
- private fun getFirst(hash: Int): ReferenceEntry? {
- // read this volatile field only once
- val table = table.value
- return table[hash and table.size - 1]
- }
-
- // Specialized implementations of map methods
- private fun getEntry(
- key: K,
- hash: Int,
- ): ReferenceEntry? {
- var e = getFirst(hash)
- while (e != null) {
- if (e.hash != hash) {
- e = e.next
- continue
- }
- val entryKey = e.key
- if (key == entryKey) {
- return e
- }
- e = e.next
- }
- return null
- }
-
- private fun getLiveEntry(
- key: K,
- hash: Int,
- now: Long,
- ): ReferenceEntry? {
- val e = getEntry(key, hash)
- if (e == null) {
- return null
- } else if (map.isExpired(e, now)) {
- tryExpireEntries(now)
- return null
- }
- return e
- }
-
- /**
- * Gets the value from an entry. Returns null if the entry is invalid, partially-collected,
- * loading, or expired.
- */
-
- fun get(
- key: K,
- hash: Int,
- ): V? {
- return try {
- if (count.value != 0) { // read-volatile
- val now = map.ticker()
- val e = getLiveEntry(key, hash, now) ?: return null
- val value = e.valueReference?.get()
- if (value != null) {
- recordRead(e, now)
- return value
- }
- }
- null
- } finally {
- postReadCleanup()
- }
- }
-
- fun getOrPut(
- key: K,
- hash: Int,
- defaultValue: () -> V,
- ): V {
- reentrantLock.lock()
- return try {
- if (count.value != 0) { // read-volatile
- val now = map.ticker()
- val e = getLiveEntry(key, hash, now)
- val value = e?.valueReference?.get()
- if (value != null) {
- recordRead(e, now)
- return value
- }
- }
- val default = defaultValue()
- put(key, hash, default, false)
- default
- } finally {
- reentrantLock.unlock()
- postReadCleanup()
- }
- }
-
- fun put(
- key: K,
- hash: Int,
- value: V,
- onlyIfAbsent: Boolean,
- ): V? {
- reentrantLock.lock()
- return try {
- val now = map.ticker()
- preWriteCleanup(now)
- if (count.value + 1 > threshold) { // ensure capacity
- expand()
- }
- val table = table.value
- val index = hash and table.size - 1
- val first = table[index]
-
- // Look for an existing entry.
- var e: ReferenceEntry? = first
- while (e != null) {
- val entryKey = e.key
- if (e.hash == hash && key == entryKey) {
- // We found an existing entry.
- val valueReference = e.valueReference
- val entryValue = valueReference!!.get()
- return when {
- entryValue == null -> {
- ++modCount
- val newCount =
- if (valueReference.isActive) {
- enqueueNotification(
- key,
- hash,
- valueReference,
- RemovalCause.COLLECTED,
- )
- setValue(e, key, value, now)
- count.value // count remains unchanged
- } else {
- setValue(e, key, value, now)
- count.value + 1
- }
- count.value = newCount // write-volatile
- evictEntries(e)
- null
- }
-
- onlyIfAbsent -> {
- // Mimic
- // "if (!map.containsKey(key)) ...
- // else return map.get(key);
- recordLockedRead(e, now)
- entryValue
- }
-
- else -> {
- // clobber existing entry, count remains unchanged
- ++modCount
- enqueueNotification(
- key,
- hash,
- valueReference,
- RemovalCause.REPLACED,
- )
- setValue(e, key, value, now)
- evictEntries(e)
- entryValue
- }
- }
- }
- e = e.next
- }
-
- // Create a new entry.
- ++modCount
- val newEntry = newEntry(key, hash, first)
- setValue(newEntry, key, value, now)
- table[index] = newEntry
- count.plusAssign(1)
- evictEntries(newEntry)
- null
- } finally {
- reentrantLock.unlock()
- postWriteCleanup()
- }
- }
-
- fun remove(
- key: K,
- hash: Int,
- ): V? {
- reentrantLock.lock()
- return try {
- val now = map.ticker()
- preWriteCleanup(now)
- val table = table.value
- val index = hash and table.size - 1
- val first = table[index]
- var e = first
- while (e != null) {
- val entryKey = e.key
- if (e.hash == hash && key == entryKey) {
- val valueReference = e.valueReference
- val entryValue = valueReference!!.get()
- val cause: RemovalCause =
- when {
- entryValue != null -> {
- RemovalCause.EXPLICIT
- }
-
- valueReference.isActive -> {
- RemovalCause.COLLECTED
- }
-
- else -> {
- // currently loading
- return null
- }
- }
- ++modCount
- val newFirst =
- removeValueFromChain(
- first!!,
- e,
- entryKey,
- hash,
- valueReference,
- cause,
- )
- val newCount = count.value - 1
- table[index] = newFirst
- count.value = newCount // write-volatile
- return entryValue
- }
- e = e.next
- }
- null
- } finally {
- reentrantLock.unlock()
- postWriteCleanup()
- }
- }
-
- fun clear() {
- if (count.value != 0) { // read-volatile
- reentrantLock.lock()
- try {
- val table = table.value
- for (i in 0 until table.size) {
- var e = table[i]
- while (e != null) {
- // Loading references aren't actually in the map yet.
- if (e.valueReference!!.isActive) {
- enqueueNotification(e, RemovalCause.EXPLICIT)
- }
- e = e.next
- }
- }
- for (i in 0 until table.size) {
- table[i] = null
- }
- writeQueue.clear()
- accessQueue.clear()
- readCount.value = 0
- ++modCount
- count.value = 0 // write-volatile
- } finally {
- reentrantLock.unlock()
- postWriteCleanup()
- }
- }
- }
-
- /**
- * Expands the table if possible.
- */
- private fun expand() {
- val oldTable = table.value
- val oldCapacity = oldTable.size
- if (oldCapacity >= MAXIMUM_CAPACITY) {
- return
- }
-
- /*
- * Reclassify nodes in each list to new Map. Because we are using power-of-two expansion, the
- * elements from each bin must either stay at same index, or move with a power of two offset.
- * We eliminate unnecessary node creation by catching cases where old nodes can be reused
- * because their next fields won't change. Statistically, at the default threshold, only
- * about one-sixth of them need cloning when a table doubles. The nodes they replace will be
- * garbage collectable as soon as they are no longer referenced by any reader thread that may
- * be in the midst of traversing table right now.
- */
- var newCount = count.value
- val newTable = SegmentTable(oldCapacity shl 1)
- threshold = newTable.size * 3 / 4
- val newMask = newTable.size - 1
- for (oldIndex in 0 until oldCapacity) {
- // We need to guarantee that any existing reads of old Map can
- // proceed. So we cannot yet null out each bin.
- val head = oldTable[oldIndex] ?: continue
-
- val next = head.next
- val headIndex = head.hash and newMask
-
- // Single node on list
- if (next == null) {
- newTable[headIndex] = head
- } else {
- // Reuse the consecutive sequence of nodes with the same target
- // index from the end of the list. tail points to the first
- // entry in the reusable list.
- var tail = head
- var tailIndex = headIndex
- var entry = next
- while (entry != null) {
- val newIndex = entry.hash and newMask
- if (newIndex != tailIndex) {
- // The index changed. We'll need to copy the previous entry.
- tailIndex = newIndex
- tail = entry
- }
- entry = entry.next
- }
- newTable[tailIndex] = tail
-
- // Clone nodes leading up to the tail.
- var headEntry = head
- while (headEntry !== tail) {
- val newIndex = headEntry.hash and newMask
- val newNext = newTable[newIndex]
- val newFirst = copyEntry(headEntry, newNext)
- if (newFirst != null) {
- newTable[newIndex] = newFirst
- } else {
- removeCollectedEntry(headEntry)
- newCount--
- }
- headEntry = headEntry.next ?: break
- }
- }
- }
- table.value = newTable
- count.value = newCount
- }
-
- private fun removeValueFromChain(
- first: ReferenceEntry,
- entry: ReferenceEntry,
- key: K,
- hash: Int,
- valueReference: ValueReference,
- cause: RemovalCause?,
- ): ReferenceEntry? {
- enqueueNotification(key, hash, valueReference, cause)
- writeQueue.remove(entry)
- accessQueue.remove(entry)
- return removeEntryFromChain(first, entry)
- }
-
- private fun removeEntryFromChain(
- first: ReferenceEntry,
- entry: ReferenceEntry,
- ): ReferenceEntry? {
- var newCount = count.value
- var newFirst = entry.next
- var e = first
- while (e !== entry) {
- val next = copyEntry(e, newFirst)
- if (next != null) {
- newFirst = next
- } else {
- removeCollectedEntry(e)
- newCount--
- }
- e = e.next ?: break
- }
- count.value = newCount
- return newFirst
- }
-
- private fun removeCollectedEntry(entry: ReferenceEntry) {
- enqueueNotification(entry, RemovalCause.COLLECTED)
- writeQueue.remove(entry)
- accessQueue.remove(entry)
- }
-
- private fun removeEntry(
- entry: ReferenceEntry,
- hash: Int,
- cause: RemovalCause?,
- ): Boolean {
- val table = table.value
- val index = hash and table.size - 1
- val first = table[index]
- var e = first
-
- while (e != null) {
- if (e === entry) {
- ++modCount
- val newFirst =
- removeValueFromChain(
- first!!,
- e,
- e.key,
- hash,
- e.valueReference!!,
- cause,
- )
- val newCount = count.value - 1
- table[index] = newFirst
- count.value = newCount // write-volatile
- return true
- }
- e = e.next
- }
- return false
- }
-
- /**
- * Performs routine cleanup following a read. Normally cleanup happens during writes. If cleanup
- * is not observed after a sufficient number of reads, try cleaning up from the read thread.
- */
- private fun postReadCleanup() {
- if (readCount.incrementAndGet() and DRAIN_THRESHOLD == 0) {
- cleanUp()
- }
- }
-
- /**
- * Performs routine cleanup prior to executing a write. This should be called every time a
- * write thread acquires the segment lock, immediately after acquiring the lock.
- *
- *
- *
- * Post-condition: expireEntries has been run.
- */
- private fun preWriteCleanup(now: Long) {
- runLockedCleanup(now)
- }
-
- /**
- * Performs routine cleanup following a write.
- */
- private fun postWriteCleanup() {
- runUnlockedCleanup()
- }
-
- fun cleanUp() {
- val now = map.ticker()
- runLockedCleanup(now)
- runUnlockedCleanup()
- }
-
- private fun runLockedCleanup(now: Long) {
- if (reentrantLock.tryLock()) {
- try {
- expireEntries(now) // calls drainRecencyQueue
- readCount.value = 0
- } finally {
- reentrantLock.unlock()
- }
- }
- }
-
- private fun runUnlockedCleanup() {
- // locked cleanup may generate notifications we can send unlocked
- /*if (!isHeldByCurrentThread) {
- map.processPendingNotifications()
- }*/
- }
-
- fun activeEntries(): Map {
- // read-volatile
- if (count.value == 0) return emptyMap()
- reentrantLock.lock()
- return try {
- val activeMap = mutableMapOf()
- val table = table.value
- for (i in 0 until table.size) {
- var e = table[i]
- while (e != null) {
- if (e.valueReference?.isActive == true) {
- activeMap[e.key] = e.valueReference?.get()!!
- }
- e = e.next
- }
- }
- activeMap.ifEmpty { emptyMap() }
- } finally {
- reentrantLock.unlock()
- }
- }
-
- init {
- threshold = initialCapacity * 3 / 4 // 0.75
- if (!map.customWeigher && threshold.toLong() == maxSegmentWeight) {
- // prevent spurious expansion before eviction
- threshold++
- }
- table = atomic(SegmentTable(initialCapacity))
- recencyQueue = if (map.usesAccessQueue) AtomicLinkedQueue() else discardingQueue()
- writeQueue = if (map.usesWriteQueue) WriteQueue() else discardingQueue()
- accessQueue = if (map.usesAccessQueue) AccessQueue() else discardingQueue()
- }
- }
- // Queues
-
- private interface Queue {
- fun poll(): T?
-
- fun add(value: T)
- }
-
- private interface MutableQueue : Queue, Iterable {
- fun peek(): E?
-
- fun isEmpty(): Boolean
-
- val size: Int
-
- fun clear()
-
- fun remove(element: E): Boolean
-
- fun contains(element: E): Boolean
- }
-
- private class AtomicLinkedQueue : Queue {
- private val head: AtomicRef> = atomic(Node(null))
- private val tail: AtomicRef> = atomic(head.value)
-
- private class Node(val value: T) {
- val next = atomic?>(null)
- }
-
- override fun add(value: T) {
- val node: Node = Node(value)
- tail.loop { curTail ->
- val curNext = curTail.next.value
- if (curNext != null) {
- tail.compareAndSet(curTail, curNext)
- return@loop
- }
- if (curTail.next.compareAndSet(null, node)) {
- tail.compareAndSet(curTail, node)
- return
- }
- }
- }
-
- override fun poll(): T? {
- head.loop { curHead ->
- val next = curHead.next.value ?: return null
- if (head.compareAndSet(curHead, next)) return next.value
- }
- }
- }
-
- /**
- * A custom queue for managing eviction order. Note that this is tightly integrated with `ReferenceEntry`, upon which it relies to perform its linking.
- *
- *
- *
- * Note that this entire implementation makes the assumption that all elements which are in
- * the map are also in this queue, and that all elements not in the queue are not in the map.
- *
- *
- *
- * The benefits of creating our own queue are that (1) we can replace elements in the middle
- * of the queue as part of copyWriteEntry, and (2) the contains method is highly optimized
- * for the current model.
- */
-
- private class WriteQueue : MutableQueue> {
- private val head: ReferenceEntry =
- object : ReferenceEntry {
- override var writeTime: Long
- get() = Long.MAX_VALUE
- set(_) {}
- override var nextInWriteQueue: ReferenceEntry = this
- override var previousInWriteQueue: ReferenceEntry = this
- }
-
- // implements Queue
- override fun add(value: ReferenceEntry) {
- // unlink
- connectWriteOrder(value.previousInWriteQueue, value.nextInWriteQueue)
-
- // add to tail
- connectWriteOrder(head.previousInWriteQueue, value)
- connectWriteOrder(value, head)
- }
-
- override fun peek(): ReferenceEntry? {
- val next = head.nextInWriteQueue
- return if (next === head) null else next
- }
-
- override fun poll(): ReferenceEntry? {
- val next = head.nextInWriteQueue
- if (next === head) {
- return null
- }
- remove(next)
- return next
- }
-
- override fun remove(element: ReferenceEntry): Boolean {
- val previous = element.previousInWriteQueue
- val next = element.nextInWriteQueue
- connectWriteOrder(previous, next)
- nullifyWriteOrder(element)
- return next !== NullEntry
- }
-
- override fun contains(element: ReferenceEntry): Boolean = element.nextInWriteQueue !== NullEntry
-
- override fun isEmpty(): Boolean = head.nextInWriteQueue === head
-
- override val size: Int
- get() {
- var size = 0
- var e = head.nextInWriteQueue
- while (e !== head) {
- size++
- e = e.nextInWriteQueue
- }
- return size
- }
-
- override fun clear() {
- var e = head.nextInWriteQueue
- while (e !== head) {
- val next = e.nextInWriteQueue
- nullifyWriteOrder(e)
- e = next
- }
- head.nextInWriteQueue = head
- head.previousInWriteQueue = head
- }
-
- override fun iterator(): Iterator> =
- iterator {
- var value = peek()
- while (value != null) {
- yield(value)
- val next = value.nextInWriteQueue
- value = if (next === head) null else next
- }
- }
- }
-
- /**
- * A custom queue for managing access order. Note that this is tightly integrated with
- * `ReferenceEntry`, upon which it reliese to perform its linking.
- *
- *
- *
- * Note that this entire implementation makes the assumption that all elements which are in
- * the map are also in this queue, and that all elements not in the queue are not in the map.
- *
- *
- *
- * The benefits of creating our own queue are that (1) we can replace elements in the middle
- * of the queue as part of copyWriteEntry, and (2) the contains method is highly optimized
- * for the current model.
- */
- private class AccessQueue : MutableQueue> {
- private val head: ReferenceEntry =
- object : ReferenceEntry {
- override var accessTime: Long
- get() = Long.MAX_VALUE
- set(_) {}
- override var nextInAccessQueue: ReferenceEntry = this
- override var previousInAccessQueue: ReferenceEntry = this
- }
-
- // implements Queue
- override fun add(value: ReferenceEntry) {
- // unlink
- connectAccessOrder(value.previousInAccessQueue, value.nextInAccessQueue)
-
- // add to tail
- connectAccessOrder(head.previousInAccessQueue, value)
- connectAccessOrder(value, head)
- }
-
- override fun peek(): ReferenceEntry? {
- val next = head.nextInAccessQueue
- return if (next === head) null else next
- }
-
- override fun poll(): ReferenceEntry? {
- val next = head.nextInAccessQueue
- if (next === head) {
- return null
- }
- remove(next)
- return next
- }
-
- override fun remove(element: ReferenceEntry): Boolean {
- val previous = element.previousInAccessQueue
- val next = element.nextInAccessQueue
- connectAccessOrder(previous, next)
- nullifyAccessOrder(element)
- return next !== NullEntry
- }
-
- override fun contains(element: ReferenceEntry): Boolean = element.nextInAccessQueue !== NullEntry
-
- override fun isEmpty(): Boolean = head.nextInAccessQueue === head
-
- override val size: Int
- get() {
- var size = 0
- var e = head.nextInAccessQueue
- while (e !== head) {
- size++
- e = e.nextInAccessQueue
- }
- return size
- }
-
- override fun clear() {
- var e = head.nextInAccessQueue
- while (e !== head) {
- val next = e.nextInAccessQueue
- nullifyAccessOrder(e)
- e = next
- }
- head.nextInAccessQueue = head
- head.previousInAccessQueue = head
- }
-
- override fun iterator(): Iterator> =
- iterator {
- var value = peek()
- while (value != null) {
- yield(value)
- val next = value.nextInAccessQueue
- value = if (next === head) null else next
- }
- }
- }
-
- // Cache support
- fun cleanUp() {
- for (segment in segments) {
- segment?.cleanUp()
- }
- }
-
- // ConcurrentMap methods
- fun getIfPresent(key: K): V? {
- val hash = hash(key)
- return segmentFor(hash).get(key, hash)
- }
-
- fun put(
- key: K,
- value: V,
- ): V? {
- val hash = hash(key)
- return segmentFor(hash).put(key, hash, value, false)
- }
-
- fun getOrPut(
- key: K,
- defaultValue: () -> V,
- ): V {
- val hash = hash(key)
- return segmentFor(hash).getOrPut(key, hash, defaultValue)
- }
-
- fun clear() {
- for (segment in segments) {
- segment?.clear()
- }
- }
-
- fun remove(key: K): V? {
- val hash = hash(key)
- return segmentFor(hash).remove(key, hash)
- }
-
- fun getAllPresent(): Map {
- return buildMap {
- for (segment in segments) {
- segment?.let { putAll(it.activeEntries()) }
- }
- }
- }
-
- // Serialization Support
- internal class LocalManualCache private constructor(private val localCache: LocalCache) :
- Cache {
- constructor(builder: CacheBuilder) : this(LocalCache(builder))
-
- // Cache methods
- override fun getIfPresent(key: K): V? {
- return localCache.getIfPresent(key)
- }
-
- override fun put(
- key: K,
- value: V,
- ) {
- localCache.put(key, value)
- }
-
- override fun invalidate(key: K) {
- localCache.remove(key)
- }
-
- override fun getOrPut(
- key: K,
- valueProducer: () -> V,
- ): V {
- return localCache.getOrPut(key, valueProducer)
- }
-
- override fun getAllPresent(keys: List<*>): Map {
- return localCache.getAllPresent().filterKeys { it in keys }
- }
-
- override fun getAllPresent(): Map {
- return localCache.getAllPresent()
- }
-
- override fun invalidateAll(keys: List) {
- TODO("Not yet implemented")
- }
-
- override fun putAll(map: Map) {
- TODO("Not yet implemented")
- }
-
- override fun invalidateAll() {
- localCache.clear()
- }
-
- override fun size(): Long {
- TODO("Not yet implemented")
- }
- }
-
- companion object {
- /*
- * The basic strategy is to subdivide the table among Segments, each of which itself is a
- * concurrently readable hash table. The map supports non-blocking reads and concurrent writes
- * across different segments.
- *
- * If a maximum size is specified, a best-effort bounding is performed per segment, using a
- * page-replacement algorithm to determine which entries to evict when the capacity has been
- * exceeded.
- *
- * The page replacement algorithm's data structures are kept casually consistent with the map. The
- * ordering of writes to a segment is sequentially consistent. An update to the map and recording
- * of reads may not be immediately reflected on the algorithm's data structures. These structures
- * are guarded by a lock and operations are applied in batches to avoid lock contention. The
- * penalty of applying the batches is spread across threads so that the amortized cost is slightly
- * higher than performing just the operation without enforcing the capacity constraint.
- *
- * This implementation uses a per-segment queue to record a memento of the additions, removals,
- * and accesses that were performed on the map. The queue is drained on writes and when it exceeds
- * its capacity threshold.
- *
- * The Least Recently Used page replacement algorithm was chosen due to its simplicity, high hit
- * rate, and ability to be implemented with O(1) time complexity. The initial LRU implementation
- * operates per-segment rather than globally for increased implementation simplicity. We expect
- * the cache hit rate to be similar to that of a global LRU algorithm.
- */
- // Constants
- private val OneWeigher: Weigher = { _, _ -> 1 }
-
- /**
- * The maximum capacity, used if a higher value is implicitly specified by either of the
- * constructors with arguments. MUST be a power of two <= 1<<30 to ensure that entries are
- * indexable using ints.
- */
- const val MAXIMUM_CAPACITY = 1 shl 30
-
- /**
- * The maximum number of segments to allow; used to bound constructor arguments.
- */
- const val MAX_SEGMENTS = 1 shl 16 // slightly conservative
-
- /**
- * Number of cache access operations that can be buffered per segment before the cache's recency
- * ordering information is updated. This is used to avoid lock contention by recording a memento
- * of reads and delaying a lock acquisition until the threshold is crossed or a mutation occurs.
- *
- *
- *
- * This must be a (2^n)-1 as it is used as a mask.
- */
- const val DRAIN_THRESHOLD = 0x3F
-
- /**
- * Placeholder. Indicates that the value hasn't been set yet.
- */
- private val UNSET: ValueReference =
- object : ValueReference {
- override fun get(): Any? {
- return null
- }
-
- override val weight: Int
- get() = 0
- override val entry: ReferenceEntry?
- get() = null
-
- override fun copyFor(
- value: Any?,
- entry: ReferenceEntry