Skip to content

Commit b19d5ae

Browse files
committed
fix list objects ordering to follow AWS S3
Signed-off-by: Utkarsh Srivastava <[email protected]> add more tests for asserting the correctness of utf8 sorting Signed-off-by: Utkarsh Srivastava <[email protected]> fix order when markers are involved Signed-off-by: Utkarsh Srivastava <[email protected]> add caching for name buffers - lifetime limited to per invocation of list_objects Signed-off-by: Utkarsh Srivastava <[email protected]>
1 parent 3c8a29a commit b19d5ae

File tree

4 files changed

+253
-49
lines changed

4 files changed

+253
-49
lines changed

src/sdk/namespace_fs.js

+99-49
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const stream_utils = require('../util/stream_utils');
1818
const buffer_utils = require('../util/buffer_utils');
1919
const size_utils = require('../util/size_utils');
2020
const native_fs_utils = require('../util/native_fs_utils');
21+
const js_utils = require('../util/js_utils');
2122
const ChunkFS = require('../util/chunk_fs');
2223
const LRUCache = require('../util/lru_cache');
2324
const nb_native = require('../util/nb_native');
@@ -90,14 +91,48 @@ const XATTR_METADATA_IGNORE_LIST = [
9091
];
9192

9293
/**
93-
* @param {fs.Dirent} a
94-
* @param {fs.Dirent} b
95-
* @returns {1|-1|0}
94+
* get_simple_cache returns a simple function
95+
* which can be used to access the cached data
96+
*
97+
* If the cached data doesn't exist then the
98+
* given loader function is called with the
99+
* requested key and the result is cached.
100+
*
101+
* There is no cache invalidation.
102+
* @template {string | number | symbol} T
103+
* @template U
104+
*
105+
* @param {(key: T) => U} loader
106+
* @param {Partial<Record<T, U>>} [cache={}]
107+
* @returns {(key: T) => U}
96108
*/
97-
function sort_entries_by_name(a, b) {
98-
if (a.name < b.name) return -1;
99-
if (a.name > b.name) return 1;
100-
return 0;
109+
function get_simple_cache(loader, cache = {}) {
110+
return key => {
111+
const cached = cache[key];
112+
if (cached) return cached;
113+
114+
const computed = loader(key);
115+
cache[key] = computed;
116+
return computed;
117+
};
118+
}
119+
120+
/**
121+
* sort_entries_by_name_with_cache generates a sorter which
122+
* sorts by UTF8 and caches the intermediate buffers generated
123+
* to avoid recomputation
124+
* @param {Record<string, Buffer>} [cache={}]
125+
* @returns {(a: fs.Dirent, b: fs.Dirent) => -1|0|1}
126+
*/
127+
function sort_entries_by_name_with_cache(cache = {}) {
128+
const get_cached_name = get_simple_cache(name => Buffer.from(name, 'utf8'), cache);
129+
130+
return function(a, b) {
131+
const a_name = get_cached_name(a.name);
132+
const b_name = get_cached_name(b.name);
133+
134+
return Buffer.compare(a_name, b_name);
135+
};
101136
}
102137

103138
function _get_version_id_by_stat({ino, mtimeNsBigint}) {
@@ -144,27 +179,33 @@ function _get_filename(file_name) {
144179
}
145180
return file_name;
146181
}
182+
147183
/**
148-
* @param {fs.Dirent} first_entry
149-
* @param {fs.Dirent} second_entry
150-
* @returns {Number}
184+
* sort_entries_by_name_and_time_with_cache generates a sorter which sorts
185+
* items but UTF8 encoding of the name and in case of conflict uses mtime
186+
* to resolve it.
187+
* @param {Record<string, Buffer>} [cache={}]
188+
* @returns {(first_entry: fs.Dirent, second_entry: fs.Dirent) => -1|0|1}
151189
*/
152-
function sort_entries_by_name_and_time(first_entry, second_entry) {
153-
const first_entry_name = _get_filename(first_entry.name);
154-
const second_entry_name = _get_filename(second_entry.name);
155-
if (first_entry_name === second_entry_name) {
156-
const first_entry_mtime = _get_mtime_from_filename(first_entry.name);
157-
const second_entry_mtime = _get_mtime_from_filename(second_entry.name);
158-
// To sort the versions in the latest first order,
159-
// below logic is followed
160-
if (second_entry_mtime < first_entry_mtime) return -1;
161-
if (second_entry_mtime > first_entry_mtime) return 1;
162-
return 0;
163-
} else {
164-
if (first_entry_name < second_entry_name) return -1;
165-
if (first_entry_name > second_entry_name) return 1;
166-
return 0;
167-
}
190+
function sort_entries_by_name_and_time_with_cache(cache = {}) {
191+
const _get_filename_with_cache = get_simple_cache(name => Buffer.from(_get_filename(name), 'utf8'), cache);
192+
return function(first_entry, second_entry) {
193+
const first_entry_name = _get_filename_with_cache(first_entry.name);
194+
const second_entry_name = _get_filename_with_cache(second_entry.name);
195+
196+
const compare_result = Buffer.compare(first_entry_name, second_entry_name);
197+
if (compare_result === 0) {
198+
const first_entry_mtime = _get_mtime_from_filename(first_entry.name);
199+
const second_entry_mtime = _get_mtime_from_filename(second_entry.name);
200+
// To sort the versions in the latest first order,
201+
// below logic is followed
202+
if (second_entry_mtime < first_entry_mtime) return -1;
203+
if (second_entry_mtime > first_entry_mtime) return 1;
204+
return 0;
205+
}
206+
207+
return compare_result;
208+
};
168209
}
169210

170211
// This is helper function for list object version
@@ -249,14 +290,6 @@ function is_sparse_file(stat) {
249290
return (stat.blocks * 512 < stat.size);
250291
}
251292

252-
/**
253-
* @param {fs.Dirent} e
254-
* @returns {string}
255-
*/
256-
function get_entry_name(e) {
257-
return e.name;
258-
}
259-
260293
/**
261294
* @param {string} name
262295
* @returns {fs.Dirent}
@@ -306,14 +339,14 @@ function to_fs_xattr(xattr) {
306339
const dir_cache = new LRUCache({
307340
name: 'nsfs-dir-cache',
308341
make_key: ({ dir_path }) => dir_path,
309-
load: async ({ dir_path, fs_context }) => {
342+
load: async ({ dir_path, fs_context, dirent_name_cache = {} }) => {
310343
const time = Date.now();
311344
const stat = await nb_native().fs.stat(fs_context, dir_path);
312345
let sorted_entries;
313346
let usage = config.NSFS_DIR_CACHE_MIN_DIR_SIZE;
314347
if (stat.size <= config.NSFS_DIR_CACHE_MAX_DIR_SIZE) {
315348
sorted_entries = await nb_native().fs.readdir(fs_context, dir_path);
316-
sorted_entries.sort(sort_entries_by_name);
349+
sorted_entries.sort(sort_entries_by_name_with_cache(dirent_name_cache));
317350
for (const ent of sorted_entries) {
318351
usage += ent.name.length + 4;
319352
}
@@ -341,7 +374,7 @@ const dir_cache = new LRUCache({
341374
const versions_dir_cache = new LRUCache({
342375
name: 'nsfs-versions-dir-cache',
343376
make_key: ({ dir_path }) => dir_path,
344-
load: async ({ dir_path, fs_context }) => {
377+
load: async ({ dir_path, fs_context, dirent_name_cache = {} }) => {
345378
const time = Date.now();
346379
const stat = await nb_native().fs.stat(fs_context, dir_path);
347380
const version_path = dir_path + "/" + HIDDEN_VERSIONS_PATH;
@@ -375,7 +408,7 @@ const versions_dir_cache = new LRUCache({
375408
old_versions_after_rename
376409
} = await _rename_null_version(old_versions, fs_context, version_path);
377410
const entries = latest_versions.concat(old_versions_after_rename);
378-
sorted_entries = entries.sort(sort_entries_by_name_and_time);
411+
sorted_entries = entries.sort(sort_entries_by_name_and_time_with_cache(dirent_name_cache));
379412
// rename back version to include 'null' suffix.
380413
if (renamed_null_versions_set.size > 0) {
381414
for (const ent of sorted_entries) {
@@ -387,7 +420,7 @@ const versions_dir_cache = new LRUCache({
387420
}
388421
}
389422
} else {
390-
sorted_entries = latest_versions.sort(sort_entries_by_name);
423+
sorted_entries = latest_versions.sort(sort_entries_by_name_with_cache(dirent_name_cache));
391424
}
392425
/*eslint no-unused-expressions: ["error", { "allowTernary": true }]*/
393426
for (const ent of sorted_entries) {
@@ -647,6 +680,10 @@ class NamespaceFS {
647680
/** @type {Result[]} */
648681
const results = [];
649682

683+
const _dirent_name_cache = {};
684+
/** @type {(key: string) => Buffer} */
685+
const dirent_name_cache = get_simple_cache(key => Buffer.from(key, 'utf8'), _dirent_name_cache);
686+
650687
/**
651688
* @param {string} dir_key
652689
* @returns {Promise<void>}
@@ -655,6 +692,7 @@ class NamespaceFS {
655692
if (this._is_hidden_version_path(dir_key)) {
656693
return;
657694
}
695+
658696
// /** @type {fs.Dir} */
659697
let dir_handle;
660698
/** @type {ReaddirCacheItem} */
@@ -688,9 +726,11 @@ class NamespaceFS {
688726
// Since versions are arranged next to latest object in the latest first order,
689727
// no need to find the sorted last index. Push the ".versions/#VERSION_OBJECT" as
690728
// they are in order
691-
if (results.length && r.key < results[results.length - 1].key &&
729+
730+
const r_key_buf = dirent_name_cache(r.key);
731+
if (results.length && Buffer.compare(r_key_buf, dirent_name_cache(results[results.length - 1].key)) === -1 &&
692732
!this._is_hidden_version_path(r.key)) {
693-
pos = _.sortedLastIndexBy(results, r, a => a.key);
733+
pos = js_utils.sortedLastIndexBy(results, curr => Buffer.compare(dirent_name_cache(curr.key), r_key_buf) === -1);
694734
} else {
695735
pos = results.length;
696736
}
@@ -720,7 +760,7 @@ class NamespaceFS {
720760
const process_entry = async ent => {
721761
// dbg.log0('process_entry', dir_key, ent.name);
722762
if ((!ent.name.startsWith(prefix_ent) ||
723-
ent.name < marker_curr ||
763+
Buffer.compare(dirent_name_cache(ent.name), dirent_name_cache(marker_curr)) === -1 ||
724764
ent.name === this.get_bucket_tmpdir_name() ||
725765
ent.name === config.NSFS_FOLDER_OBJECT_NAME) ||
726766
this._is_hidden_version_path(ent.name)) {
@@ -748,9 +788,17 @@ class NamespaceFS {
748788
if (!(await this.check_access(fs_context, dir_path))) return;
749789
try {
750790
if (list_versions) {
751-
cached_dir = await versions_dir_cache.get_with_cache({ dir_path, fs_context });
791+
cached_dir = await versions_dir_cache.get_with_cache({
792+
dir_path,
793+
fs_context,
794+
dirent_name_cache: _dirent_name_cache,
795+
});
752796
} else {
753-
cached_dir = await dir_cache.get_with_cache({ dir_path, fs_context });
797+
cached_dir = await dir_cache.get_with_cache({
798+
dir_path,
799+
fs_context,
800+
dirent_name_cache: _dirent_name_cache,
801+
});
754802
}
755803
} catch (err) {
756804
if (['ENOENT', 'ENOTDIR'].includes(err.code)) {
@@ -786,11 +834,13 @@ class NamespaceFS {
786834
{name: start_marker}
787835
) + 1;
788836
} else {
789-
marker_index = _.sortedLastIndexBy(
790-
sorted_entries,
791-
make_named_dirent(marker_curr),
792-
get_entry_name
793-
);
837+
const marker_curr_buf = dirent_name_cache(make_named_dirent(marker_curr).name);
838+
839+
marker_index = js_utils.sortedLastIndexBy(sorted_entries, curr => {
840+
const curr_cache_buf = dirent_name_cache(curr.name);
841+
const compare_res = Buffer.compare(curr_cache_buf, marker_curr_buf);
842+
return compare_res === -1 || compare_res === 0;
843+
});
794844
}
795845

796846
// handling a scenario in which key_marker points to an object inside a directory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/* Copyright (C) 2024 NooBaa */
2+
'use strict';
3+
4+
const { sortedLastIndexBy } = require("../../../util/js_utils");
5+
6+
describe('test js_utils.js', () => {
7+
describe('sortedLastIndexBy', () => {
8+
it('should correctly find position for number array', () => {
9+
const test_table = [{
10+
array: [1, 3, 4, 5],
11+
target: 0,
12+
expected_position: 0,
13+
}, {
14+
array: [1, 3, 4, 5],
15+
target: 2,
16+
expected_position: 1,
17+
}, {
18+
array: [1, 3, 4, 5],
19+
target: 6,
20+
expected_position: 4,
21+
}];
22+
23+
for (const entry of test_table) {
24+
expect(sortedLastIndexBy(entry.array, curr => curr < entry.target)).toBe(entry.expected_position);
25+
}
26+
});
27+
28+
it('should correctly find position for string array', () => {
29+
const test_table = [{
30+
array: ["a", "b", "c", "d"],
31+
target: "A",
32+
expected_position: 0,
33+
}, {
34+
array: ["a", "b", "d", "e"],
35+
target: "c",
36+
expected_position: 2,
37+
}, {
38+
array: ["a", "b", "c", "d"],
39+
target: "z",
40+
expected_position: 4,
41+
}];
42+
43+
for (const entry of test_table) {
44+
expect(sortedLastIndexBy(entry.array, curr => curr < entry.target)).toBe(entry.expected_position);
45+
}
46+
});
47+
48+
it('should correctly find position for utf8 string (buffer) array', () => {
49+
// Editors might not render characters in this array properly but that's not a mistake,
50+
// it's intentional to use such characters - sourced from:
51+
// https://forum.moonwalkinc.com/t/determining-s3-listing-order/116
52+
const array = ["file_ꦏ_1.txt", "file__2.txt", "file__3.txt", "file_𐎣_4.txt"];
53+
const array_of_bufs = array.map(item => Buffer.from(item, 'utf8'));
54+
55+
const test_table = [{
56+
array: array_of_bufs,
57+
target: array_of_bufs[0],
58+
expected_position: 0,
59+
}, {
60+
array: array_of_bufs,
61+
target: array_of_bufs[2],
62+
expected_position: 2,
63+
}, {
64+
array: array_of_bufs,
65+
target: array_of_bufs[3],
66+
expected_position: 3,
67+
}, {
68+
array: array_of_bufs,
69+
target: Buffer.from("file_𐎣_40.txt", 'utf8'),
70+
expected_position: 4,
71+
}];
72+
73+
for (const entry of test_table) {
74+
expect(sortedLastIndexBy(entry.array, curr => curr.compare(entry.target) === -1)).toBe(entry.expected_position);
75+
}
76+
});
77+
});
78+
});
79+

src/test/unit_tests/test_ns_list_objects.js

+49
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,55 @@ function test_ns_list_objects(ns, object_sdk, bucket) {
186186

187187
});
188188

189+
mocha.describe('utf8 specific basic tests', function() {
190+
// source of keys: https://forum.moonwalkinc.com/t/determining-s3-listing-order/116
191+
const strange_utf8_keys = ["file_ꦏ_1.txt", "file__2.txt", "file__3.txt", "file_𐎣_4.txt"];
192+
193+
mocha.before(async function() {
194+
await create_keys([
195+
...strange_utf8_keys,
196+
]);
197+
});
198+
mocha.after(async function() {
199+
await delete_keys([
200+
...strange_utf8_keys,
201+
]);
202+
});
203+
204+
mocha.it('verify byte-by-byte order sort for utf8 encoded keys', async function() {
205+
const r = await ns.list_objects({ bucket }, object_sdk);
206+
assert.deepStrictEqual(r.is_truncated, false);
207+
assert.deepStrictEqual(r.common_prefixes, []);
208+
assert.deepStrictEqual(r.objects.map(it => it.key), strange_utf8_keys);
209+
});
210+
211+
mocha.it('verify byte-by-byte order sort for utf8 encoded keys with markers', async function() {
212+
const test_table = [
213+
{
214+
limit: 1,
215+
},
216+
{
217+
limit: 2,
218+
},
219+
{
220+
limit: 3,
221+
},
222+
{
223+
limit: 4,
224+
},
225+
{
226+
limit: 5,
227+
}
228+
];
229+
for (const test of test_table) {
230+
const r = await truncated_listing({ bucket, limit: test.limit });
231+
assert.deepStrictEqual(r.is_truncated, false);
232+
assert.deepStrictEqual(r.common_prefixes, []);
233+
assert.deepStrictEqual(r.objects.map(it => it.key), strange_utf8_keys);
234+
}
235+
});
236+
});
237+
189238
mocha.describe('list objects - dirs', function() {
190239

191240
this.timeout(10 * 60 * 1000); // eslint-disable-line no-invalid-this

0 commit comments

Comments
 (0)