-
Notifications
You must be signed in to change notification settings - Fork 74
feat(data): serve contiguous data by content digest #753
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -110,6 +110,22 @@ describe('ReadThroughDataCache', function () { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| getDataAttributesByHash: async (hash: string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hash === 'knownHash') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hash: 'knownHash', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| size: 100, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| contentType: 'text/plain', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: 'knownId', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Indexed in contiguous_data but the blob is missing from the store. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hash === 'indexedButNoBlob') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { hash: 'indexedButNoBlob', size: 50 }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line no-empty-pattern | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| saveDataContentAttributes: async ({}: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -169,6 +185,52 @@ describe('ReadThroughDataCache', function () { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mock.restoreAll(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe('getDataByHash', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('streams indexed content addressed by hash, marked self-verifying', async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const result = await readThroughDataCache.getDataByHash('knownHash'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.hash, 'knownHash'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.size, 100); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.totalSize, 100); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.sourceContentType, 'text/plain'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Content-addressed reads are self-verifying and always local-cache. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.verified, true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.trusted, true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.cached, true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The single internal lookup also surfaces the representative id. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.representativeId, 'knownId'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const chunks: Buffer[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for await (const chunk of result.stream) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| chunks.push(Buffer.from(chunk)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(Buffer.concat(chunks).toString(), 'simulated data'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('honors a byte region', async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const result = await readThroughDataCache.getDataByHash('knownHash', { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| offset: 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| size: 4, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.size, 4); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert.equal(result.totalSize, 100); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+210
to
+217
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assert that the region is passed to the store call. This test currently validates return metadata only; it can still pass if 🧪 Suggested assertion hardening it('honors a byte region', async () => {
+ let receivedRegion: { offset: number; size: number } | undefined;
+ mock.method(
+ mockContiguousDataStore,
+ 'get',
+ async (hash: string, region?: { offset: number; size: number }) => {
+ if (hash === 'knownHash') {
+ receivedRegion = region;
+ const stream = new Readable();
+ stream.push('simulated data');
+ stream.push(null);
+ return stream;
+ }
+ return undefined;
+ },
+ );
+
const result = await readThroughDataCache.getDataByHash('knownHash', {
offset: 0,
size: 4,
});
assert.equal(result.size, 4);
assert.equal(result.totalSize, 100);
+ assert.deepEqual(receivedRegion, { offset: 0, size: 4 });
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('rejects when the hash is not indexed', async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await assert.rejects( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readThroughDataCache.getDataByHash('unknownHash'), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /No content indexed/, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('rejects when indexed but the blob is missing from the store', async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await assert.rejects( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readThroughDataCache.getDataByHash('indexedButNoBlob'), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /No cached data/, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe('getCachedData', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('should return data from cache when available', async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let calledWithArgument: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,6 +20,7 @@ import * as metrics from '../metrics.js'; | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { KvJsonStore } from '../store/kv-attributes-store.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { startChildSpan } from '../tracing.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ByHashData, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ContiguousData, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ContiguousDataAttributesStore, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ContiguousDataIndex, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -435,6 +436,64 @@ export class ReadThroughDataCache implements ContiguousDataSource { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Serve contiguous data addressed directly by its content hash (the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * value emitted as X-AR-IO-Digest and used as the on-disk cache key). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Unlike {@link getData}, there is no id, no manifest/ArNS resolution, and | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * no upstream fall-through: Arweave and peers address by transaction id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * not by content hash, so a hash we have never materialized cannot be | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * fetched on demand. The endpoint therefore serves only content already | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * present in the local content store. Because the store is keyed by the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * SHA-256 of the bytes, a successful read is self-verifying — the bytes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * provably hash to the requested digest — so the result is reported as | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * verified, trusted, and cached. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * @throws if no content is indexed for the hash, or the indexed blob is | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * missing from the store (evicted/pruned between index and read). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async getDataByHash( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hash: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| region?: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| offset: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| size: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<ByHashData> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const attributes = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await this.contiguousDataIndex.getDataAttributesByHash(hash); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (attributes === undefined) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`No content indexed for hash: ${hash}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cacheStream = await this.dataStore.get(hash, region); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cacheStream === undefined) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`No cached data found for hash: ${hash}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const requestType = region !== undefined ? 'range' : 'full'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| metrics.getDataStreamSuccessesTotal.inc({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class: this.constructor.name, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| source: 'cache', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request_type: requestType, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const totalSize = attributes.size; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hash, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| stream: cacheStream, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| size: region?.size ?? totalSize, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| totalSize, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sourceContentType: attributes.contentType, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Content-addressed: the bytes provably hash to the requested digest. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| verified: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| trusted: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cached: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // A representative id resolving to this hash (the same single lookup | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // above), so callers need not re-query to emit id-scoped headers. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| representativeId: attributes.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+473
to
+494
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Defer success metrics until the stream actually completes.
📈 Suggested fix const requestType = region !== undefined ? 'range' : 'full';
- metrics.getDataStreamSuccessesTotal.inc({
- class: this.constructor.name,
- source: 'cache',
- request_type: requestType,
- });
+ cacheStream.once('error', () => {
+ metrics.getDataStreamErrorsTotal.inc({
+ class: this.constructor.name,
+ source: 'cache',
+ request_type: requestType,
+ });
+ });
+ cacheStream.once('end', () => {
+ metrics.getDataStreamSuccessesTotal.inc({
+ class: this.constructor.name,
+ source: 'cache',
+ request_type: requestType,
+ });
+ });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async getData({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| requestAttributes, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix broken link fragment.
The anchor reference at line 229 uses
#contiguous-data-store, but the actual anchor at line 99 is<a id="contiguous-data">. Update the link to#contiguous-datato match the existing anchor.🔗 Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 229-229: Link fragments should be valid
(MD051, link-fragments)
🤖 Prompt for AI Agents